495 lines
18 KiB
Python
495 lines
18 KiB
Python
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()
|