diff --git a/api/.importlinter b/api/.importlinter index 22130ee9d73..a64da0ce5b6 100644 --- a/api/.importlinter +++ b/api/.importlinter @@ -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 diff --git a/api/configs/deploy/__init__.py b/api/configs/deploy/__init__.py index 491157f694e..638b3dacfd5 100644 --- a/api/configs/deploy/__init__.py +++ b/api/configs/deploy/__init__.py @@ -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", diff --git a/api/controllers/console/init_validate.py b/api/controllers/console/init_validate.py index 9d9e9a813e8..171d58c1caa 100644 --- a/api/controllers/console/init_validate.py +++ b/api/controllers/console/init_validate.py @@ -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")), + ) diff --git a/api/controllers/console/setup.py b/api/controllers/console/setup.py index 38e3325d6d4..20fa631d057 100644 --- a/api/controllers/console/setup.py +++ b/api/controllers/console/setup.py @@ -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 diff --git a/api/controllers/console/wraps.py b/api/controllers/console/wraps.py index f4cef023079..8e353c6c980 100644 --- a/api/controllers/console/wraps.py +++ b/api/controllers/console/wraps.py @@ -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() diff --git a/api/extensions/ext_application_services.py b/api/extensions/ext_application_services.py index dd1ead89a5d..db647eafbe2 100644 --- a/api/extensions/ext_application_services.py +++ b/api/extensions/ext_application_services.py @@ -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, ) diff --git a/api/services/init_validation_service.py b/api/services/init_validation_service.py new file mode 100644 index 00000000000..ef305f52b31 --- /dev/null +++ b/api/services/init_validation_service.py @@ -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 diff --git a/api/tests/unit_tests/configs/test_dify_config.py b/api/tests/unit_tests/configs/test_dify_config.py index f52caed618e..af671c77600 100644 --- a/api/tests/unit_tests/configs/test_dify_config.py +++ b/api/tests/unit_tests/configs/test_dify_config.py @@ -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, diff --git a/api/tests/unit_tests/controllers/console/app/test_workflow_human_input_debug_api.py b/api/tests/unit_tests/controllers/console/app/test_workflow_human_input_debug_api.py index 2f6a03b107b..507a38e0155 100644 --- a/api/tests/unit_tests/controllers/console/app/test_workflow_human_input_debug_api.py +++ b/api/tests/unit_tests/controllers/console/app/test_workflow_human_input_debug_api.py @@ -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) diff --git a/api/tests/unit_tests/controllers/console/test_extension.py b/api/tests/unit_tests/controllers/console/test_extension.py index 2a054f75d41..5d38d982e55 100644 --- a/api/tests/unit_tests/controllers/console/test_extension.py +++ b/api/tests/unit_tests/controllers/console/test_extension.py @@ -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. diff --git a/api/tests/unit_tests/controllers/console/test_fastopenapi_init_validate.py b/api/tests/unit_tests/controllers/console/test_fastopenapi_init_validate.py new file mode 100644 index 00000000000..1d73ee30a68 --- /dev/null +++ b/api/tests/unit_tests/controllers/console/test_fastopenapi_init_validate.py @@ -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() diff --git a/api/tests/unit_tests/controllers/console/test_fastopenapi_setup.py b/api/tests/unit_tests/controllers/console/test_fastopenapi_setup.py index ea72412e794..8018ed3c115 100644 --- a/api/tests/unit_tests/controllers/console/test_fastopenapi_setup.py +++ b/api/tests/unit_tests/controllers/console/test_fastopenapi_setup.py @@ -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") diff --git a/api/tests/unit_tests/controllers/console/test_feature.py b/api/tests/unit_tests/controllers/console/test_feature.py index 97e0a1aa8c6..f8e7d94b496 100644 --- a/api/tests/unit_tests/controllers/console/test_feature.py +++ b/api/tests/unit_tests/controllers/console/test_feature.py @@ -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), ) diff --git a/api/tests/unit_tests/controllers/console/test_init_validate.py b/api/tests/unit_tests/controllers/console/test_init_validate.py index a639886d48b..80145f7cae6 100644 --- a/api/tests/unit_tests/controllers/console/test_init_validate.py +++ b/api/tests/unit_tests/controllers/console/test_init_validate.py @@ -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) diff --git a/api/tests/unit_tests/controllers/console/test_wraps.py b/api/tests/unit_tests/controllers/console/test_wraps.py index 98c00fea127..a1432098416 100644 --- a/api/tests/unit_tests/controllers/console/test_wraps.py +++ b/api/tests/unit_tests/controllers/console/test_wraps.py @@ -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() diff --git a/api/tests/unit_tests/extensions/test_ext_application_services.py b/api/tests/unit_tests/extensions/test_ext_application_services.py index 1474ae5fac4..8122b953c10 100644 --- a/api/tests/unit_tests/extensions/test_ext_application_services.py +++ b/api/tests/unit_tests/extensions/test_ext_application_services.py @@ -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), ) diff --git a/api/tests/unit_tests/services/test_init_validation_service.py b/api/tests/unit_tests/services/test_init_validation_service.py new file mode 100644 index 00000000000..82fc787f78d --- /dev/null +++ b/api/tests/unit_tests/services/test_init_validation_service.py @@ -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-🔐")