refactor(api): extract init validation into layered application service (#40046)

This commit is contained in:
Byron.wang
2026-08-12 16:42:09 +08:00
committed by GitHub
parent 6d185b6f40
commit 51a007e84e
17 changed files with 547 additions and 114 deletions
+14
View File
@@ -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
+5
View File
@@ -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",
+13 -25
View File
@@ -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")),
)
+2 -2
View File
@@ -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 -2
View File
@@ -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,
)
+52
View File
@@ -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-🔐")