from __future__ import annotations import hashlib import json import threading from pathlib import Path import pytest from alembic import command from alembic.config import Config from sqlalchemy import create_engine, inspect, select from server.agent_core import harness from server.auth.context import IdentityContext, bind_identity, reset_identity from server.db.database import get_engine, get_session, reset_engine from server.db.models import ( ApprovalEventRecord, ApprovalGrantRecord, ApprovalRequestRecord, ) def _identity(user_id: int, tenant: str = "platform") -> IdentityContext: return IdentityContext( user_id, f"db-user-{user_id}", f"DB User {user_id}", tenant, roles=("planner",), ) def _as(identity: IdentityContext, callback): token = bind_identity(identity) try: return callback() finally: reset_identity(token) def _stage(action: str = "mes.dispatch") -> tuple[str, dict]: params = ( {"track": "fixed", "versionId": 91, "evidenceRefs": ["schedule-version:91"]} if action == "mes.dispatch" else {"versionId": 91} ) block = harness.stage_confirmation( "database-approval-test", action, params, title="Database approval", summary_lines=["test"], ) return str(block.props["confirmId"]), params @pytest.fixture def database_backend(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): monkeypatch.setenv("APS_APPROVAL_BACKEND", "database") monkeypatch.delenv("APS_APPROVAL_PATH", raising=False) monkeypatch.setenv("APS_DATABASE_URL", f"sqlite:///{(tmp_path / 'approval.db').as_posix()}") monkeypatch.setenv("APS_APPROVAL_DATABASE_ALLOW_SQLITE", "1") reset_engine() store = harness.configure_approval_store() yield store store.clear() reset_engine() def test_database_backend_records_approval_note(database_backend): """共享 database 后端:审批意见进 approvals 与历史事件。""" owner = _identity(7201) confirm_id, _params = _as(owner, lambda: _stage("schedule.publish")) approved = _as(owner, lambda: harness.take_confirmation(confirm_id, approve=True, note="DB 同意")) assert approved is not None assert approved["needsSecondConfirm"] is False assert approved["approvals"][-1]["note"] == "DB 同意" history = _as(owner, harness.list_approval_history) assert history[-1]["note"] == "DB 同意" assert history[-1]["status"] == "APPROVED" def test_database_backend_records_reject_note(database_backend): """共享 database 后端:驳回意见进历史事件。""" owner = _identity(7202) confirm_id, _params = _as(owner, lambda: _stage("schedule.publish")) rejected = _as(owner, lambda: harness.take_confirmation(confirm_id, approve=False, note="DB 驳回")) assert rejected is not None history = _as(owner, harness.list_approval_history) assert history[-1]["note"] == "DB 驳回" assert history[-1]["status"] == "REJECTED" def test_database_backend_preserves_contract_across_instances(database_backend): owner = _identity(7101) confirm_id, _params = _as(owner, lambda: _stage("schedule.publish")) harness.configure_approval_store() assert [row["confirmId"] for row in _as(owner, harness.list_pending)] == [confirm_id] approved = _as(owner, lambda: harness.take_confirmation(confirm_id, approve=True)) assert approved and approved["needsSecondConfirm"] is False harness.configure_approval_store() assert _as(owner, harness.list_pending) == [] assert _as(owner, harness.list_approval_history)[-1]["status"] == "APPROVED" def test_database_backend_expires_pending_and_records_history(database_backend): owner = _identity(7151) confirm_id, _params = _as(owner, _stage) with get_session() as session: expires_at = session.get(ApprovalRequestRecord, confirm_id).expires_at_epoch assert database_backend.expire(now_epoch=expires_at + 1) is True assert _as(owner, lambda: harness.is_confirmation_pending(confirm_id)) is False assert _as(owner, harness.list_approval_history)[-1]["status"] == "EXPIRED" def test_database_backend_queries_are_filtered_by_tenant_and_project(database_backend): def record(confirm_id: str, tenant_uuid: str, project_id: str) -> dict: return { "confirmId": confirm_id, "sessionId": "scope-test", "action": "schedule.publish", "power": "P2", "params": {"versionId": 91}, "paramsHash": hashlib.sha256(b"scope-test").hexdigest(), "tenantUuid": tenant_uuid, "ownerUserId": 7501, "projectId": project_id, "worldKey": project_id, "requester": {"userId": "7501"}, "approvals": [], "approvalStep": 0, "requiredApprovals": 1, "createdAt": "2026-07-31T00:00:00+00:00", "createdAtEpoch": 1_754_000_000.0, "expiresAt": "2099-07-31T00:00:00+00:00", "expiresAtEpoch": 4_089_686_400.0, } assert database_backend.stage(record("scope-a", "tenant-a", "project-a"), now_epoch=1.0) assert database_backend.stage(record("scope-b", "tenant-a", "project-b"), now_epoch=1.0) assert database_backend.stage(record("scope-c", "tenant-b", "project-a"), now_epoch=1.0) pending = database_backend.pending_items( tenant_uuid="tenant-a", project_id="project-a", allowed=lambda _record: True, now_epoch=2.0, ) assert [item["confirmId"] for item in pending] == ["scope-a"] rejected = database_backend.decide( "scope-a", approve=False, approver={"userId": "7502"}, allowed=lambda _record: True, now_epoch=3.0, grant_ttl_seconds=300, ) assert rejected is not None history = database_backend.history_items( tenant_uuid="tenant-a", project_id="project-a", allowed=lambda _record: True, limit=100, now_epoch=4.0, ) assert [(item["confirmId"], item["status"]) for item in history] == [ ("scope-a", "REJECTED") ] def test_database_backend_enforces_sod_and_stores_only_grant_digest(database_backend): owner = _identity(7201) second_approver = _identity(7202) confirm_id, _params = _as(owner, _stage) first = _as(owner, lambda: harness.take_confirmation(confirm_id, approve=True)) denied = _as(owner, lambda: harness.take_confirmation(confirm_id, approve=True)) approved = _as(second_approver, lambda: harness.take_confirmation(confirm_id, approve=True)) assert first and first["approvalStep"] == 1 assert denied and denied["separationRequired"] is True assert approved and approved["executionGrant"] grant = str(approved["executionGrant"]) with get_session() as session: row = session.scalar(select(ApprovalGrantRecord)) assert row is not None assert row.token_hash == hashlib.sha256(grant.encode("utf-8")).hexdigest() assert grant not in json.dumps(row.payload, sort_keys=True) assert grant not in row.token_hash def test_cross_connection_final_approval_issues_one_grant_and_consumes_once(database_backend): owner = _identity(7301) confirm_id, params = _as(owner, _stage) first = _as(owner, lambda: harness.take_confirmation(confirm_id, approve=True)) assert first and first["needsSecondConfirm"] is True approval_barrier = threading.Barrier(2) approval_results: list[dict | None] = [] def approve(identity: IdentityContext) -> None: token = bind_identity(identity) try: approval_barrier.wait() approval_results.append(harness.take_confirmation(confirm_id, approve=True)) finally: reset_identity(token) threads = [ threading.Thread(target=approve, args=(_identity(7302),)), threading.Thread(target=approve, args=(_identity(7303),)), ] for thread in threads: thread.start() for thread in threads: thread.join() grants = [ str(result["executionGrant"]) for result in approval_results if result and result.get("executionGrant") ] assert len(grants) == 1 with get_session() as session: assert len(list(session.scalars(select(ApprovalGrantRecord)))) == 1 consume_barrier = threading.Barrier(2) consume_results: list[bool] = [] def consume() -> None: token = bind_identity(owner) try: consume_barrier.wait() consume_results.append(harness.consume_execution_grant( grants[0], confirm_id=confirm_id, action="mes.dispatch", params=params, )) finally: reset_identity(token) consumers = [threading.Thread(target=consume), threading.Thread(target=consume)] for thread in consumers: thread.start() for thread in consumers: thread.join() assert sorted(consume_results) == [False, True] with get_session() as session: statuses = list(session.scalars( select(ApprovalEventRecord.status).where(ApprovalEventRecord.confirm_id == confirm_id) )) assert statuses.count("APPROVED") == 1 assert statuses.count("EXECUTED") == 1 def test_database_backend_approve_reject_race_has_one_terminal_state(database_backend): owner = _identity(7401) confirm_id, _params = _as(owner, lambda: _stage("schedule.publish")) barrier = threading.Barrier(2) results: list[dict | None] = [] def decide(approve: bool) -> None: token = bind_identity(owner) try: barrier.wait() results.append(harness.take_confirmation(confirm_id, approve=approve)) finally: reset_identity(token) threads = [threading.Thread(target=decide, args=(True,)), threading.Thread(target=decide, args=(False,))] for thread in threads: thread.start() for thread in threads: thread.join() assert sum(result is not None for result in results) == 1 with get_session() as session: statuses = list(session.scalars( select(ApprovalEventRecord.status).where(ApprovalEventRecord.confirm_id == confirm_id) )) assert sum(status in {"APPROVED", "REJECTED"} for status in statuses) == 1 def test_database_backend_requires_explicit_sqlite_opt_in(tmp_path: Path, monkeypatch): monkeypatch.setenv("APS_APPROVAL_BACKEND", "database") monkeypatch.delenv("APS_APPROVAL_PATH", raising=False) monkeypatch.setenv("APS_DATABASE_URL", f"sqlite:///{(tmp_path / 'blocked.db').as_posix()}") monkeypatch.delenv("APS_APPROVAL_DATABASE_ALLOW_SQLITE", raising=False) with pytest.raises(RuntimeError, match="SQLite approval storage is test-only"): harness.configure_approval_store() def test_database_backend_requires_explicit_database_url(monkeypatch): monkeypatch.setenv("APS_APPROVAL_BACKEND", "database") monkeypatch.delenv("APS_APPROVAL_PATH", raising=False) monkeypatch.delenv("APS_DATABASE_URL", raising=False) with pytest.raises(RuntimeError, match="requires an explicit APS_DATABASE_URL"): harness.configure_approval_store() def test_database_backend_rejects_non_mysql_database(monkeypatch): monkeypatch.setenv("APS_APPROVAL_BACKEND", "database") monkeypatch.delenv("APS_APPROVAL_PATH", raising=False) monkeypatch.setenv("APS_DATABASE_URL", "postgresql://approval:secret@db.invalid/aps") with pytest.raises(RuntimeError, match="requires MySQL"): harness.configure_approval_store() def test_database_backend_rejects_file_path_configuration(tmp_path: Path, monkeypatch): monkeypatch.setenv("APS_APPROVAL_BACKEND", "database") monkeypatch.setenv("APS_APPROVAL_PATH", str(tmp_path / "approvals.json")) monkeypatch.setenv("APS_DATABASE_URL", f"sqlite:///{(tmp_path / 'approval.db').as_posix()}") monkeypatch.setenv("APS_APPROVAL_DATABASE_ALLOW_SQLITE", "1") with pytest.raises(RuntimeError, match="cannot be combined"): harness.configure_approval_store() def test_database_backend_unavailable_fails_closed(monkeypatch): import server.agent_core.approval_db_store as database_store_module monkeypatch.setenv("APS_APPROVAL_BACKEND", "database") monkeypatch.delenv("APS_APPROVAL_PATH", raising=False) monkeypatch.setenv( "APS_DATABASE_URL", "mysql+pymysql://approval:invalid@127.0.0.1:3306/aps", ) monkeypatch.delenv("APS_APPROVAL_DATABASE_ALLOW_SQLITE", raising=False) monkeypatch.setattr( database_store_module, "get_engine", lambda: (_ for _ in ()).throw(ConnectionError("offline")), ) with pytest.raises(RuntimeError, match="approval database is unavailable"): harness.configure_approval_store() @pytest.mark.parametrize( ("revisions", "table_engines", "expected_error"), [ ( ["20260731_03"], [ ("aps_approval_requests", "InnoDB"), ("aps_approval_grants", "InnoDB"), ("aps_approval_events", "InnoDB"), ], None, ), ([], [], "required Alembic revision 20260731_03"), (["20260728_02"], [], "required Alembic revision 20260731_03"), (["20260731_03"], [], "tables must exist and use InnoDB"), ( ["20260731_03"], [ ("aps_approval_requests", "InnoDB"), ("aps_approval_grants", "MyISAM"), ("aps_approval_events", "InnoDB"), ], "tables must exist and use InnoDB", ), ], ) def test_database_backend_requires_shared_approval_migration( monkeypatch: pytest.MonkeyPatch, revisions: list[str], table_engines: list[tuple[str, str]], expected_error: str | None, ): import server.agent_core.approval_db_store as database_store_module class Result: def __init__(self, values: list[str] | None = None): self.values = values or [] def scalars(self): return iter(self.values) def all(self): return list(self.values) class Connection: def __enter__(self): return self def __exit__(self, *_args): return None def execute(self, statement): if "alembic_version" in str(statement): return Result(revisions) if "information_schema.TABLES" in str(statement): return Result(table_engines) return Result() class Engine: dialect = type("Dialect", (), {"name": "mysql"})() @staticmethod def connect(): return Connection() monkeypatch.setattr(database_store_module, "get_engine", lambda: Engine()) if expected_error is None: database_store_module.DatabaseApprovalStore() else: with pytest.raises(RuntimeError, match=expected_error): database_store_module.DatabaseApprovalStore() def test_shared_approval_migration_matches_orm_and_downgrades( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ): database_path = tmp_path / "approval-migration.db" monkeypatch.delenv("APS_DATABASE_URL", raising=False) monkeypatch.setenv("APS_DB_PATH", str(database_path)) reset_engine() config = Config("alembic.ini") command.upgrade(config, "20260731_03") engine = create_engine(f"sqlite:///{database_path.as_posix()}") inspector = inspect(engine) models = (ApprovalRequestRecord, ApprovalGrantRecord, ApprovalEventRecord) assert {model.__tablename__ for model in models} <= set(inspector.get_table_names()) for model in models: table = model.__table__ actual_columns = {column["name"]: column for column in inspector.get_columns(table.name)} assert set(actual_columns) == {column.name for column in table.columns} assert set(inspector.get_pk_constraint(table.name)["constrained_columns"]) == { column.name for column in table.primary_key.columns } assert {index["name"] for index in inspector.get_indexes(table.name)} == { index.name for index in table.indexes } grant_foreign_keys = inspector.get_foreign_keys(ApprovalGrantRecord.__tablename__) assert any( key["referred_table"] == ApprovalRequestRecord.__tablename__ and key["constrained_columns"] == ["confirm_id"] and key["referred_columns"] == ["confirm_id"] for key in grant_foreign_keys ) engine.dispose() command.downgrade(config, "20260728_02") downgraded_engine = create_engine(f"sqlite:///{database_path.as_posix()}") remaining_tables = set(inspect(downgraded_engine).get_table_names()) assert not ({model.__tablename__ for model in models} & remaining_tables) downgraded_engine.dispose() reset_engine() def test_runtime_schema_init_does_not_precreate_approval_migration_tables( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ): database_path = tmp_path / "approval-runtime-init.db" monkeypatch.delenv("APS_DATABASE_URL", raising=False) monkeypatch.delenv("APS_APPROVAL_DATABASE_ALLOW_SQLITE", raising=False) monkeypatch.setenv("APS_DB_PATH", str(database_path)) reset_engine() config = Config("alembic.ini") command.upgrade(config, "20260728_02") approval_tables = { ApprovalRequestRecord.__tablename__, ApprovalGrantRecord.__tablename__, ApprovalEventRecord.__tablename__, } runtime_engine = get_engine() assert not (approval_tables & set(inspect(runtime_engine).get_table_names())) reset_engine() command.upgrade(config, "20260731_03") upgraded_engine = create_engine(f"sqlite:///{database_path.as_posix()}") assert approval_tables <= set(inspect(upgraded_engine).get_table_names()) upgraded_engine.dispose() reset_engine()