aps-agent/tests/golden/test_approval_database_stor...

508 lines
18 KiB
Python
Raw Normal View History

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"),
[
(
["20260804_01"],
[
("aps_approval_requests", "InnoDB"),
("aps_approval_grants", "InnoDB"),
("aps_approval_events", "InnoDB"),
],
None,
),
([], [], "required Alembic revision 20260804_01"),
(["20260728_02"], [], "required Alembic revision 20260804_01"),
(["20260804_01"], [], "tables must exist and use InnoDB"),
(
["20260804_01"],
[
("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, "20260804_01")
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, "20260731_03")
b1_engine = create_engine(f"sqlite:///{database_path.as_posix()}")
b1_inspector = inspect(b1_engine)
assert {model.__tablename__ for model in models} <= set(b1_inspector.get_table_names())
b1_engine.dispose()
command.upgrade(config, "20260804_01")
head_engine = create_engine(f"sqlite:///{database_path.as_posix()}")
assert {model.__tablename__ for model in models} <= set(
inspect(head_engine).get_table_names()
)
head_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()