mirror of
https://github.com/langgenius/dify.git
synced 2026-08-30 17:11:50 +08:00
refactor(api): extract init validation into layered application service (#40046)
This commit is contained in:
@@ -65,6 +65,20 @@ forbidden_modules =
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:init-validation-service-boundary]
|
||||
name = Initialization validation application service is framework and persistence neutral
|
||||
type = forbidden
|
||||
source_modules =
|
||||
services.init_validation_service
|
||||
forbidden_modules =
|
||||
controllers
|
||||
extensions
|
||||
flask
|
||||
models
|
||||
repositories
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:explore-banner-query-service-boundary]
|
||||
name = Explore banner query application service is framework and persistence neutral
|
||||
type = forbidden
|
||||
|
||||
@@ -30,6 +30,11 @@ class DeploymentConfig(BaseSettings):
|
||||
default=DeploymentEdition.COMMUNITY,
|
||||
)
|
||||
|
||||
INIT_PASSWORD: str = Field(
|
||||
description="Password required before initializing a self-hosted deployment",
|
||||
default="",
|
||||
)
|
||||
|
||||
DEPLOY_ENV: str = Field(
|
||||
description="Deployment environment (e.g., 'PRODUCTION', 'DEVELOPMENT'), default to PRODUCTION",
|
||||
default="PRODUCTION",
|
||||
|
||||
@@ -1,17 +1,11 @@
|
||||
import os
|
||||
from typing import Literal
|
||||
|
||||
from flask import session
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from configs import dify_config
|
||||
from controllers.fastopenapi import console_router
|
||||
from enums import DeploymentEdition
|
||||
from extensions.ext_database import db
|
||||
from models.model import DifySetup
|
||||
from services.account_service import TenantService
|
||||
from extensions.ext_application_services import application_services
|
||||
from services.init_validation_service import AlreadyInitializedError, InvalidInitializationPasswordError
|
||||
|
||||
from .error import AlreadySetupError, InitValidateFailedError
|
||||
from .wraps import only_edition_self_hosted
|
||||
@@ -36,7 +30,7 @@ class InitValidateResponse(BaseModel):
|
||||
)
|
||||
def get_init_status() -> InitStatusResponse:
|
||||
"""Get initialization validation status."""
|
||||
init_status = get_init_validate_status()
|
||||
init_status = is_init_validated()
|
||||
if init_status:
|
||||
return InitStatusResponse(status="finished")
|
||||
return InitStatusResponse(status="not_started")
|
||||
@@ -51,25 +45,19 @@ def get_init_status() -> InitStatusResponse:
|
||||
@only_edition_self_hosted
|
||||
def validate_init_password(payload: InitValidatePayload) -> InitValidateResponse:
|
||||
"""Validate initialization password."""
|
||||
tenant_count = TenantService.get_tenant_count(session=db.session())
|
||||
if tenant_count > 0:
|
||||
raise AlreadySetupError()
|
||||
|
||||
if payload.password != os.environ.get("INIT_PASSWORD"):
|
||||
try:
|
||||
application_services().init_validation.validate_password(payload.password)
|
||||
except AlreadyInitializedError:
|
||||
raise AlreadySetupError() from None
|
||||
except InvalidInitializationPasswordError:
|
||||
session["is_init_validated"] = False
|
||||
raise InitValidateFailedError()
|
||||
raise InitValidateFailedError() from None
|
||||
|
||||
session["is_init_validated"] = True
|
||||
return InitValidateResponse(result="success")
|
||||
|
||||
|
||||
def get_init_validate_status() -> bool:
|
||||
if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.CLOUD:
|
||||
if os.environ.get("INIT_PASSWORD"):
|
||||
if session.get("is_init_validated"):
|
||||
return True
|
||||
|
||||
with Session(db.engine) as db_session:
|
||||
return db_session.execute(select(DifySetup)).scalar_one_or_none() is not None
|
||||
|
||||
return True
|
||||
def is_init_validated() -> bool:
|
||||
return application_services().init_validation.is_validated(
|
||||
session_validated=bool(session.get("is_init_validated")),
|
||||
)
|
||||
|
||||
@@ -14,7 +14,7 @@ from services.setup_service import (
|
||||
)
|
||||
|
||||
from .error import AlreadySetupError, NotInitValidateError
|
||||
from .init_validate import get_init_validate_status
|
||||
from .init_validate import is_init_validated
|
||||
from .wraps import mark_setup_completed, only_edition_self_hosted
|
||||
|
||||
|
||||
@@ -85,7 +85,7 @@ def setup_system(payload: SetupRequestPayload) -> SetupResponse:
|
||||
ip_address=extract_remote_ip(request),
|
||||
language=payload.language,
|
||||
),
|
||||
initialization_validated=get_init_validate_status(),
|
||||
initialization_validated=is_init_validated(),
|
||||
)
|
||||
except SetupAlreadyCompletedError:
|
||||
raise AlreadySetupError() from None
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import contextlib
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from functools import wraps
|
||||
@@ -319,7 +318,7 @@ def setup_required[R](view: Callable[..., R]) -> Callable[..., R]:
|
||||
# preserving support for plain functions used in tests and utilities.
|
||||
# check setup
|
||||
if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.CLOUD and not _is_setup_completed():
|
||||
if os.environ.get("INIT_PASSWORD"):
|
||||
if dify_config.INIT_PASSWORD:
|
||||
raise NotInitValidateError()
|
||||
raise NotSetupError()
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ from services.explore_banner_query_service import ExploreBannerQueryService
|
||||
from services.feature_query_service import FeatureQueryService
|
||||
from services.feature_service import FeatureService
|
||||
from services.feature_service_gateway import FeatureServiceGateway
|
||||
from services.init_validation_service import InitValidationService
|
||||
from services.schema_definition_service import SchemaDefinitionService
|
||||
from services.setup_adapters import RedisSetupLock, RegisterServiceAccountProvisioner
|
||||
from services.setup_service import SetupService
|
||||
@@ -37,6 +38,7 @@ class ApplicationServices:
|
||||
schema_definitions: SchemaDefinitionService
|
||||
setup: SetupService
|
||||
feature_queries: FeatureQueryService
|
||||
init_validation: InitValidationService
|
||||
workspace_queries: WorkspaceQueryService
|
||||
workspace_member_queries: WorkspaceMemberQueryService
|
||||
|
||||
@@ -45,6 +47,7 @@ def build_application_services(
|
||||
*,
|
||||
database_client: sessionmaker[Session],
|
||||
deployment_edition: DeploymentEdition,
|
||||
initialization_password: str,
|
||||
redis: RedisClientWrapper,
|
||||
) -> ApplicationServices:
|
||||
installation_state = InstallationStateRepository(client=database_client)
|
||||
@@ -65,6 +68,11 @@ def build_application_services(
|
||||
trial_models=FeatureService.get_trial_models(),
|
||||
app_dsl_version=CURRENT_APP_DSL_VERSION,
|
||||
),
|
||||
init_validation=InitValidationService(
|
||||
state=installation_state,
|
||||
validation_required=(deployment_edition != DeploymentEdition.CLOUD and bool(initialization_password)),
|
||||
expected_password=initialization_password,
|
||||
),
|
||||
workspace_queries=WorkspaceQueryService(
|
||||
workspaces=WorkspaceQueryRepository(
|
||||
client=database_client,
|
||||
@@ -84,6 +92,7 @@ def init_app(app: Flask) -> None:
|
||||
app.extensions[_EXTENSION_KEY] = build_application_services(
|
||||
database_client=get_session_maker(),
|
||||
deployment_edition=dify_config.DEPLOYMENT_EDITION,
|
||||
initialization_password=dify_config.INIT_PASSWORD,
|
||||
redis=redis_client,
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Application service for the self-hosted initialization gate."""
|
||||
|
||||
import hmac
|
||||
from typing import Protocol
|
||||
|
||||
|
||||
class InitValidationState(Protocol):
|
||||
def has_tenants(self) -> bool: ...
|
||||
|
||||
def is_setup(self) -> bool: ...
|
||||
|
||||
|
||||
class AlreadyInitializedError(Exception):
|
||||
"""Raised when initialization has already created a tenant."""
|
||||
|
||||
|
||||
class InvalidInitializationPasswordError(Exception):
|
||||
"""Raised when the supplied initialization password does not match."""
|
||||
|
||||
|
||||
class InitValidationService:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
state: InitValidationState,
|
||||
validation_required: bool,
|
||||
expected_password: str,
|
||||
) -> None:
|
||||
self._state = state
|
||||
self._validation_required = validation_required
|
||||
self._expected_password = expected_password
|
||||
|
||||
def is_validated(self, *, session_validated: bool) -> bool:
|
||||
if not self._validation_required or session_validated:
|
||||
return True
|
||||
|
||||
return self._state.is_setup()
|
||||
|
||||
def validate_password(self, password: str) -> None:
|
||||
if self._state.has_tenants():
|
||||
raise AlreadyInitializedError
|
||||
|
||||
expected_password = self._expected_password
|
||||
if (
|
||||
not password
|
||||
or not expected_password
|
||||
or not hmac.compare_digest(
|
||||
password.encode("utf-8"),
|
||||
expected_password.encode("utf-8"),
|
||||
)
|
||||
):
|
||||
raise InvalidInitializationPasswordError
|
||||
@@ -97,6 +97,31 @@ def test_dify_config(monkeypatch: pytest.MonkeyPatch):
|
||||
assert Version(config.project.version) >= Version("1.0.0")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("environment_value", "expected"),
|
||||
[
|
||||
pytest.param(None, "", id="unset"),
|
||||
pytest.param("", "", id="empty"),
|
||||
pytest.param("expected", "expected", id="ascii"),
|
||||
pytest.param("pässwörd-🔐", "pässwörd-🔐", id="unicode"),
|
||||
],
|
||||
)
|
||||
def test_init_password_defaults_to_empty_and_preserves_environment_value(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
environment_value: str | None,
|
||||
expected: str,
|
||||
) -> None:
|
||||
_set_basic_config_env(monkeypatch)
|
||||
if environment_value is None:
|
||||
monkeypatch.delenv("INIT_PASSWORD", raising=False)
|
||||
else:
|
||||
monkeypatch.setenv("INIT_PASSWORD", environment_value)
|
||||
|
||||
config = DifyConfig(_env_file=None)
|
||||
|
||||
assert expected == config.INIT_PASSWORD
|
||||
|
||||
|
||||
@pytest.mark.parametrize("edition", list(DeploymentEdition))
|
||||
def test_deployment_edition_is_loaded_from_environment(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
|
||||
@@ -40,8 +40,7 @@ def _patch_console_guards(monkeypatch: pytest.MonkeyPatch, account: Account, app
|
||||
monkeypatch.setattr(login_lib, "check_csrf_token", lambda *_, **__: None)
|
||||
monkeypatch.setattr(console_wraps, "current_account_with_tenant", lambda: (account, account.current_tenant_id))
|
||||
monkeypatch.setattr(app_wraps, "current_account_with_tenant", lambda: (account, account.current_tenant_id))
|
||||
monkeypatch.setattr(console_wraps.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD)
|
||||
monkeypatch.delenv("INIT_PASSWORD", raising=False)
|
||||
monkeypatch.setattr(console_wraps.dify_config, "INIT_PASSWORD", "")
|
||||
|
||||
# Avoid hitting the database when resolving the app model
|
||||
monkeypatch.setattr(app_wraps, "_load_app_model_from_scoped_session", lambda _app_id: app_model)
|
||||
|
||||
@@ -64,8 +64,8 @@ def _mock_console_guards(monkeypatch: pytest.MonkeyPatch) -> MagicMock:
|
||||
account.is_authenticated = True
|
||||
|
||||
monkeypatch.setattr(wraps_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD)
|
||||
monkeypatch.setattr(wraps_module.dify_config, "INIT_PASSWORD", "")
|
||||
monkeypatch.setattr("libs.login.dify_config.LOGIN_DISABLED", True)
|
||||
monkeypatch.delenv("INIT_PASSWORD", raising=False)
|
||||
monkeypatch.setattr(wraps_module, "current_account_with_tenant", lambda: (account, "tenant-123"))
|
||||
|
||||
# The login_required decorator consults the shared LocalProxy in libs.login.
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
"""HTTP contract tests for the FastOpenAPI initialization routes."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock, create_autospec
|
||||
|
||||
import pytest
|
||||
|
||||
from controllers.console import init_validate
|
||||
from dify_app import DifyApp
|
||||
from enums import DeploymentEdition
|
||||
from extensions import ext_fastopenapi
|
||||
from services.init_validation_service import (
|
||||
AlreadyInitializedError,
|
||||
InitValidationService,
|
||||
InvalidInitializationPasswordError,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def init_validation(monkeypatch: pytest.MonkeyPatch) -> Mock:
|
||||
service = create_autospec(InitValidationService, instance=True, spec_set=True)
|
||||
services = SimpleNamespace(init_validation=service)
|
||||
monkeypatch.setattr(init_validate, "application_services", lambda: services)
|
||||
return service
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app() -> DifyApp:
|
||||
app = DifyApp(__name__)
|
||||
app.config.update(TESTING=True, SECRET_KEY="test-secret")
|
||||
ext_fastopenapi.init_app(app)
|
||||
return app
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("validated", "expected_status"),
|
||||
[
|
||||
pytest.param(True, "finished", id="finished"),
|
||||
pytest.param(False, "not_started", id="not-started"),
|
||||
],
|
||||
)
|
||||
def test_get_init_status(
|
||||
app: DifyApp,
|
||||
init_validation: Mock,
|
||||
validated: bool,
|
||||
expected_status: str,
|
||||
) -> None:
|
||||
init_validation.is_validated.return_value = validated
|
||||
|
||||
response = app.test_client().get("/console/api/init")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.get_json() == {"status": expected_status}
|
||||
init_validation.is_validated.assert_called_once_with(session_validated=False)
|
||||
|
||||
|
||||
def test_validate_init_password_success(
|
||||
app: DifyApp,
|
||||
init_validation: Mock,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
"controllers.console.wraps.dify_config.DEPLOYMENT_EDITION",
|
||||
DeploymentEdition.COMMUNITY,
|
||||
)
|
||||
client = app.test_client()
|
||||
|
||||
response = client.post("/console/api/init", json={"password": "expected"})
|
||||
|
||||
assert response.status_code == 201
|
||||
assert response.get_json() == {"result": "success"}
|
||||
init_validation.validate_password.assert_called_once_with("expected")
|
||||
with client.session_transaction() as browser_session:
|
||||
assert browser_session["is_init_validated"] is True
|
||||
|
||||
|
||||
def test_validate_init_password_rejects_a_mismatch(
|
||||
app: DifyApp,
|
||||
init_validation: Mock,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
"controllers.console.wraps.dify_config.DEPLOYMENT_EDITION",
|
||||
DeploymentEdition.COMMUNITY,
|
||||
)
|
||||
init_validation.validate_password.side_effect = InvalidInitializationPasswordError
|
||||
client = app.test_client()
|
||||
|
||||
response = client.post("/console/api/init", json={"password": "wrong"})
|
||||
|
||||
assert response.status_code == 401
|
||||
with client.session_transaction() as browser_session:
|
||||
assert browser_session["is_init_validated"] is False
|
||||
|
||||
|
||||
def test_validate_init_password_rejects_an_initialized_installation(
|
||||
app: DifyApp,
|
||||
init_validation: Mock,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
"controllers.console.wraps.dify_config.DEPLOYMENT_EDITION",
|
||||
DeploymentEdition.COMMUNITY,
|
||||
)
|
||||
init_validation.validate_password.side_effect = AlreadyInitializedError
|
||||
|
||||
response = app.test_client().post("/console/api/init", json={"password": "expected"})
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"payload",
|
||||
[
|
||||
pytest.param({}, id="missing-password"),
|
||||
pytest.param({"password": "x" * 31}, id="password-too-long"),
|
||||
],
|
||||
)
|
||||
def test_validate_init_password_rejects_an_invalid_payload(
|
||||
app: DifyApp,
|
||||
init_validation: Mock,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
payload: dict[str, str],
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
"controllers.console.wraps.dify_config.DEPLOYMENT_EDITION",
|
||||
DeploymentEdition.COMMUNITY,
|
||||
)
|
||||
|
||||
response = app.test_client().post("/console/api/init", json=payload)
|
||||
|
||||
assert response.status_code == 422
|
||||
init_validation.validate_password.assert_not_called()
|
||||
|
||||
|
||||
def test_validate_init_password_is_not_available_in_cloud(
|
||||
app: DifyApp,
|
||||
init_validation: Mock,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
"controllers.console.wraps.dify_config.DEPLOYMENT_EDITION",
|
||||
DeploymentEdition.CLOUD,
|
||||
)
|
||||
|
||||
response = app.test_client().post("/console/api/init", json={"password": "expected"})
|
||||
|
||||
assert response.status_code == 404
|
||||
init_validation.validate_password.assert_not_called()
|
||||
@@ -80,7 +80,7 @@ def test_console_setup_fastopenapi_post_success(
|
||||
deployment_edition: DeploymentEdition,
|
||||
) -> None:
|
||||
monkeypatch.setattr(wraps.dify_config, "DEPLOYMENT_EDITION", deployment_edition)
|
||||
monkeypatch.setattr(setup_controller, "get_init_validate_status", lambda: True)
|
||||
monkeypatch.setattr(setup_controller, "is_init_validated", lambda: True)
|
||||
mark_setup_completed = Mock()
|
||||
monkeypatch.setattr(setup_controller, "mark_setup_completed", mark_setup_completed)
|
||||
payload = {
|
||||
@@ -197,7 +197,7 @@ def test_console_setup_translates_service_errors_to_controller_errors(
|
||||
expected_controller_error: type[Exception],
|
||||
) -> None:
|
||||
monkeypatch.setattr(wraps.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY)
|
||||
monkeypatch.setattr(setup_controller, "get_init_validate_status", lambda: False)
|
||||
monkeypatch.setattr(setup_controller, "is_init_validated", lambda: False)
|
||||
mark_setup_completed = Mock()
|
||||
monkeypatch.setattr(setup_controller, "mark_setup_completed", mark_setup_completed)
|
||||
setup_service.initialize.side_effect = service_error
|
||||
@@ -228,7 +228,7 @@ def test_console_setup_fastopenapi_does_not_mark_setup_completed_when_service_fa
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(wraps.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY)
|
||||
monkeypatch.setattr(setup_controller, "get_init_validate_status", lambda: True)
|
||||
monkeypatch.setattr(setup_controller, "is_init_validated", lambda: True)
|
||||
mark_setup_completed = Mock()
|
||||
monkeypatch.setattr(setup_controller, "mark_setup_completed", mark_setup_completed)
|
||||
setup_service.initialize.side_effect = RuntimeError("provision failed")
|
||||
|
||||
@@ -17,6 +17,7 @@ from services.entities.feature_entities import (
|
||||
)
|
||||
from services.explore_banner_query_service import ExploreBannerQueryService
|
||||
from services.feature_query_service import FeatureQueryService
|
||||
from services.init_validation_service import InitValidationService
|
||||
from services.schema_definition_service import SchemaDefinitionService
|
||||
from services.setup_service import SetupService
|
||||
from services.workspace_member_query_service import WorkspaceMemberQueryService
|
||||
@@ -39,6 +40,7 @@ def _install_application_services(mocker: MockerFixture):
|
||||
schema_definitions=create_autospec(SchemaDefinitionService, instance=True, spec_set=True),
|
||||
setup=create_autospec(SetupService, instance=True, spec_set=True),
|
||||
feature_queries=feature_queries,
|
||||
init_validation=create_autospec(InitValidationService, instance=True, spec_set=True),
|
||||
workspace_queries=create_autospec(WorkspaceQueryService, instance=True, spec_set=True),
|
||||
workspace_member_queries=create_autospec(WorkspaceMemberQueryService, instance=True, spec_set=True),
|
||||
)
|
||||
|
||||
@@ -1,34 +1,56 @@
|
||||
"""Initialization validation tests with real setup-state persistence in SQLite."""
|
||||
|
||||
from __future__ import annotations
|
||||
"""Tests for the Flask adapter around initialization validation."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock, create_autospec
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from controllers.console import init_validate
|
||||
from controllers.console import init_validate, wraps
|
||||
from controllers.console.error import AlreadySetupError, InitValidateFailedError
|
||||
from enums import DeploymentEdition
|
||||
from models.model import DifySetup
|
||||
from services.init_validation_service import (
|
||||
AlreadyInitializedError,
|
||||
InitValidationService,
|
||||
InvalidInitializationPasswordError,
|
||||
)
|
||||
|
||||
|
||||
def test_get_init_status_finished(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(init_validate, "get_init_validate_status", lambda: True)
|
||||
result = init_validate.get_init_status()
|
||||
assert result.status == "finished"
|
||||
@pytest.fixture
|
||||
def init_validation(monkeypatch: pytest.MonkeyPatch) -> Mock:
|
||||
service = create_autospec(InitValidationService, instance=True, spec_set=True)
|
||||
application_services = SimpleNamespace(init_validation=service)
|
||||
monkeypatch.setattr(init_validate, "application_services", lambda: application_services)
|
||||
return service
|
||||
|
||||
|
||||
def test_get_init_status_not_started(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(init_validate, "get_init_validate_status", lambda: False)
|
||||
result = init_validate.get_init_status()
|
||||
assert result.status == "not_started"
|
||||
def test_get_init_status_finished(app: Flask, init_validation: Mock) -> None:
|
||||
init_validation.is_validated.return_value = True
|
||||
app.secret_key = "test-secret"
|
||||
|
||||
with app.test_request_context("/console/api/init", method="GET"):
|
||||
result = init_validate.get_init_status()
|
||||
|
||||
assert result.status == "finished"
|
||||
|
||||
|
||||
def test_validate_init_password_already_setup(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(init_validate.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY)
|
||||
monkeypatch.setattr(init_validate.TenantService, "get_tenant_count", lambda *, session: 1)
|
||||
def test_get_init_status_not_started(app: Flask, init_validation: Mock) -> None:
|
||||
init_validation.is_validated.return_value = False
|
||||
app.secret_key = "test-secret"
|
||||
|
||||
with app.test_request_context("/console/api/init", method="GET"):
|
||||
result = init_validate.get_init_status()
|
||||
|
||||
assert result.status == "not_started"
|
||||
|
||||
|
||||
def test_validate_init_password_already_setup(
|
||||
app: Flask,
|
||||
init_validation: Mock,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(wraps.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY)
|
||||
init_validation.validate_password.side_effect = AlreadyInitializedError
|
||||
app.secret_key = "test-secret"
|
||||
|
||||
with app.test_request_context("/console/api/init", method="POST"):
|
||||
@@ -36,10 +58,13 @@ def test_validate_init_password_already_setup(app: Flask, monkeypatch: pytest.Mo
|
||||
init_validate.validate_init_password(init_validate.InitValidatePayload(password="pw"))
|
||||
|
||||
|
||||
def test_validate_init_password_wrong_password(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(init_validate.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY)
|
||||
monkeypatch.setattr(init_validate.TenantService, "get_tenant_count", lambda *, session: 0)
|
||||
monkeypatch.setenv("INIT_PASSWORD", "expected")
|
||||
def test_validate_init_password_wrong_password(
|
||||
app: Flask,
|
||||
init_validation: Mock,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(wraps.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY)
|
||||
init_validation.validate_password.side_effect = InvalidInitializationPasswordError
|
||||
app.secret_key = "test-secret"
|
||||
|
||||
with app.test_request_context("/console/api/init", method="POST"):
|
||||
@@ -48,58 +73,42 @@ def test_validate_init_password_wrong_password(app: Flask, monkeypatch: pytest.M
|
||||
assert init_validate.session.get("is_init_validated") is False
|
||||
|
||||
|
||||
def test_validate_init_password_success(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(init_validate.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY)
|
||||
monkeypatch.setattr(init_validate.TenantService, "get_tenant_count", lambda *, session: 0)
|
||||
monkeypatch.setenv("INIT_PASSWORD", "expected")
|
||||
def test_validate_init_password_success(
|
||||
app: Flask,
|
||||
init_validation: Mock,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(wraps.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY)
|
||||
app.secret_key = "test-secret"
|
||||
|
||||
with app.test_request_context("/console/api/init", method="POST"):
|
||||
result = init_validate.validate_init_password(init_validate.InitValidatePayload(password="expected"))
|
||||
|
||||
assert result.result == "success"
|
||||
assert init_validate.session.get("is_init_validated") is True
|
||||
init_validation.validate_password.assert_called_once_with("expected")
|
||||
|
||||
|
||||
def test_get_init_validate_status_not_self_hosted(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(init_validate.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD)
|
||||
assert init_validate.get_init_validate_status() is True
|
||||
|
||||
|
||||
def test_get_init_validate_status_validated_session(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(init_validate.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY)
|
||||
monkeypatch.setenv("INIT_PASSWORD", "expected")
|
||||
app.secret_key = "test-secret"
|
||||
|
||||
with app.test_request_context("/console/api/init", method="GET"):
|
||||
init_validate.session["is_init_validated"] = True
|
||||
assert init_validate.get_init_validate_status() is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [(DifySetup,)], indirect=True)
|
||||
def test_get_init_validate_status_setup_exists(
|
||||
app: Flask, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session
|
||||
@pytest.mark.parametrize(
|
||||
("session_value", "expected"),
|
||||
[
|
||||
pytest.param(None, False, id="missing"),
|
||||
pytest.param(False, False, id="not-validated"),
|
||||
pytest.param(True, True, id="validated"),
|
||||
],
|
||||
)
|
||||
def test_is_init_validated_passes_session_state(
|
||||
app: Flask,
|
||||
init_validation: Mock,
|
||||
session_value: bool | None,
|
||||
expected: bool,
|
||||
) -> None:
|
||||
monkeypatch.setattr(init_validate.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY)
|
||||
monkeypatch.setenv("INIT_PASSWORD", "expected")
|
||||
monkeypatch.setattr(init_validate, "db", SimpleNamespace(engine=sqlite_session.get_bind()))
|
||||
sqlite_session.add(DifySetup(version="test-version"))
|
||||
sqlite_session.commit()
|
||||
init_validation.is_validated.return_value = True
|
||||
app.secret_key = "test-secret"
|
||||
|
||||
with app.test_request_context("/console/api/init", method="GET"):
|
||||
init_validate.session.pop("is_init_validated", None)
|
||||
assert init_validate.get_init_validate_status() is True
|
||||
if session_value is not None:
|
||||
init_validate.session["is_init_validated"] = session_value
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [(DifySetup,)], indirect=True)
|
||||
def test_get_init_validate_status_not_validated(
|
||||
app: Flask, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session
|
||||
) -> None:
|
||||
monkeypatch.setattr(init_validate.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY)
|
||||
monkeypatch.setenv("INIT_PASSWORD", "expected")
|
||||
monkeypatch.setattr(init_validate, "db", SimpleNamespace(engine=sqlite_session.get_bind()))
|
||||
app.secret_key = "test-secret"
|
||||
|
||||
with app.test_request_context("/console/api/init", method="GET"):
|
||||
init_validate.session.pop("is_init_validated", None)
|
||||
assert init_validate.get_init_validate_status() is False
|
||||
assert init_validate.is_init_validated() is True
|
||||
init_validation.is_validated.assert_called_once_with(session_validated=expected)
|
||||
|
||||
@@ -925,17 +925,18 @@ class TestSystemSetup:
|
||||
assert mock_db.session.scalar.call_count == 1
|
||||
|
||||
@patch("controllers.console.wraps.db")
|
||||
@patch("controllers.console.wraps.os.environ.get")
|
||||
def test_should_not_cache_missing_setup(self, mock_environ_get, mock_db):
|
||||
def test_should_not_cache_missing_setup(self, mock_db):
|
||||
"""Test that first-time bootstrap completion can be observed later in the same process"""
|
||||
mock_db.session.scalar.side_effect = [None, MagicMock()]
|
||||
mock_environ_get.return_value = None
|
||||
|
||||
@setup_required
|
||||
def admin_view():
|
||||
return "admin_success"
|
||||
|
||||
with patch("controllers.console.wraps.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY):
|
||||
with (
|
||||
patch("controllers.console.wraps.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY),
|
||||
patch("controllers.console.wraps.dify_config.INIT_PASSWORD", ""),
|
||||
):
|
||||
with pytest.raises(NotSetupError):
|
||||
admin_view()
|
||||
assert admin_view() == "admin_success"
|
||||
@@ -943,36 +944,38 @@ class TestSystemSetup:
|
||||
assert mock_db.session.scalar.call_count == 2
|
||||
|
||||
@patch("controllers.console.wraps.db")
|
||||
@patch("controllers.console.wraps.os.environ.get")
|
||||
def test_should_raise_not_init_validate_error_with_init_password(self, mock_environ_get, mock_db: MagicMock):
|
||||
def test_should_raise_not_init_validate_error_with_init_password(self, mock_db: MagicMock):
|
||||
"""Test NotInitValidateError when INIT_PASSWORD is set but setup not complete"""
|
||||
# Arrange
|
||||
mock_db.session.scalar.return_value = None # No setup
|
||||
mock_environ_get.return_value = "some_password"
|
||||
|
||||
@setup_required
|
||||
def admin_view():
|
||||
return "admin_success"
|
||||
|
||||
# Act & Assert
|
||||
with patch("controllers.console.wraps.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY):
|
||||
with (
|
||||
patch("controllers.console.wraps.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY),
|
||||
patch("controllers.console.wraps.dify_config.INIT_PASSWORD", "some_password"),
|
||||
):
|
||||
with pytest.raises(NotInitValidateError):
|
||||
admin_view()
|
||||
|
||||
@patch("controllers.console.wraps.db")
|
||||
@patch("controllers.console.wraps.os.environ.get")
|
||||
def test_should_raise_not_setup_error_without_init_password(self, mock_environ_get, mock_db: MagicMock):
|
||||
def test_should_raise_not_setup_error_without_init_password(self, mock_db: MagicMock):
|
||||
"""Test NotSetupError when no INIT_PASSWORD and setup not complete"""
|
||||
# Arrange
|
||||
mock_db.session.scalar.return_value = None # No setup
|
||||
mock_environ_get.return_value = None # No INIT_PASSWORD
|
||||
|
||||
@setup_required
|
||||
def admin_view():
|
||||
return "admin_success"
|
||||
|
||||
# Act & Assert
|
||||
with patch("controllers.console.wraps.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY):
|
||||
with (
|
||||
patch("controllers.console.wraps.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY),
|
||||
patch("controllers.console.wraps.dify_config.INIT_PASSWORD", ""),
|
||||
):
|
||||
with pytest.raises(NotSetupError):
|
||||
admin_view()
|
||||
|
||||
|
||||
@@ -1,11 +1,86 @@
|
||||
"""Tests for application-service dependency wiring."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from enums import DeploymentEdition
|
||||
from extensions.ext_application_services import build_application_services
|
||||
from extensions import ext_application_services
|
||||
from extensions.ext_redis import RedisClientWrapper
|
||||
from models.model import DifySetup
|
||||
from services.init_validation_service import InvalidInitializationPasswordError
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("deployment_edition", "initialization_password", "session_validated", "setup_exists", "expected"),
|
||||
[
|
||||
pytest.param(DeploymentEdition.CLOUD, "expected", False, False, True, id="cloud"),
|
||||
pytest.param(DeploymentEdition.COMMUNITY, "", False, False, True, id="no-password"),
|
||||
pytest.param(DeploymentEdition.COMMUNITY, "expected", False, False, False, id="not-validated"),
|
||||
pytest.param(DeploymentEdition.ENTERPRISE, "expected", False, False, False, id="enterprise"),
|
||||
pytest.param(DeploymentEdition.COMMUNITY, "expected", True, False, True, id="browser-session"),
|
||||
pytest.param(DeploymentEdition.COMMUNITY, "expected", False, True, True, id="setup-record"),
|
||||
],
|
||||
)
|
||||
def test_build_application_services_configures_init_validation(
|
||||
sqlite_session: Session,
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
deployment_edition: DeploymentEdition,
|
||||
initialization_password: str,
|
||||
session_validated: bool,
|
||||
setup_exists: bool,
|
||||
expected: bool,
|
||||
) -> None:
|
||||
if setup_exists:
|
||||
sqlite_session.add(DifySetup(version="test-version"))
|
||||
sqlite_session.commit()
|
||||
|
||||
services = ext_application_services.build_application_services(
|
||||
database_client=sqlite_session_factory,
|
||||
deployment_edition=deployment_edition,
|
||||
initialization_password=initialization_password,
|
||||
redis=MagicMock(spec=RedisClientWrapper),
|
||||
)
|
||||
|
||||
assert services.init_validation.is_validated(session_validated=session_validated) is expected
|
||||
|
||||
|
||||
def test_build_application_services_passes_the_expected_password(
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
services = ext_application_services.build_application_services(
|
||||
database_client=sqlite_session_factory,
|
||||
deployment_edition=DeploymentEdition.COMMUNITY,
|
||||
initialization_password="expected",
|
||||
redis=MagicMock(spec=RedisClientWrapper),
|
||||
)
|
||||
|
||||
services.init_validation.validate_password("expected")
|
||||
with pytest.raises(InvalidInitializationPasswordError):
|
||||
services.init_validation.validate_password("wrong")
|
||||
|
||||
|
||||
def test_init_app_registers_services_for_the_current_app(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
app = Flask(__name__)
|
||||
monkeypatch.setattr(ext_application_services, "get_session_maker", lambda: sqlite_session_factory)
|
||||
monkeypatch.setattr(
|
||||
ext_application_services.dify_config,
|
||||
"DEPLOYMENT_EDITION",
|
||||
DeploymentEdition.COMMUNITY,
|
||||
)
|
||||
monkeypatch.setattr(ext_application_services.dify_config, "INIT_PASSWORD", "expected")
|
||||
|
||||
ext_application_services.init_app(app)
|
||||
|
||||
with app.app_context():
|
||||
services = ext_application_services.application_services()
|
||||
assert services is app.extensions["application_services"]
|
||||
assert services.init_validation.is_validated(session_validated=False) is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -21,9 +96,10 @@ def test_build_application_services_configures_setup_policy(
|
||||
deployment_edition: DeploymentEdition,
|
||||
setup_completed: bool,
|
||||
) -> None:
|
||||
services = build_application_services(
|
||||
services = ext_application_services.build_application_services(
|
||||
database_client=sqlite_session_factory,
|
||||
deployment_edition=deployment_edition,
|
||||
initialization_password="",
|
||||
redis=MagicMock(spec=RedisClientWrapper),
|
||||
)
|
||||
|
||||
@@ -33,9 +109,10 @@ def test_build_application_services_configures_setup_policy(
|
||||
def test_build_application_services_wires_builtin_schema_definitions(
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
services = build_application_services(
|
||||
services = ext_application_services.build_application_services(
|
||||
database_client=sqlite_session_factory,
|
||||
deployment_edition=DeploymentEdition.COMMUNITY,
|
||||
initialization_password="",
|
||||
redis=MagicMock(spec=RedisClientWrapper),
|
||||
)
|
||||
|
||||
@@ -49,9 +126,10 @@ def test_build_application_services_does_not_construct_schema_manager(
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
with patch("extensions.ext_application_services.SchemaManager") as schema_manager:
|
||||
build_application_services(
|
||||
ext_application_services.build_application_services(
|
||||
database_client=sqlite_session_factory,
|
||||
deployment_edition=DeploymentEdition.COMMUNITY,
|
||||
initialization_password="",
|
||||
redis=MagicMock(spec=RedisClientWrapper),
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
"""Tests for initialization validation policy without Flask or persistence."""
|
||||
|
||||
from unittest.mock import Mock, create_autospec
|
||||
|
||||
import pytest
|
||||
|
||||
from services.init_validation_service import (
|
||||
AlreadyInitializedError,
|
||||
InitValidationService,
|
||||
InitValidationState,
|
||||
InvalidInitializationPasswordError,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def state() -> Mock:
|
||||
return create_autospec(InitValidationState, instance=True, spec_set=True)
|
||||
|
||||
|
||||
def test_status_is_valid_when_validation_is_not_required(state: Mock) -> None:
|
||||
service = InitValidationService(state=state, validation_required=False, expected_password="")
|
||||
|
||||
assert service.is_validated(session_validated=False) is True
|
||||
state.is_setup.assert_not_called()
|
||||
|
||||
|
||||
def test_status_is_valid_when_browser_session_was_validated(state: Mock) -> None:
|
||||
service = InitValidationService(state=state, validation_required=True, expected_password="expected")
|
||||
|
||||
assert service.is_validated(session_validated=True) is True
|
||||
state.is_setup.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("setup_exists", [False, True])
|
||||
def test_status_falls_back_to_persisted_setup_state(state: Mock, setup_exists: bool) -> None:
|
||||
state.is_setup.return_value = setup_exists
|
||||
service = InitValidationService(state=state, validation_required=True, expected_password="expected")
|
||||
|
||||
assert service.is_validated(session_validated=False) is setup_exists
|
||||
state.is_setup.assert_called_once_with()
|
||||
|
||||
|
||||
def test_password_validation_rejects_an_initialized_installation(state: Mock) -> None:
|
||||
state.has_tenants.return_value = True
|
||||
service = InitValidationService(state=state, validation_required=True, expected_password="expected")
|
||||
|
||||
with pytest.raises(AlreadyInitializedError):
|
||||
service.validate_password("expected")
|
||||
|
||||
|
||||
def test_initialized_installation_takes_precedence_over_a_password_mismatch(state: Mock) -> None:
|
||||
state.has_tenants.return_value = True
|
||||
service = InitValidationService(state=state, validation_required=True, expected_password="expected")
|
||||
|
||||
with pytest.raises(AlreadyInitializedError):
|
||||
service.validate_password("wrong")
|
||||
|
||||
|
||||
def test_password_validation_rejects_a_mismatch(state: Mock) -> None:
|
||||
state.has_tenants.return_value = False
|
||||
service = InitValidationService(state=state, validation_required=True, expected_password="expected")
|
||||
|
||||
with pytest.raises(InvalidInitializationPasswordError):
|
||||
service.validate_password("wrong")
|
||||
|
||||
|
||||
def test_password_validation_accepts_a_match(state: Mock) -> None:
|
||||
state.has_tenants.return_value = False
|
||||
service = InitValidationService(state=state, validation_required=True, expected_password="expected")
|
||||
|
||||
service.validate_password("expected")
|
||||
|
||||
state.has_tenants.assert_called_once_with()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("expected_password", ["", "expected"])
|
||||
def test_password_validation_rejects_an_empty_password(state: Mock, expected_password: str) -> None:
|
||||
state.has_tenants.return_value = False
|
||||
service = InitValidationService(
|
||||
state=state,
|
||||
validation_required=bool(expected_password),
|
||||
expected_password=expected_password,
|
||||
)
|
||||
|
||||
with pytest.raises(InvalidInitializationPasswordError):
|
||||
service.validate_password("")
|
||||
|
||||
|
||||
def test_password_validation_rejects_a_password_when_no_password_is_configured(state: Mock) -> None:
|
||||
state.has_tenants.return_value = False
|
||||
service = InitValidationService(state=state, validation_required=False, expected_password="")
|
||||
|
||||
with pytest.raises(InvalidInitializationPasswordError):
|
||||
service.validate_password("unexpected")
|
||||
|
||||
|
||||
def test_password_validation_accepts_a_unicode_password(state: Mock) -> None:
|
||||
state.has_tenants.return_value = False
|
||||
service = InitValidationService(state=state, validation_required=True, expected_password="pässwörd-🔐")
|
||||
|
||||
service.validate_password("pässwörd-🔐")
|
||||
Reference in New Issue
Block a user