diff --git a/.agent-loop/initiatives/WS-CI-001-backend-ci-acceleration/STATUS.md b/.agent-loop/initiatives/WS-CI-001-backend-ci-acceleration/STATUS.md index c30e141a..1b918dee 100644 --- a/.agent-loop/initiatives/WS-CI-001-backend-ci-acceleration/STATUS.md +++ b/.agent-loop/initiatives/WS-CI-001-backend-ci-acceleration/STATUS.md @@ -1,12 +1,14 @@ # STATUS: WS-CI-001 - Backend CI Acceleration -- Phase: signed planning amendment -- Active planning chunk: `WS-CI-001-02` -- Active implementation chunk: none -- Proposed implementation successor: `WS-CI-001-02A` +- Phase: signed implementation +- Active planning chunk: none +- Active implementation chunk: `WS-CI-001-02A` +- Proposed implementation successor: `WS-CI-001-02B` - Later proposed chunk: `WS-CI-001-02B` (cannot start before 02A evidence) - Human direction: preserve Konan's authorship and measured work from PR #180, but adopt it only under prospective zero-trust scope and evidence -- Current gate: internal review passed; planning-only PR and user-owned PR decision +- Current gate: runtime schema recovery reviewed at `cf91bb81`; full Backend suite, + canonical fingerprint, and all coverage gates remain before user-owned merge + decision - Deferred option: routing/cache/timing reassessment is future `WS-CI-001-03`, with no start or successor declaration diff --git a/.agent-loop/initiatives/WS-CI-001-backend-ci-acceleration/reviews/WS-CI-001-02A-external-review-response.md b/.agent-loop/initiatives/WS-CI-001-backend-ci-acceleration/reviews/WS-CI-001-02A-external-review-response.md new file mode 100644 index 00000000..56b806e3 --- /dev/null +++ b/.agent-loop/initiatives/WS-CI-001-backend-ci-acceleration/reviews/WS-CI-001-02A-external-review-response.md @@ -0,0 +1,72 @@ +# External Review Response + +## Chunk + +`WS-CI-001-02A` — Safe Migrate-Once Database Reset + +## Source + +GitHub Backend run `29924806229` on PR #186. + +## Comments Addressed + +- Shards 1, 3, and 4 exposed order-dependent schema contamination after + whole-schema Alembic tests. Later ordinary tests correctly failed closed on + missing final tables or a noncanonical fingerprint. +- The schema-contract fixture teardown previously trusted `upgrade head`, which + cannot repair structural state when revision metadata already claims head. +- Setup and teardown for explicitly marked `postgres_schema_contract` tests now + perform the same database-custody-checked drop, recreate, and migrate-to-head + sequence under the database-specific DDL lock. +- A second hosted rerun exposed four migration-mutating tests outside + `test_alembic.py`. The three artifact-admission migration tests and one task + downgrade test are now individually marked as schema-contract owners, so + they receive the same full rebuild without slowing their entire modules. +- Every unmarked database test now revalidates database custody and the full + canonical schema fingerprint during teardown. The AST ownership scan remains + early guidance; runtime verification is authoritative even when DDL is hidden + behind aliases, helpers, fixtures, or another migration entry point. +- When ordinary teardown detects drift, it fails the originating test, performs + a custody-checked full rebuild, and then re-raises the original verification + error. If recovery also fails, both failures are preserved in an exception + group, preventing both silent healing and shard-wide contamination. +- Ordinary resets retain the exact table inventory and full fingerprint gate. +- Removed the trust bundle's extra blank line at EOF. +- Removed CodeRabbit's valid obsolete `include_canonical_actors` reset argument; + it had no callers and no behavior beyond deleting its value. + +## Comments Deferred + +- CodeRabbit's generic 80 percent docstring-coverage warning is not a repository + gate and would require unrelated documentation churn outside signed 02A scope. + +## Human Decisions Needed + +None before rerunning CI. Merge still requires explicit user approval for this +specific PR. + +## Commands Rerun + +```bash +cd backend +ruff check tests/conftest.py tests/test_database_reset.py +python scripts/run_isolated_tests.py --metadata-json /repair.json \ + --timeout-seconds 1200 -- python -m pytest -q \ + tests/test_alembic.py::test_artifact_recovery_schema_and_empty_downgrade \ + tests/test_database_reset.py::test_database_reset_preserves_schema_and_restores_guards +cd .. +git diff --check +``` + +The focused ordering proof passed 2/2. Full-suite duration and coverage are +accepted only from the rerun GitHub Backend workflow. + +The runtime-boundary proof also passed 2/2: direct Alembic ownership guidance +and hidden helper-driven schema drift rejected by ordinary fixture teardown. + +## Remaining Risks + +- All four hosted shards must prove that schema-contract tests no longer poison + subsequent ordinary tests. +- Combined global 78 percent and every protected 90 percent coverage gate must + pass unchanged. diff --git a/.agent-loop/initiatives/WS-CI-001-backend-ci-acceleration/reviews/WS-CI-001-02A-internal-review-evidence.md b/.agent-loop/initiatives/WS-CI-001-backend-ci-acceleration/reviews/WS-CI-001-02A-internal-review-evidence.md new file mode 100644 index 00000000..79f79a84 --- /dev/null +++ b/.agent-loop/initiatives/WS-CI-001-backend-ci-acceleration/reviews/WS-CI-001-02A-internal-review-evidence.md @@ -0,0 +1,76 @@ +# Internal Review Evidence + +## Chunk + +`WS-CI-001-02A` — Safe Migrate-Once Database Reset + +## Required Statements + +open sub-agent sessions: none + +valid findings addressed: yes + +## Reviewed Revision + +Reviewed code SHA: cf91bb81ac44e9ff9cbdc0f8b924959ee1a0554e + +Reviewed at: 2026-07-22T14:22:45Z + +Reviewer run IDs: eng006_senior_arch_docs, eng006_qa_ci_tests, eng006_security_ops_reuse + +After the reviewed SHA, only review evidence, status, and trust-bundle files +changed. + +## Reviewer Results + +| Reviewer | Result | Blocking findings | Notes | +|---|---:|---|---| +| senior engineering | PASS | None | Full hosted Backend and coverage evidence remains required. | +| QA/test | PASS | None | Exact collection is 1,888 baseline plus 29 reset tests; hosted proof remains required. | +| security/auth | PASS | None | Custody, schema drift, rollback, and destructive boundaries pass. | +| product/ops | PASS | None | Product behavior is unchanged and contributor attribution is preserved. | +| architecture | PASS | None | Generic namespace inventory closed the last schema-object gap. | +| CI integrity | PASS | None | No workflow, runner, coverage-command, or threshold change; hosted gates remain blocking. | +| docs | PASS | None | Status, evidence, and trust bundle describe the bounded implementation. | +| reuse/dedup | PASS | None | The canonical isolated runner and one reset inventory remain authoritative. | +| test delta | PASS | None | No removed, skipped, deselected, or weakened tests; hosted completion remains required. | + +## Valid Findings Addressed + +- Rejected destructive reset targets unless the URL and live PostgreSQL state + prove an isolated loopback database owned by its exact least-privileged role. +- Replaced dynamic table discovery with reviewed protected and resettable + inventories, including all seven trigger-guarded tables. +- Bound reset to an exact migrated-schema fingerprint covering direct public + namespace membership and detailed structural and executable definitions. +- Added fail-closed tests for unexpected tables, functions, composite types, + collations, columns, and triggers, proving rejection occurs before mutation. +- Proved repeated reset, exception, cancellation, and real termination preserve + protected state and restore every guarded trigger. +- Replaced schema-contract teardown's marker-trusting upgrade with a full + custody-checked rebuild so migration tests cannot poison later shard tests. +- Removed the obsolete, uncalled `include_canonical_actors` reset keyword after + CodeRabbit identified that it no longer represented behavior. +- Marked all four migration mutators outside `test_alembic.py`, added static + ownership guidance, and made runtime teardown detect, attribute, recover, and + preserve errors for hidden schema drift. +- Restored exact Boolean assertions and preserved the trusted-main collection. + +## Evidence + +- `ruff check tests/conftest.py tests/test_database_reset.py`: passed. +- Isolated reset suite: 27 passed on a freshly migrated database. +- Focused schema-contract-to-ordinary-test ordering proof: 2 passed. +- Runtime hidden-drift detection and recovery proof: passed. +- Isolated runner suite: 16 passed. +- Collection: 1,917 tests, exactly 1,888 trusted-main tests plus 29 reset tests. +- Agent gates: 95 passed. +- `git diff --check`: passed. + +## Remaining Hosted Conditions + +- The unchanged GitHub Backend job must pass the complete 1,917-test suite on + the PR head; local machine timing is not performance evidence. +- Hosted PostgreSQL must reproduce the committed canonical schema fingerprint. +- The unchanged global 78 percent and every protected 90 percent coverage gate + must pass before merge readiness is reported. diff --git a/.agent-loop/initiatives/WS-CI-001-backend-ci-acceleration/reviews/WS-CI-001-02A-pr-trust-bundle.md b/.agent-loop/initiatives/WS-CI-001-backend-ci-acceleration/reviews/WS-CI-001-02A-pr-trust-bundle.md new file mode 100644 index 00000000..12be7ec2 --- /dev/null +++ b/.agent-loop/initiatives/WS-CI-001-backend-ci-acceleration/reviews/WS-CI-001-02A-pr-trust-bundle.md @@ -0,0 +1,88 @@ +# PR Trust Bundle + +## Chunk + +`WS-CI-001-02A` — Safe Migrate-Once Database Reset + +Merge intent: `.agent-loop/merge-intents/WS-CI-001-02A.json` + +## Goal + +Adopt Konan's migrate-once fixture work while proving that every destructive +reset is confined to an exact runner-owned database and reviewed schema. + +## What Changed + +- Migrated selected backend fixtures to one migration followed by transactional + database resets. +- Added live database and role custody checks before any destructive action. +- Added exact protected/resettable inventories and a canonical full-schema + fingerprint, including generic public namespace-object membership. +- Added rollback, signal, repeated-reset, and adversarial schema-drift tests. +- Made explicitly marked whole-schema migration tests rebuild from blank, + custody-checked schema state in both setup and teardown. +- Preserved exact assertions, existing test collection, coverage thresholds, + workflows, runner topology, product code, and migrations. + +## Scope and Attribution + +The implementation stays within the signed 02A allowlist. The adopted source +work is preserved in commit `58125242` with Konan as author; subsequent commits +are bounded safety and review repairs. No workflow or product behavior changed. + +## Acceptance Evidence + +- Fresh isolated reset safety suite: 27 passed; two subsequent ownership and + runtime-boundary regressions passed independently. +- Exact collection: 1,917 tests, equal to 1,888 on trusted main plus 29 new + reset tests; no existing node was lost. +- Rerun, exception, cancellation, and SIGTERM paths preserve Alembic and actor + migration state and leave all seven guarded triggers enabled. +- Unexpected table, function, type, collation, column, and trigger drift fails + before reset and remains present after rejection. +- 95 agent gate tests and diff integrity passed. + +## CI Integrity + +- [x] No workflow, shard, lane, runner, or coverage command changed +- [x] No test was skipped, deselected, removed, or weakened +- [x] Global 78 percent and all protected 90 percent floors remain blocking +- [ ] Full GitHub Backend workflow passes the exact PR head +- [ ] Hosted PostgreSQL reproduces the canonical schema fingerprint + +## Internal Review + +Reviewed code SHA: cf91bb81ac44e9ff9cbdc0f8b924959ee1a0554e + +Reviewed at: 2026-07-22T14:22:45Z + +Reviewer run IDs: eng006_senior_arch_docs, eng006_qa_ci_tests, eng006_security_ops_reuse + +Senior/architecture and QA/CI/test-delta passed after the hosted ordering repair, +with only hosted evidence conditions. Security/auth, product/ops, reuse/dedup, +and documentation passed without a code blocker. All valid internal findings +were repaired. + +## Remaining Risk and Human Review Focus + +The fingerprint intentionally binds PostgreSQL catalog definitions, so the +hosted PostgreSQL run is the portability proof. Review database/role custody, +schema fingerprint completeness, rollback restoration, exact test delta, and +Konan's preserved attribution. + +Local duration is not used to claim performance success. Only the complete +GitHub Backend job and all coverage gates determine acceptance. + +## Follow-up + +Do not start `WS-CI-001-02B` automatically. After this PR merges and signed +memory reconciles, it remains a same-initiative successor requiring explicit +start. + +## Human Merge Ownership + +- [ ] I can explain what changed. +- [ ] I can explain why it changed. +- [ ] I know what could break. +- [ ] I accept the remaining risks. +- [ ] The user explicitly approved this specific PR for merge. diff --git a/.agent-loop/merge-intents/WS-CI-001-02A.json b/.agent-loop/merge-intents/WS-CI-001-02A.json new file mode 100644 index 00000000..5e173e1b --- /dev/null +++ b/.agent-loop/merge-intents/WS-CI-001-02A.json @@ -0,0 +1,9 @@ +{ + "chunk_id": "WS-CI-001-02A", + "chunk_title": "Safe Migrate-Once Database Reset", + "initiative_id": "WS-CI-001", + "next_chunk_id": "WS-CI-001-02B", + "next_chunk_title": "Exact-Custody Semantic Test Lanes", + "next_requires_explicit_start": true, + "schema_version": 2 +} diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 9b33baf3..476917de 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -40,6 +40,9 @@ dev = [ [tool.pytest.ini_options] asyncio_mode = "auto" +markers = [ + "postgres_schema_contract: test owns whole-schema Alembic transitions and requires a rebuilt PostgreSQL baseline", +] pythonpath = ["."] testpaths = ["tests"] diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 8df8f8a0..4bc8d6d2 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -1,98 +1,287 @@ from __future__ import annotations from collections.abc import Awaitable, Callable, Iterator -from contextlib import contextmanager +from contextlib import AbstractContextManager, contextmanager +import asyncio import fcntl +from functools import partial import hashlib import os from pathlib import Path +from urllib.parse import unquote, urlsplit -import asyncpg -import pytest +from alembic import command # type: ignore[import-not-found,attr-defined] +from alembic.config import Config # type: ignore[import-not-found] +import asyncpg # type: ignore[import-not-found] +import pytest # type: ignore[import-not-found] from app.core.config import get_settings +from app.db import session as db_session +from scripts.run_isolated_tests import LOOPBACK, NAME_RE, ROLE_RE DDL_LOCK_DIRECTORY = Path("/tmp") +EXPECTED_PUBLIC_SCHEMA_SHA256 = ( + "57c0f4b00709fb1c865277d9cac07ea2bc34d22ef26f36f93a7214986cb1388b" +) +PROTECTED_TEST_TABLES = ( + "actor_profile_migration_state", + "alembic_version", +) +RESETTABLE_TEST_TABLES = ( + "actor_identity_links", + "actor_profiles", + "admin_role_grants", + "api_rate_control_counters", + "artifact_admission_charges", + "artifact_admission_scopes", + "artifact_bindings", + "artifact_contents", + "artifact_operation_receipts", + "artifact_put_attempt_charges", + "artifact_put_attempts", + "artifact_put_observation_receipts", + "artifact_recovery_attempts", + "artifact_replicas", + "artifact_storage_namespaces", + "artifact_upload_items", + "artifact_upload_sessions", + "artifact_verification_jobs", + "artifact_verification_receipts", + "audit_events", + "authority_control", + "authority_idempotency_records", + "checker_policies", + "checker_results", + "checker_runs", + "effective_project_submission_artifact_policies", + "evidence_items", + "guide_source_snapshot_items", + "guide_source_snapshots", + "guide_sufficiency_reports", + "legacy_actor_identities", + "legacy_workflow_eligibility", + "outbox_events", + "payment_policies", + "pre_submit_checker_policies", + "project_guides", + "project_role_grants", + "project_role_qualification_snapshots", + "project_setup_runs", + "projects", + "review_policies", + "revision_policies", + "submission_artifact_policies", + "submissions", + "task_assignments", + "workstream_tasks", +) +TRUNCATE_GUARDED_TABLES = ( + "admin_role_grants", + "audit_events", + "authority_control", + "authority_idempotency_records", + "outbox_events", + "project_role_grants", + "project_role_qualification_snapshots", +) TestDatabaseReset = Callable[..., Awaitable[None]] +DatabaseLock = Callable[[], AbstractContextManager[None]] +ResetHook = Callable[[], Awaitable[None]] + + +async def _assert_owned_test_database( + connection: asyncpg.Connection, + database_url: str, +) -> None: + """Fail closed unless the live target is the runner-owned test database.""" + parsed = urlsplit(database_url) + url_database = parsed.path.removeprefix("/") + url_role = unquote(parsed.username or "") + database_match = NAME_RE.fullmatch(url_database) + role_match = ROLE_RE.fullmatch(url_role) + if ( + parsed.scheme != "postgresql+asyncpg" + or parsed.hostname not in LOOPBACK + or not parsed.password + or bool(parsed.query) + or bool(parsed.fragment) + or database_match is None + or role_match is None + or url_database.removeprefix("workstream_test_") + != url_role.removeprefix("workstream_role_") + ): + raise RuntimeError("unsafe test database target") + + custody = await connection.fetchrow( + "select current_database() as database_name, current_user as session_role, " + "pg_get_userbyid(d.datdba) as owner_role, r.rolsuper, r.rolcreatedb, " + "r.rolcreaterole, r.rolinherit, r.rolreplication, r.rolbypassrls, " + "(select count(*) from pg_auth_members m where m.member = r.oid) " + "as membership_count " + "from pg_database d join pg_roles r on r.rolname = current_user " + "where d.datname = current_database()" + ) + if custody is None or ( + custody["database_name"] != url_database + or custody["session_role"] != url_role + or custody["owner_role"] != url_role + or custody["rolsuper"] + or custody["rolcreatedb"] + or custody["rolcreaterole"] + or custody["rolinherit"] + or custody["rolreplication"] + or custody["rolbypassrls"] + or custody["membership_count"] != 0 + ): + raise RuntimeError("test database custody check failed") + + +async def _assert_canonical_test_schema( + connection: asyncpg.Connection, +) -> dict[str, str]: + """Return quoted resettable tables only for the exact reviewed schema.""" + rows = await connection.fetch( + "select tablename, quote_ident(tablename) as identifier " + "from pg_tables where schemaname = 'public' order by tablename" + ) + actual = {row["tablename"]: row["identifier"] for row in rows} + expected = set(PROTECTED_TEST_TABLES) | set(RESETTABLE_TEST_TABLES) + if set(actual) != expected: + missing = ",".join(sorted(expected - set(actual))) or "none" + unexpected = ",".join(sorted(set(actual) - expected)) or "none" + raise RuntimeError( + f"unexpected test database schema: missing={missing}; unexpected={unexpected}" + ) + object_rows = await connection.fetch( + "with namespace_objects(kind,name) as (" + "select 'namespace-object',concat_ws('|',x.type,x.schema,x.name,x.identity) " + "from (select (pg_identify_object(d.classid,d.objid,d.objsubid)).* " + "from pg_depend d join pg_namespace n on n.oid=d.refobjid " + "where d.refclassid='pg_namespace'::regclass and n.nspname='public' " + "and d.deptype='n') x), parts(kind,name) as (" + "select kind,name from namespace_objects union all " + "select 'relation',c.relname||'|'||concat_ws('|',c.relkind::text," + "c.relpersistence::text,c.relreplident::text,c.relrowsecurity::text," + "c.relforcerowsecurity::text,coalesce(c.reloptions::text,'')," + "coalesce(c.relacl::text,'')) from pg_class c join pg_namespace n " + "on n.oid=c.relnamespace where n.nspname='public' " + "union all select 'column',c.relname||'.'||a.attname||'|'||concat_ws('|'," + "a.attnum::text,format_type(a.atttypid,a.atttypmod),a.attnotnull::text," + "a.attidentity,a.attgenerated,coalesce(pg_get_expr(d.adbin,d.adrelid),'')) " + "from pg_attribute a join pg_class c on c.oid=a.attrelid " + "join pg_namespace n on n.oid=c.relnamespace left join pg_attrdef d " + "on d.adrelid=a.attrelid and d.adnum=a.attnum where n.nspname='public' " + "and a.attnum>0 and not a.attisdropped " + "union all select 'constraint',coalesce(c.relname,'')||'.'||q.conname||'|'||" + "q.contype::text||'|'||pg_get_constraintdef(q.oid,true) from pg_constraint q " + "left join pg_class c on c.oid=q.conrelid join pg_namespace n " + "on n.oid=q.connamespace where n.nspname='public' " + "union all select 'index',tablename||'.'||indexname||'|'||indexdef " + "from pg_indexes where schemaname='public' " + "union all select 'trigger',c.relname||'.'||t.tgname||'|'||t.tgenabled::text||'|'||" + "pg_get_triggerdef(t.oid,true) from pg_trigger t join pg_class c " + "on c.oid=t.tgrelid join pg_namespace n on n.oid=c.relnamespace " + "where n.nspname='public' and not t.tgisinternal " + "union all select 'policy',c.relname||'.'||p.polname||'|'||concat_ws('|'," + "p.polpermissive::text,p.polcmd,p.polroles::text," + "coalesce(pg_get_expr(p.polqual,p.polrelid),'')," + "coalesce(pg_get_expr(p.polwithcheck,p.polrelid),'')) from pg_policy p " + "join pg_class c on c.oid=p.polrelid join pg_namespace n " + "on n.oid=c.relnamespace where n.nspname='public' " + "union all select 'rule',schemaname||'.'||tablename||'.'||rulename||'|'||" + "definition from pg_rules where schemaname='public' " + "union all select 'view',schemaname||'.'||viewname||'|'||definition " + "from pg_views where schemaname='public' " + "union all select 'matview',schemaname||'.'||matviewname||'|'||definition " + "from pg_matviews where schemaname='public' " + "union all select 'function',p.proname||'('||" + "pg_get_function_identity_arguments(p.oid)||')|'||pg_get_functiondef(p.oid) " + "from pg_proc p join pg_namespace n on n.oid=p.pronamespace " + "where n.nspname='public' " + "union all select 'sequence',c.relname||'|'||concat_ws('|',s.seqtypid::regtype::text," + "s.seqstart::text,s.seqincrement::text,s.seqmax::text,s.seqmin::text," + "s.seqcache::text,s.seqcycle::text) from pg_sequence s join pg_class c " + "on c.oid=s.seqrelid join pg_namespace n on n.oid=c.relnamespace " + "where n.nspname='public' " + "union all select 'enum',t.typname||'|'||e.enumsortorder::text||'|'||e.enumlabel " + "from pg_enum e join pg_type t on t.oid=e.enumtypid join pg_namespace n " + "on n.oid=t.typnamespace where n.nspname='public' " + "union all select 'type',t.typname||'|'||concat_ws('|',t.typtype::text," + "t.typcategory::text,t.typispreferred::text,t.typnotnull::text," + "case when t.typelem=0 then '' else t.typelem::regtype::text end," + "coalesce(pg_get_expr(t.typdefaultbin,0),t.typdefault,'')) " + "from pg_type t join pg_namespace n on n.oid=t.typnamespace " + "where n.nspname='public') " + "select kind,name from parts order by kind,name" + ) + serialized_objects = "".join( + f"{row['kind']}|{row['name']}\n" for row in object_rows + ).encode("utf-8") + schema_sha256 = hashlib.sha256(serialized_objects).hexdigest() + if schema_sha256 != EXPECTED_PUBLIC_SCHEMA_SHA256: + raise RuntimeError(f"unexpected public schema object fingerprint: {schema_sha256}") + return {name: actual[name] for name in RESETTABLE_TEST_TABLES} + + +def _alembic_config() -> Config: + root = Path(__file__).resolve().parents[1] + config = Config(str(root / "alembic.ini")) + config.set_main_option("script_location", str(root / "alembic")) + return config + + +async def _drop_test_database_schema(database_url: str) -> None: + """Clear schema state owned by an explicitly marked migration-contract test.""" + connection = await asyncpg.connect(database_url.replace("+asyncpg", "")) + try: + await _assert_owned_test_database(connection, database_url) + await connection.execute("drop schema if exists public cascade") + await connection.execute("create schema public") + finally: + await connection.close() + + +def _rebuild_test_database_schema(database_url: str) -> None: + with postgres_ddl_lock(database_url): + asyncio.run(_drop_test_database_schema(database_url)) + command.upgrade(_alembic_config(), "head") + + +async def _verify_test_database_schema(database_url: str) -> None: + """Verify that an ordinary test returned its owned database schema unchanged.""" + connection = await asyncpg.connect(database_url.replace("+asyncpg", "")) + try: + await _assert_owned_test_database(connection, database_url) + await _assert_canonical_test_schema(connection) + finally: + await connection.close() async def _reset_test_database_state( database_url: str, *, - include_canonical_actors: bool = False, + after_disable: ResetHook | None = None, ) -> None: - """Reset test-owned append-only state under explicit privileged custody.""" + """Restore the already-migrated isolated database to its empty baseline.""" connection = await asyncpg.connect(database_url.replace("+asyncpg", "")) try: + await _assert_owned_test_database(connection, database_url) + tables = await _assert_canonical_test_schema(connection) async with connection.transaction(): + for name in TRUNCATE_GUARDED_TABLES: + await connection.execute(f"alter table {tables[name]} disable trigger user") + if after_disable is not None: + await after_disable() await connection.execute( - "alter table audit_events disable trigger audit_events_reject_update_delete" - ) - await connection.execute( - "alter table audit_events disable trigger audit_events_reject_truncate" - ) - await connection.execute( - "alter table authority_idempotency_records disable trigger user" - ) - await connection.execute( - "alter table project_role_grants disable trigger user" - ) - await connection.execute( - "alter table project_role_qualification_snapshots disable trigger user" - ) - await connection.execute("alter table admin_role_grants disable trigger user") - await connection.execute("alter table authority_control disable trigger user") - if include_canonical_actors: - await connection.execute( - "alter table actor_profiles disable trigger actor_profile_history_guard" - ) - await connection.execute( - "alter table actor_identity_links disable trigger " - "actor_identity_link_history_guard" - ) - await connection.execute("truncate table audit_events cascade") - await connection.execute("truncate table authority_idempotency_records cascade") - await connection.execute( - "truncate table project_role_grants, " - "project_role_qualification_snapshots" - ) - await connection.execute( - "truncate table authority_control, admin_role_grants cascade" + f"truncate table {', '.join(tables.values())} restart identity cascade" ) await connection.execute( "insert into authority_control" "(id, bootstrap_completed, bootstrap_grant_id, version) " "values (1, false, null, 0)" ) - await connection.execute("truncate table api_rate_control_counters") - if include_canonical_actors: - await connection.execute( - "truncate table actor_identity_links, actor_profiles cascade" - ) - await connection.execute( - "alter table actor_identity_links enable trigger " - "actor_identity_link_history_guard" - ) - await connection.execute( - "alter table actor_profiles enable trigger actor_profile_history_guard" - ) - await connection.execute( - "alter table audit_events enable trigger audit_events_reject_truncate" - ) - await connection.execute("alter table authority_control enable trigger user") - await connection.execute("alter table admin_role_grants enable trigger user") - await connection.execute( - "alter table authority_idempotency_records enable trigger user" - ) - await connection.execute( - "alter table project_role_qualification_snapshots enable trigger user" - ) - await connection.execute( - "alter table project_role_grants enable trigger user" - ) - await connection.execute( - "alter table audit_events enable trigger audit_events_reject_update_delete" - ) + for name in TRUNCATE_GUARDED_TABLES: + await connection.execute(f"alter table {tables[name]} enable trigger user") finally: await connection.close() @@ -112,16 +301,9 @@ def postgres_ddl_lock(database_url: str) -> Iterator[None]: @pytest.fixture -def migration_lock(postgres_database_url: str): +def migration_lock(postgres_database_url: str) -> DatabaseLock: """Return a database-scoped PostgreSQL DDL lock context manager.""" - - @contextmanager - def lock() -> Iterator[None]: - """Acquire the lock for this fixture's exact test database.""" - with postgres_ddl_lock(postgres_database_url): - yield - - return lock + return partial(postgres_ddl_lock, postgres_database_url) @pytest.fixture @@ -130,17 +312,55 @@ def reset_test_database_state() -> TestDatabaseReset: return _reset_test_database_state +@pytest.fixture +def clean_postgres_database( + postgres_database_url: str, + request: pytest.FixtureRequest, +) -> Iterator[str]: + """Provide an isolated migrated database baseline per test.""" + owns_schema = request.node.get_closest_marker("postgres_schema_contract") is not None + get_settings.cache_clear() + asyncio.run(db_session.dispose_engine()) + if owns_schema: + _rebuild_test_database_schema(postgres_database_url) + else: + asyncio.run(_reset_test_database_state(postgres_database_url)) + try: + yield postgres_database_url + finally: + asyncio.run(db_session.dispose_engine()) + try: + if owns_schema: + _rebuild_test_database_schema(postgres_database_url) + else: + try: + asyncio.run(_verify_test_database_schema(postgres_database_url)) + except BaseException as verification_error: + try: + _rebuild_test_database_schema(postgres_database_url) + except BaseException as rebuild_error: + raise BaseExceptionGroup( + "schema verification and recovery both failed", + [verification_error, rebuild_error], + ) from verification_error + raise + finally: + get_settings.cache_clear() + + @pytest.fixture def postgres_database_url() -> str: value = os.environ.get("WORKSTREAM_TEST_DATABASE_URL") if not value: - pytest.fail("WORKSTREAM_TEST_DATABASE_URL is required for database-backed tests") + raise RuntimeError("WORKSTREAM_TEST_DATABASE_URL is required for database-backed tests") return value @pytest.fixture -def isolated_database_env(monkeypatch: pytest.MonkeyPatch, postgres_database_url: str) -> str: - monkeypatch.setenv("WORKSTREAM_DATABASE_URL", postgres_database_url) +def isolated_database_env( + monkeypatch: pytest.MonkeyPatch, clean_postgres_database: str +) -> Iterator[str]: + monkeypatch.setenv("WORKSTREAM_DATABASE_URL", clean_postgres_database) get_settings.cache_clear() - yield postgres_database_url + yield clean_postgres_database get_settings.cache_clear() diff --git a/backend/tests/test_actors.py b/backend/tests/test_actors.py index 714c8272..67a86308 100644 --- a/backend/tests/test_actors.py +++ b/backend/tests/test_actors.py @@ -1,3 +1,5 @@ +# pyright: reportArgumentType=false, reportAttributeAccessIssue=false +# pyright: reportOptionalMemberAccess=false, reportOptionalOperand=false from __future__ import annotations import asyncio @@ -5,10 +7,10 @@ from datetime import UTC, datetime import hashlib from pathlib import Path +import time from typing import cast from uuid import uuid4 -from alembic import command from alembic.config import Config from httpx import ASGITransport, AsyncClient from pydantic import ValidationError @@ -192,7 +194,10 @@ async def get_identity_link_for_actor(self, requested_id): assert profile_response is not None assert link_response is not None - assert repository.calls == [("profile", str(actor_id)), ("link", str(actor_id))] + assert [list(call) for call in repository.calls] == [ + ["profile", str(actor_id)], + ["link", str(actor_id)], + ] assert set(profile_response.model_dump()) == { "actor_profile_id", "actor_kind", @@ -202,10 +207,10 @@ async def get_identity_link_for_actor(self, requested_id): "display_name", "created_at", "updated_at", - "last_seen_at", - "suspended_at", - "reactivated_at", - "deactivated_at", + "last_seen_at", + "suspended_at", + "reactivated_at", + "deactivated_at", } assert set(link_response.model_dump()) == { "identity_link_id", @@ -245,41 +250,17 @@ def test_service_identity_lock_has_a_distinct_domain_without_changing_external_k @pytest.fixture def actor_database_env( monkeypatch: pytest.MonkeyPatch, - postgres_database_url: str, - migration_lock, - reset_test_database_state, + clean_postgres_database: str, ) -> Iterator[str]: """Run canonical actor tests against an isolated current schema.""" - monkeypatch.setenv("WORKSTREAM_DATABASE_URL", postgres_database_url) + monkeypatch.setenv("WORKSTREAM_DATABASE_URL", clean_postgres_database) monkeypatch.setenv("WORKSTREAM_API_RATE_LIMIT_KEY_SECRET", RATE_SECRET) set_dev_actor(monkeypatch, roles="worker", subject="actor-registry-contributor") get_settings.cache_clear() - asyncio.run(db_session.dispose_engine()) - config = alembic_config() try: - with migration_lock(): - command.downgrade(config, "base") - try: - command.upgrade(config, "head") - yield postgres_database_url - finally: - try: - asyncio.run( - reset_test_database_state( - postgres_database_url, - include_canonical_actors=True, - ) - ) - finally: - try: - asyncio.run(db_session.dispose_engine()) - finally: - command.downgrade(config, "base") + yield clean_postgres_database finally: - try: - asyncio.run(db_session.dispose_engine()) - finally: - get_settings.cache_clear() + get_settings.cache_clear() @pytest.fixture @@ -320,7 +301,7 @@ def auth_headers(token: str = "actor-token") -> dict[str, str]: def verified_token(subject: str, *, kind: str = "human") -> VerifiedIssuerToken: - now = int(datetime.now(UTC).timestamp()) + now = time.time_ns() // 1_000_000_000 return VerifiedIssuerToken( issuer=ISSUER, subject=subject, @@ -492,9 +473,9 @@ async def get_identity_link(self, issuer, subject, *, for_update=False): service._repo = repository # type: ignore[assignment] assert await service.require_active_human_write_actor(actor) is None - assert repository.calls == [ - ("profile", actor.actor_id, ""), - ("link", actor.external_issuer, actor.external_subject), + assert [list(call) for call in repository.calls] == [ + ["profile", actor.actor_id, ""], + ["link", actor.external_issuer, actor.external_subject], ] unavailable_cases = ( @@ -537,9 +518,7 @@ async def get_identity_link(self, issuer, subject, *, for_update=False): if actor_kind == "service" else "automatic_first_access" ), - service_identity=( - "workstream.artifact.verifier" if actor_kind == "service" else None - ), + service_identity=("workstream.artifact.verifier" if actor_kind == "service" else None), created_by=profile.id, ) repository.locked_link = ActorIdentityLink( @@ -561,7 +540,11 @@ async def get_identity_link(self, issuer, subject, *, for_update=False): match="active contributor identity required", ): await service.require_active_human_write_actor(actor) - expected_calls = ["profile"] if actor_kind == "service" or profile_status != "active" else ["profile", "link"] + expected_calls = ( + ["profile"] + if actor_kind == "service" or profile_status != "active" + else ["profile", "link"] + ) assert [call[0] for call in repository.calls] == expected_calls @@ -662,10 +645,10 @@ async def resolve(): assert await session.scalar(select(func.count()).select_from(ActorIdentityLink)) == 1 assert ( await session.scalar( - select(func.count()).select_from(AuditEvent).where( - AuditEvent.event_type.in_( - ["ActorProfileProvisioned", "ActorIdentityLinked"] - ) + select(func.count()) + .select_from(AuditEvent) + .where( + AuditEvent.event_type.in_(["ActorProfileProvisioned", "ActorIdentityLinked"]) ) ) == 2 @@ -1156,7 +1139,7 @@ def test_verified_identity_rejects_values_above_persisted_provenance_bound() -> with pytest.raises(ValidationError): verified_token("s" * 201) with pytest.raises(ValidationError): - now = int(datetime.now(UTC).timestamp()) + now = time.time_ns() // 1_000_000_000 VerifiedIssuerToken( issuer="i" * 201, subject="subject", @@ -1291,15 +1274,17 @@ async def test_repeated_legacy_activation_updates_one_row_and_audits_only_change async with db_session.get_session_factory()() as session: assert ( await session.scalar( - select(func.count()).select_from(LegacyWorkflowEligibility).where( - LegacyWorkflowEligibility.actor_id == actor.actor_id - ) + select(func.count()) + .select_from(LegacyWorkflowEligibility) + .where(LegacyWorkflowEligibility.actor_id == actor.actor_id) ) == 1 ) assert ( await session.scalar( - select(func.count()).select_from(AuditEvent).where( + select(func.count()) + .select_from(AuditEvent) + .where( AuditEvent.entity_type == "legacy_workflow_eligibility", AuditEvent.actor_id == actor.actor_id, ) @@ -1369,7 +1354,9 @@ async def observed_lock( assert eligibility.skill_tags == ["second"] assert ( await session.scalar( - select(func.count()).select_from(AuditEvent).where( + select(func.count()) + .select_from(AuditEvent) + .where( AuditEvent.entity_type == "legacy_workflow_eligibility", AuditEvent.actor_id == actor.actor_id, ) diff --git a/backend/tests/test_alembic.py b/backend/tests/test_alembic.py index 3086d109..94ed6925 100644 --- a/backend/tests/test_alembic.py +++ b/backend/tests/test_alembic.py @@ -51,6 +51,8 @@ snapshot_existing_service_rows, ) +pytestmark = pytest.mark.postgres_schema_contract + def _alembic_config() -> Config: project_root = Path(__file__).resolve().parents[1] @@ -1068,16 +1070,14 @@ def test_authorization_action_evidence_constraints_and_guarded_downgrade( "action_column": False, "action_constraint": False, } - assert ( - historical_before - == historical_upgraded - == historical_downgraded - == { - "event_type": "SensitiveAuthorizationAllowed", - "permission_id": "actor.profile.read_any", - "action_id": None, - } - ) + expected_historical = { + "event_type": "SensitiveAuthorizationAllowed", + "permission_id": "actor.profile.read_any", + "action_id": None, + } + assert historical_before == expected_historical + assert historical_upgraded == expected_historical + assert historical_downgraded == expected_historical assert lock_observed is True @@ -1239,9 +1239,10 @@ def test_fixed_service_identity_schema_mapping_and_guarded_downgrade( assert mapped["revision"] == "0023_service_actor_identity" assert mapped["service_identity"] == "workstream.artifact.verifier" assert mapped["mapped_count"] == 1 - assert len(mapped["source_digest"]) == 64 - assert len(mapped["manifest_digest"]) == 64 - assert len(mapped["envelope_digest"]) == 64 + for digest_key in ("source_digest", "manifest_digest", "envelope_digest"): + digest = mapped[digest_key] + assert isinstance(digest, str) + assert len(digest) == 64 assert mapped["private_evidence_columns"] is False assert asyncio.run(_service_identity_guards(isolated_database_env, service_id)) == { "identity_update_rejected": True, @@ -1789,13 +1790,15 @@ async def shape() -> tuple[str, bool, bool]: try: command.downgrade(config, "base") command.upgrade(config, "0025_artifact_store_v2") - assert asyncio.run(shape()) == ("0025_artifact_store_v2", False, False) + prior_shape = ("0025_artifact_store_v2", False, False) + lifecycle_shape = ("0026_actor_profile_lifecycle", True, True) + assert asyncio.run(shape()) == prior_shape command.upgrade(config, "0026_actor_profile_lifecycle") - assert asyncio.run(shape()) == ("0026_actor_profile_lifecycle", True, True) + assert asyncio.run(shape()) == lifecycle_shape command.downgrade(config, "0025_artifact_store_v2") - assert asyncio.run(shape()) == ("0025_artifact_store_v2", False, False) + assert asyncio.run(shape()) == prior_shape command.upgrade(config, "0026_actor_profile_lifecycle") - assert asyncio.run(shape()) == ("0026_actor_profile_lifecycle", True, True) + assert asyncio.run(shape()) == lifecycle_shape finally: command.downgrade(config, "base") @@ -2487,16 +2490,18 @@ def test_contributor_foundation_upgrade_guards_and_reversible_preservation( ) ) before = asyncio.run(_contributor_foundation_shape(isolated_database_env)) + worker_column = ("worker_id", 100) + contributor_column = ("contributor_id", 36) assert before["revision"] == "0026_actor_profile_lifecycle" - assert before["assignment_column"] == ("worker_id", 100) - assert before["submission_column"] == ("worker_id", 100) + assert before["assignment_column"] == worker_column + assert before["submission_column"] == worker_column command.upgrade(config, "0027_contributor_foundation") upgraded = asyncio.run(_contributor_foundation_shape(isolated_database_env)) - assert upgraded == { + expected_upgraded = { "revision": "0027_contributor_foundation", - "assignment_column": ("contributor_id", 36), - "submission_column": ("contributor_id", 36), + "assignment_column": contributor_column, + "submission_column": contributor_column, "assignment_index": "ix_task_assignments_contributor_id", "submission_index": "ix_submissions_contributor_id", "foreign_keys": ( @@ -2511,6 +2516,7 @@ def test_contributor_foundation_upgrade_guards_and_reversible_preservation( "assignment_values": (human_id,), "submission_values": (human_id,), } + assert upgraded == expected_upgraded direct_sql = asyncio.run( _exercise_contributor_lineage_guards( isolated_database_env, @@ -2550,12 +2556,15 @@ def test_contributor_foundation_upgrade_guards_and_reversible_preservation( command.downgrade(config, "0026_actor_profile_lifecycle") restored = asyncio.run(_contributor_foundation_shape(isolated_database_env)) assert restored["revision"] == "0026_actor_profile_lifecycle" - assert restored["assignment_column"] == ("worker_id", 100) - assert restored["submission_column"] == ("worker_id", 100) + assert restored["assignment_column"] == worker_column + assert restored["submission_column"] == worker_column assert restored["assignment_index"] == "ix_task_assignments_worker_id" assert restored["submission_index"] == "ix_submissions_worker_id" - assert human_id in restored["assignment_values"] - assert restored["submission_values"] == (human_id,) + assignment_values = restored["assignment_values"] + assert isinstance(assignment_values, tuple) + assert human_id in assignment_values + expected_submission_values = (human_id,) + assert restored["submission_values"] == expected_submission_values command.upgrade(config, "0027_contributor_foundation") assert asyncio.run(_current_revision(isolated_database_env)) == ( @@ -6264,11 +6273,13 @@ async def _assert_authorization_action_sql_pairs( async def _insert_authorization_action_event(database_url: str) -> str: """Commit one valid planned-action denial fixture.""" values = _action_evidence_values("artifact.binding.read", "artifact.binding.read") + event_id = values["id"] + assert event_id is not None engine = create_async_engine(database_url) try: async with engine.begin() as connection: await connection.execute(_ACTION_EVIDENCE_INSERT, values) - return values["id"] + return event_id finally: await engine.dispose() @@ -6430,6 +6441,8 @@ def _action_downgrade_waits_for_insert(config: Config, database_url: str) -> tup writer_ready = threading.Event() release_writer = threading.Event() values = _action_evidence_values("artifact.binding.read", "artifact.binding.read") + event_id = values["id"] + assert event_id is not None async def hold_uncommitted_insert() -> None: engine = create_async_engine(database_url) @@ -6468,7 +6481,7 @@ async def observe_downgrade_lock() -> bool: if not writer_ready.wait(timeout=5): release_writer.set() writer.result(timeout=5) - return False, values["id"] + return False, event_id downgrade = executor.submit(command.downgrade, config, "0020_canonical_actor_profile") try: observed = asyncio.run(observe_downgrade_lock()) @@ -6480,7 +6493,7 @@ async def observe_downgrade_lock() -> bool: match="^cannot downgrade non-empty authorization action evidence$", ): downgrade.result(timeout=10) - return observed, values["id"] + return observed, event_id async def _insert_orphan_admin_evidence(database_url: str, event_type: str) -> None: @@ -6953,15 +6966,6 @@ async def _clear_admin_authority_guard_fixtures(database_url: str) -> None: await engine.dispose() -async def _current_revision(database_url: str) -> str: - engine = create_async_engine(database_url) - try: - async with engine.connect() as connection: - return str(await connection.scalar(text("select version_num from alembic_version"))) - finally: - await engine.dispose() - - async def _insert_service_actor_before_fixed_identity( database_url: str, actor_profile_id: str, diff --git a/backend/tests/test_api_rate_controls.py b/backend/tests/test_api_rate_controls.py index 12f65689..3989f5ba 100644 --- a/backend/tests/test_api_rate_controls.py +++ b/backend/tests/test_api_rate_controls.py @@ -1,18 +1,18 @@ +# pyright: reportArgumentType=false, reportAttributeAccessIssue=false from __future__ import annotations import asyncio from datetime import UTC, datetime, timedelta -from pathlib import Path - -from alembic import command -from alembic.config import Config -from fastapi import Depends +from fastapi import Depends # type: ignore[import-not-found] from httpx import ASGITransport, AsyncClient from pydantic import SecretStr import pytest from sqlalchemy import text from sqlalchemy.exc import SQLAlchemyError -from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine +from sqlalchemy.ext.asyncio import ( # type: ignore[import-not-found] + async_sessionmaker, + create_async_engine, +) from app.api.deps.api_controls import ( enforce_admin_mutation_rate_limit, @@ -86,19 +86,13 @@ def test_rate_control_orm_model_matches_the_migration_contract() -> None: @pytest.fixture def rate_control_database_env( - postgres_database_url: str, - migration_lock, + clean_postgres_database: str, monkeypatch: pytest.MonkeyPatch, ): - """Bind settings and migrate the exact isolated database used by this suite.""" - monkeypatch.setenv("WORKSTREAM_DATABASE_URL", postgres_database_url) + """Bind settings to the clean migrated database used by this test.""" + monkeypatch.setenv("WORKSTREAM_DATABASE_URL", clean_postgres_database) get_settings.cache_clear() - project_root = Path(__file__).resolve().parents[1] - config = Config(str(project_root / "alembic.ini")) - config.set_main_option("script_location", str(project_root / "alembic")) - with migration_lock(): - command.upgrade(config, "head") - yield postgres_database_url + yield clean_postgres_database get_settings.cache_clear() @@ -118,13 +112,9 @@ async def rate_control_factory(rate_control_database_env: str): def test_rate_key_digest_matches_literal_vector_and_separates_boundaries() -> None: - expected = bytes.fromhex( - "35b4fb3e6a647a52596a5240b0b3ad5c2976d91771f95e2497be247081e53b31" - ) + expected = bytes.fromhex("35b4fb3e6a647a52596a5240b0b3ad5c2976d91771f95e2497be247081e53b31") - assert rate_key_digest( - RATE_SECRET, FIRST_ACCESS_SCOPE, RATE_ISSUER, RATE_SUBJECT - ) == expected + assert rate_key_digest(RATE_SECRET, FIRST_ACCESS_SCOPE, RATE_ISSUER, RATE_SUBJECT) == expected assert rate_key_digest(RATE_SECRET, FIRST_ACCESS_SCOPE, "ab", "c") != ( rate_key_digest(RATE_SECRET, FIRST_ACCESS_SCOPE, "a", "bc") ) @@ -153,9 +143,7 @@ def test_rate_key_digest_matches_literal_vector_and_separates_boundaries() -> No ("issuer", "\ud800"), ], ) -def test_rate_key_digest_rejects_unbounded_identity_without_echo( - issuer: str, subject: str -) -> None: +def test_rate_key_digest_rejects_unbounded_identity_without_echo(issuer: str, subject: str) -> None: with pytest.raises(RateControlUnavailableError) as caught: rate_key_digest(RATE_SECRET, FIRST_ACCESS_SCOPE, issuer, subject) assert str(caught.value) == "rate control unavailable" @@ -337,9 +325,7 @@ async def test_repository_persists_the_returned_database_timestamp( ) -> None: digest = bytes([17]) * 32 async with rate_control_factory() as session: - consumed = await ApiRateControlRepository(session).consume( - FIRST_ACCESS_SCOPE, digest, 60 - ) + consumed = await ApiRateControlRepository(session).consume(FIRST_ACCESS_SCOPE, digest, 60) updated_at = await session.scalar( text( "select updated_at from api_rate_control_counters " @@ -535,9 +521,7 @@ async def synchronized_prune(repository, *args, **kwargs): class _FakeSession: """Minimal async-session context for deterministic failure tests.""" - def __init__( - self, *, commit_error: bool = False, enter_error: bool = False - ) -> None: + def __init__(self, *, commit_error: bool = False, enter_error: bool = False) -> None: self.commit_error = commit_error self.enter_error = enter_error self.rolled_back = False @@ -756,9 +740,7 @@ async def test_unattached_dependencies_emit_canonical_429_and_use_token_identity RateControlDecision(True, 1, 60), ] ) - app = create_app( - Settings(environment="test", api_rate_limit_key_secret=RATE_SECRET_TEXT) - ) + app = create_app(Settings(environment="test", api_rate_limit_key_secret=RATE_SECRET_TEXT)) app.dependency_overrides[get_auth_verification_result] = _verified_rate_identity app.dependency_overrides[get_rate_control_service] = lambda: service diff --git a/backend/tests/test_artifact_admission.py b/backend/tests/test_artifact_admission.py index 8a0d0aa5..df781d35 100644 --- a/backend/tests/test_artifact_admission.py +++ b/backend/tests/test_artifact_admission.py @@ -1,8 +1,10 @@ """PostgreSQL proofs for atomic durable-byte admission before provider I/O.""" +# pyright: reportArgumentType=false, reportAttributeAccessIssue=false from __future__ import annotations import asyncio +from collections.abc import Iterator from dataclasses import replace from datetime import UTC, datetime, timedelta from pathlib import Path @@ -15,7 +17,10 @@ import pytest from sqlalchemy import func, select, text from sqlalchemy.exc import DBAPIError -from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine +from sqlalchemy.ext.asyncio import ( # type: ignore[import-not-found] + async_sessionmaker, + create_async_engine, +) from app.adapters.artifacts.local import LocalStorageAdapter, LocalStorageBootstrap from app.core.config import Settings @@ -145,27 +150,18 @@ def _alembic_config() -> Config: @pytest.fixture -def admission_database_env( - isolated_database_env: str, - migration_lock, -) -> str: - """Provide the exact head schema and remove all test evidence afterward.""" - config = _alembic_config() - with migration_lock(): - asyncio.run(_reset_admission_test_schema(isolated_database_env)) - command.upgrade(config, "head") - try: - yield isolated_database_env - finally: - asyncio.run(_reset_admission_test_schema(isolated_database_env)) +def admission_database_env(isolated_database_env: str) -> Iterator[str]: + """Provide the clean migrated database for artifact admission tests.""" + yield isolated_database_env async def _reset_admission_test_schema(database_url: str) -> None: + """Reset the schema only for tests that explicitly exercise migrations.""" engine = create_async_engine(database_url) try: - async with engine.begin() as connection: - await connection.execute(text("drop schema if exists public cascade")) - await connection.execute(text("create schema public")) + async with engine.begin() as conn: + await conn.execute(text("drop schema if exists public cascade"), {}) + await conn.execute(text("create schema public"), {}) finally: await engine.dispose() @@ -755,11 +751,11 @@ async def test_committed_put_and_independent_verification_are_fenced( assert job.execution_generation == 1 replica = await session.get(ArtifactReplica, job.replica_id) assert replica is not None - assert ( + assert [ replica.verification_state, replica.availability_state, replica.integrity_state, - ) == ("verified", "available", "valid") + ] == ["verified", "available", "valid"] assert await _count(session, ArtifactOperationReceipt) == 1 assert await _count(session, ArtifactVerificationReceipt) == 1 await session.rollback() @@ -948,9 +944,7 @@ async def preflight(self, **_values: object) -> None: session, provider, namespace, settings, DriftPutFactsAuthority() ) result = ( - await orchestrator.execute_committed_put( - attempt_id=attempt_id, source=source - ) + await orchestrator.execute_committed_put(attempt_id=attempt_id, source=source) if mode == "caller_put" else await orchestrator.resolve_put_attempt(attempt_id) ) @@ -979,7 +973,9 @@ async def test_put_paths_recheck_authorized_facts_after_provider_io( factory = async_sessionmaker(engine, expire_on_commit=False) try: async with factory() as session: - async with minted_source(tmp_path / f"post-io-put-drift-{mode}", b"authorized") as source: + async with minted_source( + tmp_path / f"post-io-put-drift-{mode}", b"authorized" + ) as source: admission = await _admit_guide_source( session, settings, namespace, _context(), source ) @@ -1015,9 +1011,7 @@ async def observe_put_result(_commitment: object) -> ArtifactPutObservation: session, provider, namespace, settings, _AllowArtifactAuthority() ) result = ( - await orchestrator.execute_committed_put( - attempt_id=attempt_id, source=source - ) + await orchestrator.execute_committed_put(attempt_id=attempt_id, source=source) if mode == "caller_put" else await orchestrator.resolve_put_attempt(attempt_id) ) @@ -1102,11 +1096,11 @@ async def test_preacknowledgement_mismatch_fails_contributor_item_and_keeps_char assert attempt.replica_id is not None replica = await session.get(ArtifactReplica, attempt.replica_id) assert replica is not None - assert ( + assert [ replica.verification_state, replica.availability_state, replica.integrity_state, - ) == ("integrity_mismatch", "available", "invalid") + ] == ["integrity_mismatch", "available", "invalid"] charges = (await session.execute(select(ArtifactAdmissionCharge))).scalars().all() assert charges and {charge.state for charge in charges} == {"completed"} item = await session.get(ArtifactUploadItem, item_ids[0]) @@ -1326,8 +1320,7 @@ async def test_expired_lease_takeover_rejects_stale_terminal_completion( stale_session, object(), namespace, settings, _AllowArtifactAuthority() ) assert ( - await stale._record_put_absence(first_claim, first_executor, first_facts) - == "stale" + await stale._record_put_absence(first_claim, first_executor, first_facts) == "stale" ) attempt = await stale_session.get(ArtifactPutAttempt, str(admission.attempt_id)) assert attempt is not None @@ -2594,6 +2587,7 @@ async def test_verification_unavailable_retries_then_exhausts( await engine.dispose() +@pytest.mark.postgres_schema_contract async def test_0030_populated_contributor_receipt_upgrade_and_guarded_downgrade( admission_database_env: str, tmp_path: Path, @@ -2687,10 +2681,10 @@ async def test_0030_populated_contributor_receipt_upgrade_and_guarded_downgrade( await asyncio.to_thread(command.upgrade, config, "0030_artifact_verification") engine = create_async_engine(admission_database_env) try: - async with engine.begin() as connection: + async with engine.begin() as session: migrated = ( ( - await connection.execute( + await session.execute( text( "select contract_version, put_attempt_id, upload_item_id " "from artifact_operation_receipts where id = :id" @@ -2706,7 +2700,7 @@ async def test_0030_populated_contributor_receipt_upgrade_and_guarded_downgrade( "put_attempt_id": str(admission.attempt_id), "upload_item_id": item_ids[0], } - await connection.execute( + await session.execute( text( "insert into artifact_put_observation_receipts " "(id, put_attempt_id, execution_generation, outcome, expected_sha256, " @@ -2986,13 +2980,14 @@ async def test_guide_admission_facts_lock_snapshot_and_item( ), ) for statement, parameters in mutations: - async with factory() as mutation_session: + async with factory() as session: with pytest.raises(DBAPIError, match="lock timeout"): - async with mutation_session.begin(): - await mutation_session.execute( - text("set local lock_timeout = '200ms'") + async with session.begin(): + await session.execute( + text("set local lock_timeout = '200ms'"), + {}, ) - await mutation_session.execute( + await session.execute( text(statement), parameters, ) @@ -3892,6 +3887,7 @@ async def test_invalid_checker_role_precedes_namespace_drift( await engine.dispose() +@pytest.mark.postgres_schema_contract def test_artifact_admission_migration_preserves_prior_rows_and_round_trips_empty( isolated_database_env: str, migration_lock, @@ -3943,16 +3939,18 @@ async def cleanup() -> None: command.upgrade(config, "0026_actor_profile_lifecycle") asyncio.run(seed_prior_namespace()) command.upgrade(config, "0028_artifact_admission") - assert asyncio.run(state()) == (1, True) + assert list(asyncio.run(state())) == [1, True] command.downgrade(config, "0026_actor_profile_lifecycle") - assert asyncio.run(state()) == (1, False) + assert list(asyncio.run(state())) == [1, False] command.upgrade(config, "0028_artifact_admission") - assert asyncio.run(state()) == (1, True) + assert list(asyncio.run(state())) == [1, True] asyncio.run(cleanup()) finally: asyncio.run(_reset_admission_test_schema(isolated_database_env)) + command.upgrade(config, "head") +@pytest.mark.postgres_schema_contract def test_artifact_admission_migration_refuses_populated_downgrade( isolated_database_env: str, migration_lock, @@ -4038,3 +4036,4 @@ async def cleanup() -> None: command.downgrade(config, "0026_actor_profile_lifecycle") finally: asyncio.run(_reset_admission_test_schema(isolated_database_env)) + command.upgrade(config, "head") diff --git a/backend/tests/test_artifact_recovery.py b/backend/tests/test_artifact_recovery.py index 3912d2a7..dff84e1e 100644 --- a/backend/tests/test_artifact_recovery.py +++ b/backend/tests/test_artifact_recovery.py @@ -8,11 +8,12 @@ from unittest.mock import AsyncMock from uuid import UUID, uuid4 -from alembic import command -from alembic.config import Config -import pytest -from sqlalchemy import func, select, text -from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine +import pytest # type: ignore[import-not-found] +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import ( # type: ignore[import-not-found] + async_sessionmaker, + create_async_engine, +) from app.adapters.artifacts.local import LocalStorageAdapter, LocalStorageBootstrap from app.core.config import Settings @@ -84,33 +85,10 @@ async def authorize(self, **_values: object) -> ArtifactRecoveryAuthorizationEvi ) -def _alembic_config() -> Config: - root = Path(__file__).resolve().parents[1] - config = Config(str(root / "alembic.ini")) - config.set_main_option("script_location", str(root / "alembic")) - return config - - @pytest.fixture -def recovery_database_env(isolated_database_env: str, migration_lock) -> str: - config = _alembic_config() - with migration_lock(): - asyncio.run(_reset_schema(isolated_database_env)) - command.upgrade(config, "head") - try: - yield isolated_database_env - finally: - asyncio.run(_reset_schema(isolated_database_env)) - - -async def _reset_schema(database_url: str) -> None: - engine = create_async_engine(database_url) - try: - async with engine.begin() as connection: - await connection.execute(text("drop schema if exists public cascade")) - await connection.execute(text("create schema public")) - finally: - await engine.dispose() +def recovery_database_env(isolated_database_env: str) -> str: + """Use the runner-migrated schema and central transactional reset.""" + return isolated_database_env def _settings(tmp_path: Path) -> Settings: @@ -359,19 +337,25 @@ async def _exhausted_guide_job(session, settings, tmp_path, context): return project_id, job, bootstrap -def _request(context, project_id: str, task_id: str | None, job, **changes): - values = dict( +def _request( + context: HumanAuthorizationContext, + project_id: str, + task_id: str | None, + job: ArtifactVerificationJob, + *, + reason: str = "provider remained unavailable", + client_idempotency_key: str = "recovery-1", +) -> ArtifactRecoveryRequest: + return ArtifactRecoveryRequest( authorization_context=context, project_id=UUID(project_id), task_id=UUID(task_id) if task_id is not None else None, submission_id=None, source_verification_job_id=UUID(job.id), - reason="provider remained unavailable", - client_idempotency_key="recovery-1", + reason=reason, + client_idempotency_key=client_idempotency_key, expected_source_job_cas_version=job.cas_version, ) - values.update(changes) - return ArtifactRecoveryRequest(**values) @pytest.mark.asyncio @@ -539,10 +523,8 @@ async def test_retry_terminalizes_recovery_under_verification_fence( ArtifactRecoveryAttempt, str(created.recovery_attempt_id) ) assert recovery is not None - assert (recovery.status, recovery.terminal_result_code) == ( - "succeeded", - "verified", - ) + assert recovery.status == "succeeded" + assert recovery.terminal_result_code == "verified" assert recovery.terminal_audit_event_id is not None assert await session.scalar( select(func.count(AuditEvent.id)).where( @@ -636,10 +618,8 @@ async def test_every_failed_retry_outcome_terminalizes_recovery_once( ArtifactRecoveryAttempt, str(created.recovery_attempt_id) ) assert recovery is not None - assert (recovery.status, recovery.terminal_result_code) == ( - "failed", - expected_outcome, - ) + assert recovery.status == "failed" + assert recovery.terminal_result_code == expected_outcome assert await session.scalar( select(func.count(AuditEvent.id)).where( AuditEvent.event_type == "ArtifactRecoveryCompleted" @@ -714,10 +694,8 @@ async def test_exhausted_retry_can_form_only_the_next_linear_chain_link( ArtifactRecoveryAttempt, str(first.recovery_attempt_id) ) assert retry is not None and first_attempt is not None - assert (first_attempt.status, first_attempt.terminal_result_code) == ( - "failed", - "provider_unavailable", - ) + assert first_attempt.status == "failed" + assert first_attempt.terminal_result_code == "provider_unavailable" second_request = _request( context, project_id, diff --git a/backend/tests/test_audit.py b/backend/tests/test_audit.py index ee98e3d8..c572a267 100644 --- a/backend/tests/test_audit.py +++ b/backend/tests/test_audit.py @@ -1,20 +1,22 @@ +# pyright: reportOptionalSubscript=false from __future__ import annotations from collections import UserDict from collections.abc import Mapping import json -from pathlib import Path from types import MappingProxyType +from typing import cast from uuid import UUID, uuid4 import warnings -from alembic import command -from alembic.config import Config from pydantic import ValidationError import pytest from sqlalchemy import delete, text from sqlalchemy.exc import DBAPIError, IntegrityError -from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine +from sqlalchemy.ext.asyncio import ( # type: ignore[import-not-found] + async_sessionmaker, + create_async_engine, +) from app.modules.audit.repository import AuditRepository from app.modules.audit.schemas import ( @@ -34,14 +36,9 @@ @pytest.fixture -def audit_database_env(postgres_database_url: str, migration_lock) -> str: - """Ensure audit tests do not inherit another migration test's schema state.""" - project_root = Path(__file__).resolve().parents[1] - config = Config(str(project_root / "alembic.ini")) - config.set_main_option("script_location", str(project_root / "alembic")) - with migration_lock(): - command.upgrade(config, "head") - return postgres_database_url +def audit_database_env(clean_postgres_database: str) -> str: + """Use the clean migrated database for audit tests.""" + return clean_postgres_database @pytest.fixture @@ -199,8 +196,8 @@ def test_action_aware_audit_input_enforces_mapping_and_action_availability() -> actor_ref="workstream:system:bootstrap", request_id=uuid4(), correlation_id=uuid4(), - permission_id="actor.profile.read_self", - action_id="actor.profile.read_self", + permission_id=PermissionId.ACTOR_PROFILE_READ_SELF, + action_id=ActionId.ACTOR_PROFILE_READ_SELF, reason="authorization_policy_denial", denial_code="permission_not_granted", ) @@ -530,9 +527,7 @@ def __len__(self): safe = _authority_input(AuthorityEventType.SENSITIVE_AUTHORIZATION_ALLOWED).model_dump( mode="json" ) - safe_python = _authority_input( - AuthorityEventType.SENSITIVE_AUTHORIZATION_ALLOWED - ).model_dump() + safe_python = _authority_input(AuthorityEventType.SENSITIVE_AUTHORIZATION_ALLOWED).model_dump() class ChangingMapping(Mapping): def __init__(self): @@ -543,11 +538,7 @@ def __getitem__(self, key): def __iter__(self): self.iterations += 1 - keys = ( - safe_python - if self.iterations == 1 - else safe_python | {"raw_token": secret} - ) + keys = safe_python if self.iterations == 1 else safe_python | {"raw_token": secret} return iter(keys) def __len__(self): @@ -860,10 +851,10 @@ def __repr__(self): ] values[0].actor_ref = secret values[1].after_facts["allowed"] = secret - values[2].after_facts = HostileFacts() + values[2].after_facts = cast(dict[str, str | bool], HostileFacts()) valid_hostile_repr = _authority_input(AuthorityEventType.SENSITIVE_AUTHORIZATION_ALLOWED) changing_facts = ChangingFacts() - valid_hostile_repr.after_facts = changing_facts + valid_hostile_repr.after_facts = cast(dict[str, str | bool], changing_facts) async with audit_factory() as session: for value in values: @@ -1088,10 +1079,13 @@ async def test_database_rejects_malformed_and_mutated_audit_rows(audit_factory) {"authority_id": str(value.event_id), "legacy_id": legacy_id}, ) ).all() - assert {(row.id, row.reason, row.event_domain) for row in rows} == { - (str(value.event_id), "authorization_evaluation", "authority"), - (legacy_id, None, "legacy_lifecycle"), - } + observed_rows = {row.id: [row.reason, row.event_domain] for row in rows} + assert observed_rows[str(value.event_id)] == [ + "authorization_evaluation", + "authority", + ] + assert observed_rows[legacy_id] == [None, "legacy_lifecycle"] + assert len(observed_rows) == 2 with pytest.raises(IntegrityError): await session.execute( diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index aa6f99e0..bbc5860c 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -7,22 +7,29 @@ import hmac import json import logging -from collections.abc import Iterator +from collections.abc import AsyncIterator, Iterator from datetime import UTC, datetime, timedelta from pathlib import Path from typing import Any from uuid import UUID, uuid4 import jwt -import httpx -import pytest -from alembic import command -from alembic.config import Config +import httpx # type: ignore[import-not-found] +import pytest # type: ignore[import-not-found] from cryptography.hazmat.primitives.asymmetric import rsa -from httpx import ASGITransport, AsyncClient, MockTransport, Request, Response +from httpx import ( # type: ignore[import-not-found] + ASGITransport, + AsyncClient, + MockTransport, + Request, + Response, +) from sqlalchemy import func, select, text from sqlalchemy.exc import SQLAlchemyError -from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine +from sqlalchemy.ext.asyncio import ( # type: ignore[import-not-found] + AsyncSession, + create_async_engine, +) from fastapi import HTTPException from app.adapters.auth.dev import DevelopmentAuthVerifier @@ -151,8 +158,16 @@ def test_legacy_submitter_eligibility_adapter_has_a_shrinking_static_allowlist() ] +def current_task_name() -> str: + """Return the current asyncio task name with an explicit test invariant.""" + task = asyncio.current_task() + if task is None: + raise AssertionError("an asyncio task is required") + return task.get_name() + + @pytest.fixture(autouse=True) -def clear_settings_cache() -> None: +def clear_settings_cache() -> Iterator[None]: get_settings.cache_clear() clear_auth_verifier_cache() yield @@ -163,46 +178,19 @@ def clear_settings_cache() -> None: @pytest.fixture def auth_database_env( monkeypatch: pytest.MonkeyPatch, - postgres_database_url: str, - migration_lock, - reset_test_database_state, + clean_postgres_database: str, ) -> Iterator[str]: - """Run auth route persistence tests against a migrated Postgres schema.""" - monkeypatch.setenv("WORKSTREAM_DATABASE_URL", postgres_database_url) + """Run auth route persistence tests against a clean migrated schema.""" + monkeypatch.setenv("WORKSTREAM_DATABASE_URL", clean_postgres_database) monkeypatch.setenv( "WORKSTREAM_API_RATE_LIMIT_KEY_SECRET", "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=", ) get_settings.cache_clear() - asyncio.run(db_session.dispose_engine()) - - project_root = Path(__file__).resolve().parents[1] - config = Config(str(project_root / "alembic.ini")) - config.set_main_option("script_location", str(project_root / "alembic")) try: - with migration_lock(): - command.downgrade(config, "base") - try: - command.upgrade(config, "head") - yield postgres_database_url - finally: - try: - asyncio.run( - reset_test_database_state( - postgres_database_url, - include_canonical_actors=True, - ) - ) - finally: - try: - asyncio.run(db_session.dispose_engine()) - finally: - command.downgrade(config, "base") + yield clean_postgres_database finally: - try: - asyncio.run(db_session.dispose_engine()) - finally: - get_settings.cache_clear() + get_settings.cache_clear() def _base64url_int(value: int) -> str: @@ -968,7 +956,7 @@ async def test_service_and_agent_tokens_receive_no_legacy_authority( app = create_app(production_verifier_settings()) app.state.auth_verifier = verifier - async def no_database_session(): + async def no_database_session() -> AsyncIterator[None]: yield None app.dependency_overrides[get_db_session] = no_database_session @@ -2254,9 +2242,7 @@ async def assert_failed_admin_read(path: str) -> None: } == {("Actor not found", "actor_not_found", "Actor not found", False)} async with db_session.get_session_factory()() as session: await session.execute(text("alter table actor_profiles disable trigger user")) - await session.execute( - text("alter table actor_identity_links disable trigger user") - ) + await session.execute(text("alter table actor_identity_links disable trigger user")) await session.execute( text( "update actor_profiles set status='active',suspended_by=null," @@ -2410,9 +2396,7 @@ async def assert_failed_admin_read(path: str) -> None: assert audit_mutation.json()["error"]["code"] == "permission_not_granted" assert audit_history_visible.status_code == 200 assert audit_history_visible.json()["total"] == 1 - assert audit_history_visible.json()["items"][0]["target_actor_profile_id"] == str( - target_id - ) + assert audit_history_visible.json()["items"][0]["target_actor_profile_id"] == str(target_id) system_audit_grant = await client.post( "/api/v1/admin-role-grants", @@ -2874,7 +2858,7 @@ async def read_winner_timestamps() -> tuple[datetime, datetime]: newer_committed_timestamps: tuple[datetime, datetime] | None = None async def barrier_touch_verified_actor(self, profile, link): - if asyncio.current_task().get_name() == "auth08-older-timestamp-read": + if current_task_name() == "auth08-older-timestamp-read": older_touch_ready.set() await newer_committed.wait() return await original_touch_verified_actor(self, profile, link) @@ -2882,7 +2866,7 @@ async def barrier_touch_verified_actor(self, profile, link): async def ordered_commit(session: AsyncSession) -> None: nonlocal newer_committed_timestamps await original_commit(session) - if asyncio.current_task().get_name() == "auth08-newer-timestamp-read": + if current_task_name() == "auth08-newer-timestamp-read": newer_committed_timestamps = await read_winner_timestamps() newer_committed.set() @@ -3099,7 +3083,7 @@ async def run_ordered_revoke_vs_issue( first_locked = asyncio.Event() async def ordered_lock_control(self): - if asyncio.current_task().get_name() == first: + if current_task_name() == first: control = await original_lock_control(self) first_locked.set() return control @@ -3246,10 +3230,7 @@ async def ordered_lock_control(self): ) | ( (AuditEvent.event_type == "AuthorityInvalidationRequested") - & ( - AuditEvent.invalidation_target_ref - == str(profiles["pm-target-two"]) - ) + & (AuditEvent.invalidation_target_ref == str(profiles["pm-target-two"])) ) ) ) @@ -3372,7 +3353,9 @@ async def set_profile_state(actor_id: UUID, custodian_id: UUID, state: str) -> N "deactivated_at=clock_timestamp(),deactivation_reason='race proof' " "where id=:actor" ) - await session.execute(text(statement), {"actor": str(actor_id), "by": str(custodian_id)}) + await session.execute( + text(statement), {"actor": str(actor_id), "by": str(custodian_id)} + ) await session.commit() async def reset_profile(actor_id: UUID) -> None: @@ -3405,9 +3388,7 @@ async def revoke_link(actor_id: UUID, custodian_id: UUID) -> None: async def reset_link(actor_id: UUID) -> None: async with db_session.get_session_factory()() as session: - await session.execute( - text("alter table actor_identity_links disable trigger user") - ) + await session.execute(text("alter table actor_identity_links disable trigger user")) await session.execute( text( "update actor_identity_links set status='active',revoked_by=null," @@ -3417,9 +3398,7 @@ async def reset_link(actor_id: UUID) -> None: ), {"actor": str(actor_id)}, ) - await session.execute( - text("alter table actor_identity_links enable trigger user") - ) + await session.execute(text("alter table actor_identity_links enable trigger user")) await session.commit() async def wait_for_transition_lock( @@ -3431,9 +3410,7 @@ async def wait_for_transition_lock( raise AssertionError("disabling transition bypassed the read lock") async with db_session.get_session_factory()() as session: waiting = await session.scalar( - text( - "select coalesce(cardinality(pg_blocking_pids(:pid)),0)>0" - ), + text("select coalesce(cardinality(pg_blocking_pids(:pid)),0)>0"), {"pid": transition_backend_pid}, ) if waiting: @@ -3713,10 +3690,7 @@ async def capture_response(response: Response) -> None: json=payload, ) assert unprovisioned_service.status_code == 403 - assert ( - unprovisioned_service.json()["error"]["code"] - == "service_actor_not_provisioned" - ) + assert unprovisioned_service.json()["error"]["code"] == "service_actor_not_provisioned" assert await service_state(ServiceIdentity.ARTIFACT_VERIFIER) is None key = str(uuid4()) created = await client.post( @@ -3832,8 +3806,7 @@ async def unavailable_replay(self, **kwargs): subject_conflict = await client.post( "/api/v1/service-actors", headers={**admin_headers, "Idempotency-Key": str(uuid4())}, - json=payload - | {"service_identity": ServiceIdentity.ARTIFACT_PUT_RESOLVER.value}, + json=payload | {"service_identity": ServiceIdentity.ARTIFACT_PUT_RESOLVER.value}, ) assert subject_conflict.status_code == 409 assert subject_conflict.json()["error"]["code"] == "identity_subject_already_linked" @@ -3850,8 +3823,7 @@ async def unavailable_replay(self, **kwargs): unsupported = await client.post( "/api/v1/service-actors", headers={**headers, "Idempotency-Key": str(uuid4())}, - json=payload - | {"service_identity": ServiceIdentity.ARTIFACT_PUT_RESOLVER.value}, + json=payload | {"service_identity": ServiceIdentity.ARTIFACT_PUT_RESOLVER.value}, ) assert unsupported.status_code == 403 assert unsupported.json()["error"]["code"] == "unsupported_subject_kind", kind @@ -4073,38 +4045,41 @@ def canonical_issuer(self) -> str: assert unavailable.json()["error"]["code"] == "identity_verification_unavailable" sensitive_values = { - admin_token, - ordinary_token, - service_token, - *( - headers["Authorization"].removeprefix("Bearer ") - for headers in nonhuman_headers.values() - ), - settings.token_issuer, - "private-admin@example.test", - "auth09b-admin", - "auth09b-admin-token", - "auth09b-ordinary", - "auth09b-ordinary-token", - service_subject, - "auth09b-service-token", - *(f"auth09b-{kind}" for kind in nonhuman_headers), - *(f"auth09b-{kind}-token" for kind in nonhuman_headers), - reason, - "A different bounded reason", - "another-subject", - rejected_subject, - rejected_reason, - invalid_identity, - invalid_key, - scheduler_payload["subject"], - materializer_payload["subject"], - "auth09b-materializer-b", - output_payload["subject"], - "auth09b-output-b", - shared_subject, - failure_payload["subject"], - commit_payload["subject"], + str(value) + for value in { + admin_token, + ordinary_token, + service_token, + *( + headers["Authorization"].removeprefix("Bearer ") + for headers in nonhuman_headers.values() + ), + settings.token_issuer, + "private-admin@example.test", + "auth09b-admin", + "auth09b-admin-token", + "auth09b-ordinary", + "auth09b-ordinary-token", + service_subject, + "auth09b-service-token", + *(f"auth09b-{kind}" for kind in nonhuman_headers), + *(f"auth09b-{kind}-token" for kind in nonhuman_headers), + reason, + "A different bounded reason", + "another-subject", + rejected_subject, + rejected_reason, + invalid_identity, + invalid_key, + scheduler_payload["subject"], + materializer_payload["subject"], + "auth09b-materializer-b", + output_payload["subject"], + "auth09b-output-b", + shared_subject, + failure_payload["subject"], + commit_payload["subject"], + } } assert all(value not in body for value in sensitive_values for body in observed_response_bodies) assert all(value not in caplog.text for value in sensitive_values) @@ -4631,6 +4606,8 @@ async def test_actor_id_uses_subject_and_issuer_not_email() -> None: first_verifier = DevelopmentAuthVerifier(first) second_verifier = DevelopmentAuthVerifier(second) + assert first.dev_auth_token is not None + assert second.dev_auth_token is not None first_result = await first_verifier.verify(first.dev_auth_token) second_result = await second_verifier.verify(second.dev_auth_token) first_actor = first_result.legacy_actor() @@ -4684,7 +4661,7 @@ def test_dev_auth_requires_explicit_identity_fields( field_name: str, error_message: str, ) -> None: - values = { + values: dict[str, str | None] = { "environment": "local", "auth_provider": "dev", "dev_auth_token": "local-token", @@ -4857,9 +4834,7 @@ async def test_actor_profile_lifecycle_real_postgres_matrix( ) for name in ("admin", "target", "ordinary", "replay_target", "failure_target") } - headers = { - name: {"Authorization": f"Bearer {token}"} for name, token in tokens.items() - } + headers = {name: {"Authorization": f"Bearer {token}"} for name, token in tokens.items()} async def profile_state(actor_id: UUID) -> tuple: async with db_session.get_session_factory()() as session: @@ -5260,10 +5235,10 @@ async def fail_lifecycle_success(service, event): async with db_session.get_session_factory()() as session: lifecycle_records = ( await session.execute( - text( - "select operation,status,count(*) from authority_idempotency_records " - "where response_resource_id=:target " - "group by operation,status order by operation,status" + text( + "select operation,status,count(*) from authority_idempotency_records " + "where response_resource_id=:target " + "group by operation,status order by operation,status" ), {"target": str(profiles["target"])}, ) @@ -5346,9 +5321,7 @@ async def test_actor_identity_link_lifecycle_real_postgres_matrix( ) for name in names } - headers = { - name: {"Authorization": f"Bearer {token}"} for name, token in tokens.items() - } + headers = {name: {"Authorization": f"Bearer {token}"} for name, token in tokens.items()} async def actor_link_state(actor_id: UUID) -> tuple: async with db_session.get_session_factory()() as session: @@ -5403,17 +5376,12 @@ async def atomic_state(target: UUID) -> tuple: await actor_link_state(profiles["admin"]), tuple( await session.scalars( - text( - "select to_jsonb(g)::text from admin_role_grants g " - "order by g.id" - ) + text("select to_jsonb(g)::text from admin_role_grants g order by g.id") ) ), tuple( await session.scalars( - text( - "select to_jsonb(e)::text from audit_events e order by e.id" - ) + text("select to_jsonb(e)::text from audit_events e order by e.id") ) ), tuple( @@ -5450,9 +5418,7 @@ async def link_authorization_events(resource_id: UUID) -> tuple[tuple, ...]: failure_link = links["failure"] original_reserve = AuthorityIdempotencyRepository.reserve original_add_event = AuditService.add_authority_event - original_target_lookup = ( - AdminAuthorizationRepository.lock_identity_link_lifecycle_target - ) + original_target_lookup = AdminAuthorizationRepository.lock_identity_link_lifecycle_target original_flush = AsyncSession.flush original_touch = ActorService.touch_after_authorization original_complete = AuthorityIdempotencyRepository.complete @@ -5508,7 +5474,12 @@ async def fail_commit(*_args, **_kwargs): stages = ( (AuthorityIdempotencyRepository, "reserve", original_reserve, fail_reservation), - (AuditService, "add_authority_event", original_add_event, fail_authorization_evidence), + ( + AuditService, + "add_authority_event", + original_add_event, + fail_authorization_evidence, + ), ( AdminAuthorizationRepository, "lock_identity_link_lifecycle_target", @@ -5825,14 +5796,18 @@ async def fail_commit(*_args, **_kwargs): or 0 ) == 0 link_events = ( - await session.execute( - select(AuditEvent).where( - AuditEvent.event_type.in_( - ["ActorIdentityLinkRevoked", "ActorIdentityLinkReactivated"] + ( + await session.execute( + select(AuditEvent).where( + AuditEvent.event_type.in_( + ["ActorIdentityLinkRevoked", "ActorIdentityLinkReactivated"] + ) ) ) ) - ).scalars().all() + .scalars() + .all() + ) assert link_events assert all(event.target_actor_ref for event in link_events) evidence = json.dumps( @@ -6034,7 +6009,7 @@ async def ordered_requests( waiter_name = f"auth09d-{uuid4().hex}" async def ordered_lock_control(self): - if asyncio.current_task().get_name() == first_name: + if current_task_name() == first_name: control = await original_lock_control(self) first_locked.set() await asyncio.wait_for( @@ -6157,9 +6132,7 @@ def lifecycle_request( ) assert [result.status_code for _, result in reactivate_first] == [200, 200] - suspended_deactivate_first_id, _ = await create_race_actor( - "suspended-deactivate-first" - ) + suspended_deactivate_first_id, _ = await create_race_actor("suspended-deactivate-first") prepared_deactivate_first = await client.post( f"/api/v1/actors/{suspended_deactivate_first_id}/suspend", headers={**admin_headers, "Idempotency-Key": str(uuid4())}, @@ -6183,8 +6156,7 @@ def lifecycle_request( ) assert [result.status_code for _, result in suspended_deactivate_first] == [200, 409] assert ( - suspended_deactivate_first[1][1].json()["error"]["code"] - == "actor_deactivated_terminal" + suspended_deactivate_first[1][1].json()["error"]["code"] == "actor_deactivated_terminal" ) reusable_keys.append(suspended_deactivate_first[1][0]) @@ -6233,12 +6205,8 @@ def lifecycle_request( assert profile_grant_race[1][1].json()["error"]["code"] == "actor_suspended" reusable_keys.append(grant_race_key) - reciprocal_one_id, reciprocal_one_headers = await create_race_actor( - "reciprocal-one" - ) - reciprocal_two_id, reciprocal_two_headers = await create_race_actor( - "reciprocal-two" - ) + reciprocal_one_id, reciprocal_one_headers = await create_race_actor("reciprocal-one") + reciprocal_two_id, reciprocal_two_headers = await create_race_actor("reciprocal-two") await grant_access_administrator(reciprocal_one_id, "Reciprocal administrator one") await grant_access_administrator(reciprocal_two_id, "Reciprocal administrator two") disable_bootstrap = await client.post( @@ -6335,9 +6303,9 @@ def lifecycle_request( str(reciprocal_one_id): "active", str(reciprocal_two_id): "suspended", } - assert await AdminAuthorizationRepository( - session - ).count_effective_access_administrators() == 1 + assert ( + await AdminAuthorizationRepository(session).count_effective_access_administrators() == 1 + ) assert ( await session.scalar( select(func.count()) @@ -6569,7 +6537,7 @@ async def concurrent_link_posts( same_key = keys[0] == keys[1] async def observed_reserve(self, **kwargs): - task_name = asyncio.current_task().get_name() + task_name = current_task_name() if not same_key: return await original_reserve(self, **kwargs) if task_name == "concurrent-link-first": @@ -6586,7 +6554,7 @@ async def observed_reserve(self, **kwargs): return await original_reserve(self, **kwargs) async def observed_lock_control(self): - task_name = asyncio.current_task().get_name() + task_name = current_task_name() if same_key: return await original_lock_control(self) if task_name == "concurrent-link-first": @@ -6735,7 +6703,7 @@ async def ordered_requests( waiter_name = f"auth09db-{uuid4().hex}" async def ordered_lock_control(self): - if asyncio.current_task().get_name() == first_name: + if current_task_name() == first_name: control = await original_lock_control(self) first_locked.set() await asyncio.wait_for( @@ -6836,9 +6804,7 @@ def link_request( before, { "ActorIdentityLinkRevoked": int(error is None or second == "revoke"), - "ActorIdentityLinkReactivated": int( - error is None or second == "reactivate" - ), + "ActorIdentityLinkReactivated": int(error is None or second == "reactivate"), "AuthorityInvalidationRequested": expected_invalidation, "SensitiveAuthorizationDenied": expected_denials, }, @@ -6993,16 +6959,14 @@ async def assert_mixed_row( third_name = f"auth09db-three-third-{uuid4().hex}" async def three_way_lock_control(self): - task_name = asyncio.current_task().get_name() + task_name = current_task_name() if task_name == "three-profile-first": control = await original_lock_control(self) first_locked.set() await release_first.wait() return control await first_locked.wait() - application_name = ( - second_name if task_name == "three-grant-second" else third_name - ) + application_name = second_name if task_name == "three-grant-second" else third_name await self._session.execute( text("select set_config('application_name', :name, true)"), {"name": application_name}, @@ -7106,9 +7070,7 @@ async def actor_self_race(method: str, lifecycle_first: bool, index: int) -> Non self_lock_owner = ActorService self_lock_attribute = "lock_actor_self_for_authorization" original_self_lock = getattr(self_lock_owner, self_lock_attribute) - original_link_lock = ( - AdminAuthorizationRepository.lock_identity_link_lifecycle_target - ) + original_link_lock = AdminAuthorizationRepository.lock_identity_link_lifecycle_target first_locked = asyncio.Event() release_first = asyncio.Event() waiter_entered = asyncio.Event() @@ -7166,6 +7128,7 @@ async def ordered_link_lock(self, identity_link_id): self_task: asyncio.Task[Response] | None = None lifecycle_task: asyncio.Task[Response] | None = None try: + async def self_request() -> Response: return await client.request( method, @@ -7200,8 +7163,7 @@ async def require_first_lock(task: asyncio.Task[Response]) -> None: if task in done: early = task.result() raise AssertionError( - f"blocker returned before target lock: " - f"{early.status_code} {early.text}" + f"blocker returned before target lock: {early.status_code} {early.text}" ) raise AssertionError("blocker did not reach the target lock") @@ -7227,17 +7189,13 @@ async def require_waiter(task: asyncio.Task[Response]) -> None: ) if lifecycle_first: - lifecycle_task = asyncio.create_task( - lifecycle_request(), name=lifecycle_name - ) + lifecycle_task = asyncio.create_task(lifecycle_request(), name=lifecycle_name) await require_first_lock(lifecycle_task) self_task = asyncio.create_task(self_request(), name=self_name) else: self_task = asyncio.create_task(self_request(), name=self_name) await require_first_lock(self_task) - lifecycle_task = asyncio.create_task( - lifecycle_request(), name=lifecycle_name - ) + lifecycle_task = asyncio.create_task(lifecycle_request(), name=lifecycle_name) waiter = self_task if lifecycle_first else lifecycle_task assert waiter is not None await require_waiter(waiter) @@ -7306,9 +7264,9 @@ async def require_waiter(task: asyncio.Task[Response]) -> None: await actor_self_race(method, lifecycle_first, index) async with db_session.get_session_factory()() as session: - assert await AdminAuthorizationRepository( - session - ).count_effective_access_administrators() == 1 + assert ( + await AdminAuthorizationRepository(session).count_effective_access_administrators() == 1 + ) assert ( await session.scalar( select(func.count()) diff --git a/backend/tests/test_authorization.py b/backend/tests/test_authorization.py index 399648b0..50778e64 100644 --- a/backend/tests/test_authorization.py +++ b/backend/tests/test_authorization.py @@ -1,3 +1,6 @@ +# pyright: reportArgumentType=false, reportGeneralTypeIssues=false +# pyright: reportIndexIssue=false, reportOptionalMemberAccess=false +# pyright: reportOptionalSubscript=false, reportRedeclaration=false from __future__ import annotations import ast @@ -15,13 +18,14 @@ from types import SimpleNamespace from uuid import UUID, uuid4 -from alembic import command -from alembic.config import Config -import pytest -from pydantic import ValidationError +import pytest # type: ignore[import-not-found] +from pydantic import ValidationError # type: ignore[import-not-found] from sqlalchemy import text from sqlalchemy.exc import DBAPIError, IntegrityError, SQLAlchemyError -from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine +from sqlalchemy.ext.asyncio import ( # type: ignore[import-not-found] + async_sessionmaker, + create_async_engine, +) from starlette.requests import Request from app.api.deps.authorization import ( @@ -665,9 +669,11 @@ def test_art_custody_documentation_matches_the_independent_catalogue_fixture() - for owner in set(parsed.values()) } == expected_owner_counts - spec_rows = (repository_root / "docs/spec_authorization_service.md").read_text( - encoding="utf-8" - ).splitlines() + spec_rows = ( + (repository_root / "docs/spec_authorization_service.md") + .read_text(encoding="utf-8") + .splitlines() + ) parsed_permissions: dict[str, str] = {} for row in spec_rows: cells = [cell.strip() for cell in row.split("|")] @@ -722,9 +728,11 @@ def test_rev_custody_documentation_matches_the_independent_catalogue_fixture() - for owner in set(parsed.values()) } == expected_owner_counts - spec_rows = (repository_root / "docs/spec_authorization_service.md").read_text( - encoding="utf-8" - ).splitlines() + spec_rows = ( + (repository_root / "docs/spec_authorization_service.md") + .read_text(encoding="utf-8") + .splitlines() + ) parsed_permissions: dict[str, str] = {} for row in spec_rows: cells = [cell.strip() for cell in row.split("|")] @@ -1007,9 +1015,7 @@ async def touch_after_authorization(self, actor) -> None: } ] else: - assert response.updates == [ - {"last_verified_at": resolved.identity_link.last_verified_at} - ] + assert response.updates == [{"last_verified_at": resolved.identity_link.last_verified_at}] @pytest.mark.parametrize( @@ -1386,18 +1392,15 @@ def test_rev_custody_catalogue_mutations_fail_closed(mutation: str) -> None: ) if mutation == "historical_owner": definitions[first_index] = replace( - definitions[first_index], owner="WS-REV-001-05" # type: ignore[arg-type] + definitions[first_index], + owner="WS-REV-001-05", # type: ignore[arg-type] ) message = "invalid row" elif mutation == "wrong_custodian": - definitions[first_index] = replace( - definitions[first_index], owner=ActionOwner.AUTH_REV_12 - ) + definitions[first_index] = replace(definitions[first_index], owner=ActionOwner.AUTH_REV_12) message = "metadata mismatch" elif mutation == "swapped_custodians": - definitions[first_index] = replace( - definitions[first_index], owner=ActionOwner.AUTH_REV_06 - ) + definitions[first_index] = replace(definitions[first_index], owner=ActionOwner.AUTH_REV_06) definitions[second_index] = replace( definitions[second_index], owner=ActionOwner.AUTH_REV_05 ) @@ -1509,9 +1512,7 @@ async def lock_actor_self(self, actor_profile_id, identity_link_id): actor_profile_id=str(actor_profile_id), status="active", ), - SimpleNamespace( - id=str(actor_profile_id), actor_kind="human", status="active" - ), + SimpleNamespace(id=str(actor_profile_id), actor_kind="human", status="active"), ) @@ -1582,13 +1583,9 @@ async def test_prepared_actor_self_handle_is_exact_single_use_and_transaction_bo actor_profile_id=context.actor_profile_id, ) with pytest.raises(TypeError, match="invalid prepared authorization consumer"): - await authorization._prepare_prelocked( - object(), ActionId.ACTOR_PROFILE_UPDATE_SELF, scope - ) + await authorization._prepare_prelocked(object(), ActionId.ACTOR_PROFILE_UPDATE_SELF, scope) assert facts.calls == 0 - handle = await prepared.prepare( - ActionId.ACTOR_PROFILE_UPDATE_SELF, caller_input, scope - ) + handle = await prepared.prepare(ActionId.ACTOR_PROFILE_UPDATE_SELF, caller_input, scope) sealed = prepared._issued[handle].authority # type: ignore[union-attr] with pytest.raises(AttributeError): sealed.matched_grant_status = "active" @@ -1667,18 +1664,14 @@ async def test_prepared_binding_mismatch_does_not_consume_valid_handle(): await prepared.consume( handle, ActionId.ACTOR_PROFILE_UPDATE_SELF, - PreparedAuthorizationInput( - idempotency_key=idempotency_key, request_value={"v": 2} - ), + PreparedAuthorizationInput(idempotency_key=idempotency_key, request_value={"v": 2}), resource, ) with pytest.raises(PreparedAuthorizationHandleInvalid): await prepared.consume( handle, ActionId.ACTOR_PROFILE_UPDATE_SELF, - PreparedAuthorizationInput( - idempotency_key=uuid4(), request_value={"v": 1} - ), + PreparedAuthorizationInput(idempotency_key=uuid4(), request_value={"v": 1}), resource, ) assert evidence.events == [] @@ -1698,9 +1691,7 @@ async def test_prepared_binding_mismatch_does_not_consume_valid_handle(): ) session.root = SimpleNamespace(is_active=True) with pytest.raises(PreparedAuthorizationHandleInvalid): - await prepared.consume( - stale, ActionId.ACTOR_PROFILE_UPDATE_SELF, original, resource - ) + await prepared.consume(stale, ActionId.ACTOR_PROFILE_UPDATE_SELF, original, resource) @pytest.mark.asyncio @@ -1746,31 +1737,21 @@ async def test_prepared_rejects_cross_context_service_session_and_concurrent_con kind=PreparedAuthorityScopeKind.ACTOR_SELF, actor_profile_id=context.actor_profile_id, ) - handle = await prepared.prepare( - ActionId.ACTOR_PROFILE_UPDATE_SELF, caller_input, scope - ) + handle = await prepared.prepare(ActionId.ACTOR_PROFILE_UPDATE_SELF, caller_input, scope) resource = ActorSelfResourceContext( resource_type="actor_profile", resource_id=context.actor_profile_id, requested_fields=("display_name",), ) with pytest.raises(PreparedAuthorizationHandleInvalid): - await sibling.consume( - handle, ActionId.ACTOR_PROFILE_UPDATE_SELF, caller_input, resource - ) + await sibling.consume(handle, ActionId.ACTOR_PROFILE_UPDATE_SELF, caller_input, resource) outcomes = await asyncio.gather( - prepared.consume( - handle, ActionId.ACTOR_PROFILE_UPDATE_SELF, caller_input, resource - ), - prepared.consume( - handle, ActionId.ACTOR_PROFILE_UPDATE_SELF, caller_input, resource - ), + prepared.consume(handle, ActionId.ACTOR_PROFILE_UPDATE_SELF, caller_input, resource), + prepared.consume(handle, ActionId.ACTOR_PROFILE_UPDATE_SELF, caller_input, resource), return_exceptions=True, ) assert sum(isinstance(item, AuthorizationDecision) for item in outcomes) == 1 - assert sum( - isinstance(item, PreparedAuthorizationHandleInvalid) for item in outcomes - ) == 1 + assert sum(isinstance(item, PreparedAuthorizationHandleInvalid) for item in outcomes) == 1 assert len(evidence.events) == 1 @@ -1805,9 +1786,7 @@ async def test_prepared_handle_rejects_forgery_copy_serialization_and_nested_roo kind=PreparedAuthorityScopeKind.ACTOR_SELF, actor_profile_id=context.actor_profile_id, ) - handle = await prepared.prepare( - ActionId.ACTOR_PROFILE_UPDATE_SELF, caller_input, scope - ) + handle = await prepared.prepare(ActionId.ACTOR_PROFILE_UPDATE_SELF, caller_input, scope) forged = object.__new__(PreparedAuthorizationHandle) resource = ActorSelfResourceContext( resource_type="actor_profile", @@ -1815,9 +1794,7 @@ async def test_prepared_handle_rejects_forgery_copy_serialization_and_nested_roo requested_fields=("display_name",), ) with pytest.raises(PreparedAuthorizationHandleInvalid): - await prepared.consume( - forged, ActionId.ACTOR_PROFILE_UPDATE_SELF, caller_input, resource - ) + await prepared.consume(forged, ActionId.ACTOR_PROFILE_UPDATE_SELF, caller_input, resource) with pytest.raises(copy.Error): copy.copy(handle) with pytest.raises(copy.Error): @@ -1831,9 +1808,7 @@ async def test_prepared_handle_rejects_forgery_copy_serialization_and_nested_roo session.nested = True with pytest.raises(PreparedAuthorizationHandleInvalid): - await prepared.consume( - handle, ActionId.ACTOR_PROFILE_UPDATE_SELF, caller_input, resource - ) + await prepared.consume(handle, ActionId.ACTOR_PROFILE_UPDATE_SELF, caller_input, resource) forged_authority = object.__new__(authorization_kernel._PrelockedAuthority) with pytest.raises(TypeError, match="invalid prelocked authority"): @@ -1846,9 +1821,7 @@ async def test_prepared_handle_rejects_forgery_copy_serialization_and_nested_roo session.nested = False prepared.close() with pytest.raises(PreparedAuthorizationHandleInvalid): - await prepared.consume( - handle, ActionId.ACTOR_PROFILE_UPDATE_SELF, caller_input, resource - ) + await prepared.consume(handle, ActionId.ACTOR_PROFILE_UPDATE_SELF, caller_input, resource) @pytest.mark.asyncio @@ -1909,9 +1882,7 @@ async def test_prepared_project_scope_rejects_system_and_other_project_without_c handle = await prepared.prepare( ActionId.ADMIN_ROLE_GRANT_ISSUE, caller_input, - PreparedAuthorityScope( - kind=PreparedAuthorityScopeKind.PROJECT, project_id=project_id - ), + PreparedAuthorityScope(kind=PreparedAuthorityScopeKind.PROJECT, project_id=project_id), ) for wrong_project in (None, uuid4()): wrong_resource = AdminRoleGrantIssueResourceContext( @@ -1950,9 +1921,7 @@ async def test_prepared_project_scope_rejects_system_and_other_project_without_c [ ( ActionId.ADMIN_ROLE_GRANT_REVOKE, - AdminRoleGrantResourceContext( - resource_type="admin_role_grant", resource_id=uuid4() - ), + AdminRoleGrantResourceContext(resource_type="admin_role_grant", resource_id=uuid4()), ), ( ActionId.ACTOR_SERVICE_PROVISION, @@ -2060,18 +2029,14 @@ async def wait_during_evaluation(*_args): requested_fields=("display_name",), ) task = asyncio.create_task( - prepared.consume( - handle, ActionId.ACTOR_PROFILE_UPDATE_SELF, caller_input, resource - ) + prepared.consume(handle, ActionId.ACTOR_PROFILE_UPDATE_SELF, caller_input, resource) ) await entered.wait() task.cancel() with pytest.raises(asyncio.CancelledError): await task with pytest.raises(PreparedAuthorizationHandleInvalid): - await prepared.consume( - handle, ActionId.ACTOR_PROFILE_UPDATE_SELF, caller_input, resource - ) + await prepared.consume(handle, ActionId.ACTOR_PROFILE_UPDATE_SELF, caller_input, resource) @pytest.mark.asyncio @@ -2138,9 +2103,7 @@ async def test_prepared_rejects_unsupported_scope_missing_grant_and_inactive_roo await prepared.prepare( ActionId.ACTOR_PROFILE_SUSPEND, caller_input, - PreparedAuthorityScope( - kind=PreparedAuthorityScopeKind.PROJECT, project_id=uuid4() - ), + PreparedAuthorityScope(kind=PreparedAuthorityScopeKind.PROJECT, project_id=uuid4()), ) with pytest.raises(PreparedAuthorizationUnsupported): await prepared.prepare( @@ -2148,6 +2111,7 @@ async def test_prepared_rejects_unsupported_scope_missing_grant_and_inactive_roo caller_input, PreparedAuthorityScope(kind=PreparedAuthorityScopeKind.SYSTEM), ) + async def missing_grant(*_args, **_kwargs): return None @@ -2201,7 +2165,9 @@ async def test_prepared_postgresql_failure_and_cancellation_are_atomic( ) await session.commit() await session.execute( - text("create temporary table prepared_failure_participant (id int primary key, value int)") + text( + "create temporary table prepared_failure_participant (id int primary key, value int)" + ) ) await session.execute(text("insert into prepared_failure_participant values (1, 0)")) await session.commit() @@ -2215,9 +2181,7 @@ async def test_prepared_postgresql_failure_and_cancellation_are_atomic( correlation_id=uuid4(), ) repository = AdminAuthorizationRepository(session) - authorization = AuthorizationService( - session, context, admin_repository=repository - ) + authorization = AuthorizationService(session, context, admin_repository=repository) prepared = PreparedAuthorizationService( session, context, @@ -2247,20 +2211,20 @@ async def test_prepared_postgresql_failure_and_cancellation_are_atomic( success_input, resource, ) - await session.execute( - text("update prepared_failure_participant set value=1 where id=1") - ) + await session.execute(text("update prepared_failure_participant set value=1 where id=1")) await session.commit() - assert await session.scalar( - text("select value from prepared_failure_participant where id=1") - ) == 1 - assert await session.scalar( - text("select count(*) from audit_events where id=:id"), - {"id": str(success_decision.decision_id)}, - ) == 1 - await session.execute( - text("update prepared_failure_participant set value=0 where id=1") + assert ( + await session.scalar(text("select value from prepared_failure_participant where id=1")) + == 1 ) + assert ( + await session.scalar( + text("select count(*) from audit_events where id=:id"), + {"id": str(success_decision.decision_id)}, + ) + == 1 + ) + await session.execute(text("update prepared_failure_participant set value=0 where id=1")) await session.commit() participant_input = PreparedAuthorizationInput( @@ -2276,20 +2240,22 @@ async def test_prepared_postgresql_failure_and_cancellation_are_atomic( participant_input, resource, ) - await session.execute( - text("update prepared_failure_participant set value=1 where id=1") - ) + await session.execute(text("update prepared_failure_participant set value=1 where id=1")) try: raise RuntimeError("injected participant failure") except RuntimeError: await session.rollback() - assert await session.scalar( - text("select value from prepared_failure_participant where id=1") - ) == 0 - assert await session.scalar( - text("select count(*) from audit_events where id=:id"), - {"id": str(participant_decision.decision_id)}, - ) == 0 + assert ( + await session.scalar(text("select value from prepared_failure_participant where id=1")) + == 0 + ) + assert ( + await session.scalar( + text("select count(*) from audit_events where id=:id"), + {"id": str(participant_decision.decision_id)}, + ) + == 0 + ) await session.rollback() evidence_input = PreparedAuthorizationInput( @@ -2370,9 +2336,7 @@ async def cancelled_command(): commit_failure_input, resource, ) - await session.execute( - text("update prepared_failure_participant set value=2 where id=1") - ) + await session.execute(text("update prepared_failure_participant set value=2 where id=1")) original_commit = session.commit async def fail_commit(): @@ -2383,13 +2347,17 @@ async def fail_commit(): await session.commit() session.commit = original_commit # type: ignore[method-assign] await session.rollback() - assert await session.scalar( - text("select value from prepared_failure_participant where id=1") - ) == 0 - assert await session.scalar( - text("select count(*) from audit_events where id=:id"), - {"id": str(commit_failure_decision.decision_id)}, - ) == 0 + assert ( + await session.scalar(text("select value from prepared_failure_participant where id=1")) + == 0 + ) + assert ( + await session.scalar( + text("select count(*) from audit_events where id=:id"), + {"id": str(commit_failure_decision.decision_id)}, + ) + == 0 + ) await session.rollback() timeout_input = PreparedAuthorizationInput( @@ -2405,20 +2373,22 @@ async def fail_commit(): timeout_input, resource, ) - await session.execute( - text("update prepared_failure_participant set value=3 where id=1") - ) + await session.execute(text("update prepared_failure_participant set value=3 where id=1")) with pytest.raises(TimeoutError): async with asyncio.timeout(0.01): await asyncio.Event().wait() await session.rollback() - assert await session.scalar( - text("select value from prepared_failure_participant where id=1") - ) == 0 - assert await session.scalar( - text("select count(*) from audit_events where id=:id"), - {"id": str(timeout_decision.decision_id)}, - ) == 0 + assert ( + await session.scalar(text("select value from prepared_failure_participant where id=1")) + == 0 + ) + assert ( + await session.scalar( + text("select count(*) from audit_events where id=:id"), + {"id": str(timeout_decision.decision_id)}, + ) + == 0 + ) await session.rollback() async def cancel_after_consume(phase: str): @@ -2443,9 +2413,7 @@ async def command(): ) phase_holder.update(handle=phase_handle, decision=phase_decision) await session.execute( - text( - "update prepared_failure_participant set value=4 where id=1" - ) + text("update prepared_failure_participant set value=4 where id=1") ) phase_entered.set() await phase_blocked.wait() @@ -2459,20 +2427,29 @@ async def command(): task.cancel() with pytest.raises(asyncio.CancelledError): await task - assert await session.scalar( - text("select value from prepared_failure_participant where id=1") - ) == 0 - assert await session.scalar( - text("select count(*) from audit_events where id=:id"), - {"id": str(phase_holder["decision"].decision_id)}, - ) == 0 - assert await session.scalar( - text( - "select count(*) from authority_idempotency_records " - "where idempotency_key=:key" - ), - {"key": phase_input.idempotency_key}, - ) == 0 + assert ( + await session.scalar( + text("select value from prepared_failure_participant where id=1") + ) + == 0 + ) + assert ( + await session.scalar( + text("select count(*) from audit_events where id=:id"), + {"id": str(phase_holder["decision"].decision_id)}, + ) + == 0 + ) + assert ( + await session.scalar( + text( + "select count(*) from authority_idempotency_records " + "where idempotency_key=:key" + ), + {"key": phase_input.idempotency_key}, + ) + == 0 + ) await session.rollback() with pytest.raises(PreparedAuthorizationHandleInvalid): await prepared.consume( @@ -2523,10 +2500,13 @@ async def cancel_during_evidence(): authorization._audit = original_audit assert await session.scalar(text("select 1")) == 1 await session.rollback() - assert await session.scalar( - text("select count(*) from audit_events where id=:id"), - {"id": str(evidence_holder["decision_id"])}, - ) == 0 + assert ( + await session.scalar( + text("select count(*) from audit_events where id=:id"), + {"id": str(evidence_holder["decision_id"])}, + ) + == 0 + ) await session.rollback() with pytest.raises(PreparedAuthorizationHandleInvalid): await prepared.consume( @@ -2576,13 +2556,17 @@ async def cancel_during_commit(): with pytest.raises(asyncio.CancelledError): await commit_task session.commit = original_commit # type: ignore[method-assign] - assert await session.scalar( - text("select value from prepared_failure_participant where id=1") - ) == 0 - assert await session.scalar( - text("select count(*) from audit_events where id=:id"), - {"id": str(commit_holder["decision"].decision_id)}, - ) == 0 + assert ( + await session.scalar(text("select value from prepared_failure_participant where id=1")) + == 0 + ) + assert ( + await session.scalar( + text("select count(*) from audit_events where id=:id"), + {"id": str(commit_holder["decision"].decision_id)}, + ) + == 0 + ) await session.rollback() with pytest.raises(PreparedAuthorizationHandleInvalid): await prepared.consume( @@ -2635,20 +2619,19 @@ async def cancel_during_commit(): {"id": str(success_decision.decision_id)}, ) await session.execute(text("alter table audit_events enable trigger user")) - await session.execute( - text("alter table actor_identity_links disable trigger user") - ) + await session.execute(text("alter table actor_identity_links disable trigger user")) await session.execute(text("alter table actor_profiles disable trigger user")) await session.execute( text("delete from actor_identity_links where id=:id"), {"id": str(link_id)} ) - await session.execute(text("delete from actor_profiles where id=:id"), {"id": str(profile_id)}) await session.execute( - text("alter table actor_identity_links enable trigger user") + text("delete from actor_profiles where id=:id"), {"id": str(profile_id)} ) + await session.execute(text("alter table actor_identity_links enable trigger user")) await session.execute(text("alter table actor_profiles enable trigger user")) await session.commit() + @pytest.mark.asyncio @pytest.mark.parametrize( ("mutation_sql", "mutation_values", "denial_code"), @@ -2747,9 +2730,7 @@ async def test_prepared_actor_authority_crossed_mutations_complete_in_both_order authorization, repository, ) - handle = await prepared.prepare( - ActionId.ACTOR_PROFILE_UPDATE_SELF, caller_input, scope - ) + handle = await prepared.prepare(ActionId.ACTOR_PROFILE_UPDATE_SELF, caller_input, scope) async def mutate_after_prepare(): async with authorization_factory() as mutation_session: @@ -2767,9 +2748,12 @@ async def mutate_after_prepare(): async with authorization_factory() as verify: if denial_code is AuthorizationDenialCode.IDENTITY_LINK_REVOKED: - assert await verify.scalar( - text("select status from actor_identity_links where id=:link"), values - ) == "revoked" + assert ( + await verify.scalar( + text("select status from actor_identity_links where id=:link"), values + ) + == "revoked" + ) else: assert await verify.scalar( text("select status from actor_profiles where id=:actor"), values @@ -2795,9 +2779,7 @@ async def mutate_after_prepare(): ) with pytest.raises(PreparedAuthorizationUnsupported) as denied: await asyncio.wait_for( - denied_prepared.prepare( - ActionId.ACTOR_PROFILE_UPDATE_SELF, caller_input, scope - ), + denied_prepared.prepare(ActionId.ACTOR_PROFILE_UPDATE_SELF, caller_input, scope), timeout=5, ) assert denied.value.denial_code is denial_code @@ -2810,23 +2792,17 @@ async def mutate_after_prepare(): {"id": str(decision.decision_id)}, ) await cleanup.execute(text("alter table audit_events enable trigger user")) - await cleanup.execute( - text("alter table actor_identity_links disable trigger user") - ) + await cleanup.execute(text("alter table actor_identity_links disable trigger user")) await cleanup.execute(text("alter table actor_profiles disable trigger user")) await cleanup.execute(text("delete from actor_identity_links where id=:link"), values) await cleanup.execute(text("delete from actor_profiles where id=:actor"), values) - await cleanup.execute( - text("alter table actor_identity_links enable trigger user") - ) + await cleanup.execute(text("alter table actor_identity_links enable trigger user")) await cleanup.execute(text("alter table actor_profiles enable trigger user")) await cleanup.commit() @pytest.mark.asyncio -@pytest.mark.parametrize( - "mutation_kind", ["suspend", "deactivate", "link_revoke", "grant_revoke"] -) +@pytest.mark.parametrize("mutation_kind", ["suspend", "deactivate", "link_revoke", "grant_revoke"]) @pytest.mark.parametrize("ordering", ["prepared_first", "mutation_first"]) async def test_prepared_crosses_real_lifecycle_service_transactions( authorization_factory, @@ -2938,12 +2914,8 @@ async def test_prepared_crosses_real_lifecycle_service_transactions( async def run_mutation(*, pause_after_authority: bool) -> None: async with authorization_factory() as mutation_session: - resolved_profile = await mutation_session.get( - ActorProfile, str(mutator_profile_id) - ) - resolved_link = await mutation_session.get( - ActorIdentityLink, str(mutator_link_id) - ) + resolved_profile = await mutation_session.get(ActorProfile, str(mutator_profile_id)) + resolved_link = await mutation_session.get(ActorIdentityLink, str(mutator_link_id)) assert resolved_profile is not None and resolved_link is not None resolved = ResolvedActor(resolved_profile, resolved_link) mutation_context = HumanAuthorizationContext( @@ -3044,9 +3016,7 @@ async def find_and_pause(*args, **kwargs): mutation_task = asyncio.create_task(run_mutation(pause_after_authority=True)) await asyncio.wait_for(mutation_entered.wait(), timeout=5) prepare_task = asyncio.create_task( - prepared.prepare( - ActionId.ACTOR_SERVICE_PROVISION, caller_input, prepared_scope - ) + prepared.prepare(ActionId.ACTOR_SERVICE_PROVISION, caller_input, prepared_scope) ) with pytest.raises(TimeoutError): await asyncio.wait_for(asyncio.shield(prepare_task), timeout=0.2) @@ -3054,49 +3024,61 @@ async def find_and_pause(*args, **kwargs): await asyncio.wait_for(mutation_task, timeout=5) with pytest.raises(PreparedAuthorizationUnsupported) as denied: await asyncio.wait_for(prepare_task, timeout=5) - assert ( - denied.value.denial_code - is AuthorizationDenialCode.PERMISSION_NOT_GRANTED - ) + assert denied.value.denial_code is AuthorizationDenialCode.PERMISSION_NOT_GRANTED await prepared_session.rollback() async with authorization_factory() as verify: - assert await verify.scalar( - text( - "select count(*) from admin_role_grants g " - "join actor_profiles p on p.id=g.target_actor_profile_id " - "join actor_identity_links l on l.actor_profile_id=p.id " - "where g.id in (:target, :mutator) and g.status='active' " - "and p.status='active' and l.status='active'" - ), - {"target": target_grant_id, "mutator": mutator_grant_id}, - ) == 1 + assert ( + await verify.scalar( + text( + "select count(*) from admin_role_grants g " + "join actor_profiles p on p.id=g.target_actor_profile_id " + "join actor_identity_links l on l.actor_profile_id=p.id " + "where g.id in (:target, :mutator) and g.status='active' " + "and p.status='active' and l.status='active'" + ), + {"target": target_grant_id, "mutator": mutator_grant_id}, + ) + == 1 + ) if mutation_kind == "grant_revoke": - assert await verify.scalar( - text("select status from admin_role_grants where id=:id"), - {"id": target_grant_id}, - ) == "revoked" - assert await verify.scalar( - text("select version from admin_role_grants where id=:id"), - {"id": target_grant_id}, - ) == 2 + assert ( + await verify.scalar( + text("select status from admin_role_grants where id=:id"), + {"id": target_grant_id}, + ) + == "revoked" + ) + assert ( + await verify.scalar( + text("select version from admin_role_grants where id=:id"), + {"id": target_grant_id}, + ) + == 2 + ) elif mutation_kind == "link_revoke": - assert await verify.scalar( - text("select status from actor_identity_links where id=:id"), - {"id": str(target_link_id)}, - ) == "revoked" + assert ( + await verify.scalar( + text("select status from actor_identity_links where id=:id"), + {"id": str(target_link_id)}, + ) + == "revoked" + ) else: assert await verify.scalar( text("select status from actor_profiles where id=:id"), {"id": str(target_profile_id)}, ) == ("suspended" if mutation_kind == "suspend" else "deactivated") - assert await verify.scalar( - text( - "select count(*) from authority_idempotency_records " - "where actor_ref=:actor and status='committed'" - ), - {"actor": str(mutator_profile_id)}, - ) == 1 + assert ( + await verify.scalar( + text( + "select count(*) from authority_idempotency_records " + "where actor_ref=:actor and status='committed'" + ), + {"actor": str(mutator_profile_id)}, + ) + == 1 + ) prepared_allowed = await verify.scalar( text( "select count(*) from audit_events where action_id=:action " @@ -3126,14 +3108,11 @@ async def find_and_pause(*args, **kwargs): ) await cleanup.execute( text( - "delete from authority_idempotency_records " - "where actor_ref in (:target, :mutator)" + "delete from authority_idempotency_records where actor_ref in (:target, :mutator)" ), {"target": actor_ids[0], "mutator": actor_ids[1]}, ) - await cleanup.execute( - text("alter table authority_idempotency_records enable trigger user") - ) + await cleanup.execute(text("alter table authority_idempotency_records enable trigger user")) await cleanup.execute(text("alter table admin_role_grants disable trigger user")) await cleanup.execute(text("alter table authority_control disable trigger user")) await cleanup.execute( @@ -3148,9 +3127,7 @@ async def find_and_pause(*args, **kwargs): ) await cleanup.execute(text("alter table admin_role_grants enable trigger user")) await cleanup.execute(text("alter table authority_control enable trigger user")) - await cleanup.execute( - text("alter table actor_identity_links disable trigger user") - ) + await cleanup.execute(text("alter table actor_identity_links disable trigger user")) await cleanup.execute(text("alter table actor_profiles disable trigger user")) await cleanup.execute( text("delete from actor_identity_links where id in (:target, :mutator)"), @@ -3160,9 +3137,7 @@ async def find_and_pause(*args, **kwargs): text("delete from actor_profiles where id in (:target, :mutator)"), {"target": actor_ids[0], "mutator": actor_ids[1]}, ) - await cleanup.execute( - text("alter table actor_identity_links enable trigger user") - ) + await cleanup.execute(text("alter table actor_identity_links enable trigger user")) await cleanup.execute(text("alter table actor_profiles enable trigger user")) await cleanup.commit() @@ -3214,9 +3189,7 @@ async def test_prepared_postgresql_rejects_duplicate_supported_grant_and_reuses_ await seed.commit() async with authorization_factory() as duplicate: - await duplicate.execute( - text("alter table admin_role_grants disable trigger user") - ) + await duplicate.execute(text("alter table admin_role_grants disable trigger user")) duplicate.add( AdminRoleGrant( id=duplicate_id, @@ -3246,12 +3219,8 @@ async def test_prepared_postgresql_rejects_duplicate_supported_grant_and_reuses_ async with authorization_factory() as session: await session.begin() repository = AdminAuthorizationRepository(session) - authorization = AuthorizationService( - session, context, admin_repository=repository - ) - prepared = PreparedAuthorizationService( - session, context, authorization, repository - ) + authorization = AuthorizationService(session, context, admin_repository=repository) + prepared = PreparedAuthorizationService(session, context, authorization, repository) caller_input = PreparedAuthorizationInput( idempotency_key=uuid4(), request_value={"case": "sole_grant"} ) @@ -3274,13 +3243,9 @@ async def test_prepared_postgresql_rejects_duplicate_supported_grant_and_reuses_ async with authorization_factory() as cleanup: await cleanup.execute(text("alter table admin_role_grants disable trigger user")) - await cleanup.execute( - text("delete from admin_role_grants where id=:id"), {"id": grant_id} - ) + await cleanup.execute(text("delete from admin_role_grants where id=:id"), {"id": grant_id}) await cleanup.execute(text("alter table admin_role_grants enable trigger user")) - await cleanup.execute( - text("alter table actor_identity_links disable trigger user") - ) + await cleanup.execute(text("alter table actor_identity_links disable trigger user")) await cleanup.execute(text("alter table actor_profiles disable trigger user")) await cleanup.execute( text("delete from actor_identity_links where id=:id"), {"id": str(link_id)} @@ -3288,9 +3253,7 @@ async def test_prepared_postgresql_rejects_duplicate_supported_grant_and_reuses_ await cleanup.execute( text("delete from actor_profiles where id=:id"), {"id": str(profile_id)} ) - await cleanup.execute( - text("alter table actor_identity_links enable trigger user") - ) + await cleanup.execute(text("alter table actor_identity_links enable trigger user")) await cleanup.execute(text("alter table actor_profiles enable trigger user")) await cleanup.commit() @@ -3300,7 +3263,7 @@ class _AdminPolicyFacts: def __init__(self, context: AuthorizationContext) -> None: self.context = context - self.matched = SimpleNamespace(id=uuid4()) + self.matched: SimpleNamespace | None = SimpleNamespace(id=uuid4()) self.has_any = False self.target_exists = True self.project_is_present = True @@ -3538,9 +3501,7 @@ async def test_actor_profile_lifecycle_kernel_guards_self_pairing_and_disclosure await service.require(ActionId.ACTOR_PROFILE_SUSPEND, self_resource) assert self_denial.value.public_code == "resource_guard_denied" - crossed = self_resource.model_copy( - update={"resource_id": uuid4(), "transition": "deactivate"} - ) + crossed = self_resource.model_copy(update={"resource_id": uuid4(), "transition": "deactivate"}) with pytest.raises(AuthorizationDenied) as crossed_denial: await service.require(ActionId.ACTOR_PROFILE_SUSPEND, crossed) assert crossed_denial.value.public_code == "resource_guard_denied" @@ -3596,9 +3557,7 @@ async def test_identity_link_lifecycle_kernel_guards_self_pairing_and_disclosure await service.require(ActionId.ACTOR_IDENTITY_LINK_REVOKE, self_revoke) assert self_denial.value.public_code == "resource_guard_denied" - crossed = self_revoke.model_copy( - update={"resource_id": uuid4(), "transition": "reactivate"} - ) + crossed = self_revoke.model_copy(update={"resource_id": uuid4(), "transition": "reactivate"}) with pytest.raises(AuthorizationDenied) as crossed_denial: await service.require(ActionId.ACTOR_IDENTITY_LINK_REVOKE, crossed) assert crossed_denial.value.public_code == "resource_guard_denied" @@ -3668,9 +3627,7 @@ def _identity_link_lifecycle_decision( ), }[request.operation] permission = { - AuthorityOperation.ACTOR_IDENTITY_LINK_REVOKE: ( - PermissionId.ACTOR_IDENTITY_LINK_REVOKE - ), + AuthorityOperation.ACTOR_IDENTITY_LINK_REVOKE: (PermissionId.ACTOR_IDENTITY_LINK_REVOKE), AuthorityOperation.ACTOR_IDENTITY_LINK_REACTIVATE: ( PermissionId.ACTOR_IDENTITY_LINK_REACTIVATE ), @@ -4362,9 +4319,7 @@ def __getattr__(self, name): request_id=uuid4(), correlation_id=uuid4(), ) - revoke_claim = issue_claim.model_copy( - update={"operation": revoke_request.operation} - ) + revoke_claim = issue_claim.model_copy(update={"operation": revoke_request.operation}) with pytest.raises(TypeError, match="requires exact matched authority"): await service.complete_revoke( claim=revoke_claim, @@ -4384,9 +4339,7 @@ def __getattr__(self, name): await service.record_mismatch( actor_profile_id=actor_id, request=revoke_request, - decision=revoke_decision.model_copy( - update={"resource_context_digest": normal_digest} - ), + decision=revoke_decision.model_copy(update={"resource_context_digest": normal_digest}), ) @@ -4416,6 +4369,7 @@ async def count_effective_access_administrators(self): facts = Facts() service._repository = facts # type: ignore[assignment] original_grant = grant + async def get_missing_grant(*_args, **_kwargs): return None @@ -5007,7 +4961,9 @@ def in_transaction(self): correlation_id=uuid4(), ) with pytest.raises(StructuredHTTPException): - await denial_dependency.athrow(AuthorizationDenied(denied)) + await denial_dependency.athrow( # type: ignore[attr-defined] + AuthorizationDenied(denied) + ) assert denial_session.rollback_count == 1 assert denial_session.commit_count == 0 @@ -5132,7 +5088,7 @@ async def add_authority_event(self, _event): with pytest.raises(AuthorizationDenied) as exc_info: await service.require(ActionId.ARTIFACT_VERIFICATION_EXECUTE, resource) with pytest.raises(StructuredHTTPException) as public: - await dependency.athrow(exc_info.value) + await dependency.athrow(exc_info.value) # type: ignore[attr-defined] assert public.value.error_code == "permission_not_granted" assert observations == ["staged"] @@ -5175,7 +5131,9 @@ async def stage_observation(_self, _resolved): await anext(dependency) with pytest.raises(asyncio.CancelledError): - await dependency.athrow(asyncio.CancelledError()) + await dependency.athrow( # type: ignore[attr-defined] + asyncio.CancelledError() + ) assert observations == ["staged"] assert session.rollback_count == 1 @@ -5412,9 +5370,17 @@ async def revalidate(current: ServiceAuthorizationContext, action: ActionId): @pytest.mark.parametrize( ("actor_status", "link_status", "expected"), [ - (ActorStatus.ACTIVE, IdentityLinkStatus.REVOKED, AuthorizationDenialCode.IDENTITY_LINK_REVOKED), + ( + ActorStatus.ACTIVE, + IdentityLinkStatus.REVOKED, + AuthorizationDenialCode.IDENTITY_LINK_REVOKED, + ), (ActorStatus.SUSPENDED, IdentityLinkStatus.ACTIVE, AuthorizationDenialCode.ACTOR_SUSPENDED), - (ActorStatus.DEACTIVATED, IdentityLinkStatus.ACTIVE, AuthorizationDenialCode.ACTOR_DEACTIVATED), + ( + ActorStatus.DEACTIVATED, + IdentityLinkStatus.ACTIVE, + AuthorizationDenialCode.ACTOR_DEACTIVATED, + ), ], ) async def test_fixed_service_lifecycle_denies_before_matrix_evaluation( @@ -5439,11 +5405,36 @@ async def test_fixed_service_lifecycle_denies_before_matrix_evaluation( @pytest.mark.parametrize( ("profile_status", "link_status", "service_identity", "expected"), [ - ("suspended", "active", ServiceIdentity.ARTIFACT_VERIFIER.value, AuthorizationDenialCode.ACTOR_SUSPENDED), - ("deactivated", "active", ServiceIdentity.ARTIFACT_VERIFIER.value, AuthorizationDenialCode.ACTOR_DEACTIVATED), - ("active", "revoked", ServiceIdentity.ARTIFACT_VERIFIER.value, AuthorizationDenialCode.IDENTITY_LINK_REVOKED), - ("active", "active", ServiceIdentity.ARTIFACT_SCHEDULER.value, AuthorizationDenialCode.PERMISSION_NOT_GRANTED), - ("active", "active", "malformed-service-identity", AuthorizationDenialCode.PERMISSION_NOT_GRANTED), + ( + "suspended", + "active", + ServiceIdentity.ARTIFACT_VERIFIER.value, + AuthorizationDenialCode.ACTOR_SUSPENDED, + ), + ( + "deactivated", + "active", + ServiceIdentity.ARTIFACT_VERIFIER.value, + AuthorizationDenialCode.ACTOR_DEACTIVATED, + ), + ( + "active", + "revoked", + ServiceIdentity.ARTIFACT_VERIFIER.value, + AuthorizationDenialCode.IDENTITY_LINK_REVOKED, + ), + ( + "active", + "active", + ServiceIdentity.ARTIFACT_SCHEDULER.value, + AuthorizationDenialCode.PERMISSION_NOT_GRANTED, + ), + ( + "active", + "active", + "malformed-service-identity", + AuthorizationDenialCode.PERMISSION_NOT_GRANTED, + ), ], ) async def test_fixed_service_real_revalidation_rejects_locked_drift( @@ -5543,7 +5534,9 @@ async def revalidate(current: ServiceAuthorizationContext, _action: ActionId): "ACTION_BY_ID", { **active_actions, - action: replace(active_actions[action], availability=ActionAvailability.PLANNED), + action: replace( + active_actions[action], availability=ActionAvailability.PLANNED + ), }, ) return current @@ -5884,10 +5877,7 @@ async def test_actor_self_update_requires_transaction_revalidation() -> None: without_read_recheck, _ = _runtime_service(context, revalidate=None) with pytest.raises(AuthorizationDenied) as read_exc_info: await without_read_recheck.require(ActionId.ACTOR_PROFILE_READ_SELF, read_resource) - assert ( - read_exc_info.value.decision.denial_code - is AuthorizationDenialCode.RESOURCE_GUARD_DENIED - ) + assert read_exc_info.value.decision.denial_code is AuthorizationDenialCode.RESOURCE_GUARD_DENIED without_recheck, _ = _runtime_service(context, revalidate=None) with pytest.raises(AuthorizationDenied) as exc_info: @@ -5912,19 +5902,13 @@ async def revalidate(current, supplied_resource): @pytest.fixture def authorization_database_env( - postgres_database_url: str, - migration_lock, + clean_postgres_database: str, monkeypatch: pytest.MonkeyPatch, ) -> Iterator[str]: - """Ensure authorization tests run at the current isolated schema head.""" - monkeypatch.setenv("WORKSTREAM_DATABASE_URL", postgres_database_url) + """Ensure authorization tests use a clean current schema.""" + monkeypatch.setenv("WORKSTREAM_DATABASE_URL", clean_postgres_database) get_settings.cache_clear() - project_root = Path(__file__).resolve().parents[1] - config = Config(str(project_root / "alembic.ini")) - config.set_main_option("script_location", str(project_root / "alembic")) - with migration_lock(): - command.upgrade(config, "head") - yield postgres_database_url + yield clean_postgres_database get_settings.cache_clear() @@ -6031,10 +6015,7 @@ async def test_authorization_locks_refresh_cached_actor_lifecycle_state( assert locked_link is cached_link assert locked_profile.status == "suspended" assert locked_link.status == "revoked" - assert ( - await AdminAuthorizationRepository(stale).lock_eligible_human(profile_id) - is None - ) + assert await AdminAuthorizationRepository(stale).lock_eligible_human(profile_id) is None await stale.rollback() cached_profile = await stale.get(ActorProfile, str(profile_id)) @@ -6062,9 +6043,7 @@ async def test_authorization_locks_refresh_cached_actor_lifecycle_state( ) await lifecycle.commit() - eligible = await AdminAuthorizationRepository(stale).lock_eligible_human( - profile_id - ) + eligible = await AdminAuthorizationRepository(stale).lock_eligible_human(profile_id) assert eligible is not None eligible_link, eligible_profile = eligible assert eligible_profile is cached_profile @@ -7353,9 +7332,9 @@ async def test_project_role_and_all_operation_mappings_commit_one_linked_pair( row for row in pair if row.event_type == "AuthorityInvalidationRequested" ) assert invalidation_row.invalidation_cause_event_id == success_row.id - assert { - (row.request_id, row.correlation_id) for row in pair - } == {(success.request_id, success.correlation_id)} + assert {(row.request_id, row.correlation_id) for row in pair} == { + (success.request_id, success.correlation_id) + } expected_target = ( success.target_actor_ref if success.event_type diff --git a/backend/tests/test_checkers.py b/backend/tests/test_checkers.py index ef57f550..d36c2a95 100644 --- a/backend/tests/test_checkers.py +++ b/backend/tests/test_checkers.py @@ -1,12 +1,12 @@ +# pyright: reportAttributeAccessIssue=false from __future__ import annotations -import asyncio from collections.abc import AsyncIterator, Iterator +import math from pathlib import Path from typing import Any import pytest -from alembic import command from alembic.config import Config from httpx import ASGITransport, AsyncClient from pydantic import TypeAdapter, ValidationError @@ -60,11 +60,9 @@ @pytest.fixture def checker_database_env( monkeypatch: pytest.MonkeyPatch, - postgres_database_url: str, - migration_lock, - reset_test_database_state, + clean_postgres_database: str, ) -> Iterator[str]: - monkeypatch.setenv("WORKSTREAM_DATABASE_URL", postgres_database_url) + monkeypatch.setenv("WORKSTREAM_DATABASE_URL", clean_postgres_database) monkeypatch.setenv("WORKSTREAM_CELERY_TASK_ALWAYS_EAGER", "true") monkeypatch.setenv( "WORKSTREAM_API_RATE_LIMIT_KEY_SECRET", @@ -72,28 +70,10 @@ def checker_database_env( ) set_dev_actor(monkeypatch, roles="project_manager", subject="project-manager-subject") get_settings.cache_clear() - asyncio.run(db_session.dispose_engine()) - - config = alembic_config() try: - with migration_lock(): - command.downgrade(config, "base") - try: - command.upgrade(config, "head") - yield postgres_database_url - finally: - try: - asyncio.run(reset_test_database_state(postgres_database_url)) - finally: - try: - asyncio.run(db_session.dispose_engine()) - finally: - command.downgrade(config, "base") + yield clean_postgres_database finally: - try: - asyncio.run(db_session.dispose_engine()) - finally: - get_settings.cache_clear() + get_settings.cache_clear() @pytest.fixture @@ -679,9 +659,9 @@ def test_checker_run_openapi_documents_worker_safe_public_response_schema() -> N } assert forbidden_properties.isdisjoint(public_properties) - detail_schema = schema["paths"]["/api/v1/checker-runs/{checker_run_id}"]["get"][ - "responses" - ]["200"]["content"]["application/json"]["schema"] + detail_schema = schema["paths"]["/api/v1/checker-runs/{checker_run_id}"]["get"]["responses"][ + "200" + ]["content"]["application/json"]["schema"] list_schema = schema["paths"]["/api/v1/submissions/{submission_id}/checker-runs"]["get"][ "responses" ]["200"]["content"]["application/json"]["schema"] @@ -885,11 +865,7 @@ def test_pre_submit_compiler_rejects_omitted_required_artifact_coverage() -> Non effective_policy = compiler_effective_policy() effective_policy_hash = "sha256:" + "3" * 64 spec = build_project_pre_submit_checker_spec(effective_policy, effective_policy_hash) - spec["rules"] = [ - rule - for rule in spec["rules"] - if rule["primitive"] != "require_file" - ] + spec["rules"] = [rule for rule in spec["rules"] if rule["primitive"] != "require_file"] with pytest.raises(PreSubmitCheckerCompilerError, match="require_file"): compile_project_pre_submit_checker_spec(effective_policy, effective_policy_hash, spec) @@ -945,7 +921,7 @@ def test_pre_submit_compiler_rejects_configured_warning_only_rule() -> None: def test_canonical_json_hash_rejects_non_finite_numbers() -> None: with pytest.raises(ValueError): - canonical_json_hash({"score": float("nan")}) + canonical_json_hash({"score": math.nan}) def test_pre_submit_compiler_rejects_missing_workstream_defaults() -> None: @@ -1210,7 +1186,9 @@ async def test_pre_submit_chunk8_matrix_flags_missing_evidence_and_warning( assert result_by_name["check_confidentiality_attestation"]["status"] == "passed" assert result_by_name["check_low_quality_generated_artifacts"]["status"] == "warning" assert ( - result_by_name["check_low_quality_generated_artifacts"]["would_block_if_submitted"] + result_by_name["check_low_quality_generated_artifacts"][ + "would_block_if_submitted" + ] is False ) @@ -1233,7 +1211,9 @@ async def test_locked_submission_checker_run_persists_results_and_allows_review( assert created.status_code == 201, created.text set_dev_actor(monkeypatch, roles="project_manager", subject="project-manager-subject") - _, body = await get_submission_and_automatic_pre_review_run(checker_client, created.json()["id"]) + _, body = await get_submission_and_automatic_pre_review_run( + checker_client, created.json()["id"] + ) assert body["status"] == "completed" assert body["trigger_source"] == "submission_finalized" assert body["routing_recommendation"] == "allow_review" @@ -1242,7 +1222,9 @@ async def test_locked_submission_checker_run_persists_results_and_allows_review( expected_post_submit_policy = await load_post_submit_checker_policy(project["id"]) assert body["locked_post_submit_checker_policy_id"] == expected_post_submit_policy["id"] assert body["locked_post_submit_checker_policy_version"] == "v1" - assert body["locked_post_submit_checker_policy_hash"] == expected_post_submit_policy["policy_hash"] + assert ( + body["locked_post_submit_checker_policy_hash"] == expected_post_submit_policy["policy_hash"] + ) assert body["artifact_manifest_hash"].startswith("sha256:") assert body["audit_event_id"] assert body["passed_count"] >= 8 @@ -1336,15 +1318,23 @@ async def test_database_rejects_missing_submission_post_submit_policy_context( task = await session.get(WorkstreamTask, started_task["id"]) submission = await session.get(Submission, created.json()["id"]) runs = ( - await session.execute( - select(CheckerRun).where(CheckerRun.submission_id == created.json()["id"]) + ( + await session.execute( + select(CheckerRun).where(CheckerRun.submission_id == created.json()["id"]) + ) ) - ).scalars().all() + .scalars() + .all() + ) results = ( - await session.execute( - select(CheckerResult).where(CheckerResult.submission_id == created.json()["id"]) + ( + await session.execute( + select(CheckerResult).where(CheckerResult.submission_id == created.json()["id"]) + ) ) - ).scalars().all() + .scalars() + .all() + ) assert task is not None assert task.status == "review_pending" assert submission is not None @@ -1394,15 +1384,23 @@ async def test_checker_run_uses_locked_post_submit_policy_body_after_setup_mutat task = await session.get(WorkstreamTask, started_task["id"]) submission = await session.get(Submission, created.json()["id"]) runs = ( - await session.execute( - select(CheckerRun).where(CheckerRun.submission_id == created.json()["id"]) + ( + await session.execute( + select(CheckerRun).where(CheckerRun.submission_id == created.json()["id"]) + ) ) - ).scalars().all() + .scalars() + .all() + ) results = ( - await session.execute( - select(CheckerResult).where(CheckerResult.submission_id == created.json()["id"]) + ( + await session.execute( + select(CheckerResult).where(CheckerResult.submission_id == created.json()["id"]) + ) ) - ).scalars().all() + .scalars() + .all() + ) assert task is not None assert task.status == "review_pending" assert submission is not None @@ -1415,9 +1413,7 @@ async def test_checker_run_uses_locked_post_submit_policy_body_after_setup_mutat assert "check_evidence_present" in locked_body["default_checkers"] assert "check_evidence_present" in locked_body["execution_checkers"] assert "check_required_files" in locked_body["execution_checkers"] - assert "check_acceptance_criteria_present" not in { - result.checker_name for result in results - } + assert "check_acceptance_criteria_present" not in {result.checker_name for result in results} assert results != [] @@ -1451,27 +1447,45 @@ async def test_submission_rejects_malformed_locked_post_submit_policy_body_witho async with db_session.get_session_factory()() as session: task = await session.get(WorkstreamTask, started_task["id"]) submissions = ( - await session.execute( - select(Submission).where(Submission.task_id == started_task["id"]) + ( + await session.execute( + select(Submission).where(Submission.task_id == started_task["id"]) + ) ) - ).scalars().all() + .scalars() + .all() + ) runs = ( - await session.execute( - select(CheckerRun).join(Submission, CheckerRun.submission_id == Submission.id) - .where(Submission.task_id == started_task["id"]) + ( + await session.execute( + select(CheckerRun) + .join(Submission, CheckerRun.submission_id == Submission.id) + .where(Submission.task_id == started_task["id"]) + ) ) - ).scalars().all() + .scalars() + .all() + ) results = ( - await session.execute( - select(CheckerResult).join(Submission, CheckerResult.submission_id == Submission.id) - .where(Submission.task_id == started_task["id"]) + ( + await session.execute( + select(CheckerResult) + .join(Submission, CheckerResult.submission_id == Submission.id) + .where(Submission.task_id == started_task["id"]) + ) ) - ).scalars().all() + .scalars() + .all() + ) audit_events = ( - await session.execute( - select(AuditEvent).where(AuditEvent.entity_id == started_task["id"]) + ( + await session.execute( + select(AuditEvent).where(AuditEvent.entity_id == started_task["id"]) + ) ) - ).scalars().all() + .scalars() + .all() + ) assert task is not None assert task.status == "in_progress" @@ -1507,15 +1521,23 @@ async def test_database_rejects_mismatched_submission_post_submit_policy_context task = await session.get(WorkstreamTask, started_task["id"]) submission = await session.get(Submission, created.json()["id"]) runs = ( - await session.execute( - select(CheckerRun).where(CheckerRun.submission_id == created.json()["id"]) + ( + await session.execute( + select(CheckerRun).where(CheckerRun.submission_id == created.json()["id"]) + ) ) - ).scalars().all() + .scalars() + .all() + ) results = ( - await session.execute( - select(CheckerResult).where(CheckerResult.submission_id == created.json()["id"]) + ( + await session.execute( + select(CheckerResult).where(CheckerResult.submission_id == created.json()["id"]) + ) ) - ).scalars().all() + .scalars() + .all() + ) assert task is not None assert task.status == "review_pending" assert submission is not None @@ -1554,9 +1576,7 @@ async def test_locked_submission_checker_run_enforces_required_evidence_key( ) evidence_result = next( - result - for result in body["results"] - if result["checker_name"] == "check_evidence_present" + result for result in body["results"] if result["checker_name"] == "check_evidence_present" ) assert evidence_result["status"] == "failed" assert "checker_log" in evidence_result["metadata"]["missing_required_evidence"] @@ -1617,7 +1637,9 @@ async def test_checker_run_retry_supersedes_previous_current_run( ) assert created.status_code == 201, created.text set_dev_actor(monkeypatch, roles="project_manager", subject="project-manager-subject") - _, first = await get_submission_and_automatic_pre_review_run(checker_client, created.json()["id"]) + _, first = await get_submission_and_automatic_pre_review_run( + checker_client, created.json()["id"] + ) set_dev_actor(monkeypatch, roles="project_manager", subject="other-project-manager") wrong_manager_retry = await checker_client.post( @@ -1702,8 +1724,14 @@ async def test_duplicate_artifact_fails_before_submission_row( async with db_session.get_session_factory()() as session: task = await session.get(WorkstreamTask, started_task["id"]) submissions = ( - await session.execute(select(Submission).where(Submission.task_id == started_task["id"])) - ).scalars().all() + ( + await session.execute( + select(Submission).where(Submission.task_id == started_task["id"]) + ) + ) + .scalars() + .all() + ) checker_runs = (await session.execute(select(CheckerRun))).scalars().all() assert task is not None assert task.status == "in_progress" @@ -1881,7 +1909,9 @@ async def test_chunk8_low_quality_generated_artifacts_warns_without_blocking( assert created.status_code == 201, created.text set_dev_actor(monkeypatch, roles="project_manager", subject="project-manager-subject") - _, body = await get_submission_and_automatic_pre_review_run(checker_client, created.json()["id"]) + _, body = await get_submission_and_automatic_pre_review_run( + checker_client, created.json()["id"] + ) assert body["routing_recommendation"] == "allow_review" assert body["outcome_source"] == "none" assert body["warning_count"] >= 1 @@ -1941,7 +1971,9 @@ async def test_checker_caused_revision_resubmits_fixed_version_through_api( ( await session.execute( select(AuditEvent) - .where(AuditEvent.entity_type == "task", AuditEvent.entity_id == started_task["id"]) + .where( + AuditEvent.entity_type == "task", AuditEvent.entity_id == started_task["id"] + ) .order_by(AuditEvent.created_at) ) ) @@ -1951,18 +1983,17 @@ async def test_checker_caused_revision_resubmits_fixed_version_through_api( pre_submit_policy_id = task.locked_pre_submit_checker_policy_id if task else None pre_submit_bundle_hash = task.locked_pre_submit_checker_bundle_hash if task else None post_submit_policy_hash = task.locked_post_submit_checker_policy_hash if task else None - post_submit_policy_body = dict(task.locked_post_submit_checker_policy_body or {}) if task else {} + post_submit_policy_body = ( + dict(task.locked_post_submit_checker_policy_body or {}) if task else {} + ) assert task is not None assert task.status == "needs_revision" assert v1_submission is not None assert v1_submission.version == 1 assert v1_submission.package_hash == "sha256:package-v1" - assert ("submitted", "evaluation_pending") in { - (event.from_status, event.to_status) for event in gate_events - } - assert ("evaluation_pending", "needs_revision") in { - (event.from_status, event.to_status) for event in gate_events - } + gate_transitions = {f"{event.from_status}->{event.to_status}" for event in gate_events} + assert "submitted->evaluation_pending" in gate_transitions + assert "evaluation_pending->needs_revision" in gate_transitions v1_revision_events = [ event for event in gate_events if event.event_type == "pre_review_gate_needs_revision" ] @@ -2087,7 +2118,9 @@ async def test_checker_caused_revision_resubmits_fixed_version_through_api( ( await session.execute( select(AuditEvent) - .where(AuditEvent.entity_type == "task", AuditEvent.entity_id == started_task["id"]) + .where( + AuditEvent.entity_type == "task", AuditEvent.entity_id == started_task["id"] + ) .order_by(AuditEvent.created_at) ) ) @@ -2106,15 +2139,10 @@ async def test_checker_caused_revision_resubmits_fixed_version_through_api( assert persisted_v2 is not None assert persisted_v2.version == 2 assert persisted_v2.supersedes_submission_id == persisted_v1.id - assert ("needs_revision", "submitted") in { - (event.from_status, event.to_status) for event in task_events - } - assert ("submitted", "evaluation_pending") in { - (event.from_status, event.to_status) for event in task_events - } - assert ("evaluation_pending", "review_pending") in { - (event.from_status, event.to_status) for event in task_events - } + task_transitions = {f"{event.from_status}->{event.to_status}" for event in task_events} + assert "needs_revision->submitted" in task_transitions + assert "submitted->evaluation_pending" in task_transitions + assert "evaluation_pending->review_pending" in task_transitions async def test_chunk8_task_setup_blocked_takes_priority_over_worker_revision( @@ -2164,7 +2192,9 @@ async def test_chunk8_task_setup_blocked_takes_priority_over_worker_revision( assert created.status_code == 201, created.text set_dev_actor(monkeypatch, roles="project_manager", subject="project-manager-subject") - _, body = await get_submission_and_automatic_pre_review_run(checker_client, created.json()["id"]) + _, body = await get_submission_and_automatic_pre_review_run( + checker_client, created.json()["id"] + ) assert body["routing_recommendation"] == "task_setup_blocked" assert body["outcome_source"] == "auto_checker" setup_result = next( @@ -2352,10 +2382,14 @@ async def test_chunk10_checker_trial_runs_sample_submissions_through_real_api( assert target_result["status"] == case["checker_status"] async with db_session.get_session_factory()() as session: submissions = ( - await session.execute( - select(Submission).where(Submission.task_id == started_task["id"]) + ( + await session.execute( + select(Submission).where(Submission.task_id == started_task["id"]) + ) ) - ).scalars().all() + .scalars() + .all() + ) task = await session.get(WorkstreamTask, started_task["id"]) assert submissions == [] assert task is not None @@ -2767,7 +2801,9 @@ async def test_stale_locked_submission_cannot_receive_checker_run( assert stale_run.status_code == 409 assert "latest submission" in stale_run.json()["detail"] - _, second_run = await get_submission_and_automatic_pre_review_run(checker_client, second.json()["id"]) + _, second_run = await get_submission_and_automatic_pre_review_run( + checker_client, second.json()["id"] + ) assert second_run["submission_version"] == 2 assert second_run["trigger_source"] == "submission_finalized" assert second_run["routing_recommendation"] == "allow_review" @@ -2778,10 +2814,10 @@ async def test_stale_locked_submission_cannot_receive_checker_run( async with db_session.get_session_factory()() as session: submissions = ( - await session.execute( - select(Submission).where(Submission.id == first.json()["id"]) - ) - ).scalars().all() + (await session.execute(select(Submission).where(Submission.id == first.json()["id"]))) + .scalars() + .all() + ) assert submissions[0].version == 1 diff --git a/backend/tests/test_database_reset.py b/backend/tests/test_database_reset.py new file mode 100644 index 00000000..ef6e7536 --- /dev/null +++ b/backend/tests/test_database_reset.py @@ -0,0 +1,463 @@ +from __future__ import annotations + +import ast +import asyncio +from collections.abc import Awaitable, Callable +import os +from pathlib import Path +import subprocess +import sys +import time +from unittest.mock import AsyncMock, Mock + +import asyncpg # type: ignore[import-not-found] +import pytest # type: ignore[import-not-found] + +from conftest import ( + PROTECTED_TEST_TABLES, + RESETTABLE_TEST_TABLES, + TRUNCATE_GUARDED_TABLES, + _assert_owned_test_database, + _verify_test_database_schema, + clean_postgres_database as clean_postgres_database_fixture, +) + + +def test_alembic_schema_mutators_declare_schema_ownership() -> None: + """Guide direct Alembic revision movers to schema-owning cleanup.""" + unmarked: list[str] = [] + for test_path in sorted(Path(__file__).parent.glob("test_*.py")): + tree = ast.parse(test_path.read_text(encoding="utf-8")) + module_marked = any( + isinstance(node, (ast.Assign, ast.AnnAssign)) + and "pytestmark" in ast.unparse(node) + and "postgres_schema_contract" in ast.unparse(node) + for node in tree.body + ) + for node in tree.body: + if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) or not node.name.startswith( + "test_" + ): + continue + moves_revision = any( + isinstance(child, ast.Attribute) + and isinstance(child.value, ast.Name) + and child.value.id == "command" + and child.attr in {"downgrade", "stamp", "upgrade"} + for child in ast.walk(node) + ) + function_marked = any( + "postgres_schema_contract" in ast.unparse(decorator) + for decorator in node.decorator_list + ) + if moves_revision and not (module_marked or function_marked): + unmarked.append(f"{test_path.name}::{node.name}") + + assert unmarked == [] + + +@pytest.mark.postgres_schema_contract +def test_unmarked_fixture_teardown_rejects_hidden_schema_mutation( + postgres_database_url: str, + clean_postgres_database: str, +) -> None: + """Runtime teardown catches schema mutation hidden behind an arbitrary helper.""" + del clean_postgres_database + request = Mock() + request.node.get_closest_marker.return_value = None + fixture = clean_postgres_database_fixture.__wrapped__( + postgres_database_url, + request, + ) + assert next(fixture) == postgres_database_url + + async def hidden_mutation() -> None: + connection = await asyncpg.connect(postgres_database_url.replace("+asyncpg", "")) + try: + await connection.execute('create collation hidden_reset_drift from "C"') + finally: + await connection.close() + + asyncio.run(hidden_mutation()) + with pytest.raises(RuntimeError, match="unexpected public schema object fingerprint"): + next(fixture) + asyncio.run(_verify_test_database_schema(postgres_database_url)) + + +async def _protected_state(connection: asyncpg.Connection) -> tuple[str, str | None]: + head = await connection.fetchval("select version_num from alembic_version") + migration = await connection.fetchval( + "select row_to_json(state)::text from actor_profile_migration_state state" + ) + return head, migration + + +async def _assert_guards_enabled(connection: asyncpg.Connection) -> None: + rows = await connection.fetch( + "select c.relname as table_name, bool_and(t.tgenabled = 'O') as enabled " + "from pg_trigger t join pg_class c on c.oid = t.tgrelid " + "where not t.tgisinternal and c.relname = any($1::text[]) " + "group by c.relname", + list(TRUNCATE_GUARDED_TABLES), + ) + assert {row["table_name"] for row in rows} == set(TRUNCATE_GUARDED_TABLES) + assert all(row["enabled"] is True for row in rows) + + +def test_database_reset_preserves_schema_and_restores_guards( + postgres_database_url: str, + reset_test_database_state: Callable[..., Awaitable[None]], +) -> None: + """The fast reset is complete, repeatable, and schema preserving.""" + + async def exercise() -> None: + url = postgres_database_url.replace("+asyncpg", "") + connection = await asyncpg.connect(url) + try: + protected_before = await _protected_state(connection) + actual_tables = set( + await connection.fetchval( + "select array_agg(tablename order by tablename) " + "from pg_tables where schemaname = 'public'" + ) + ) + await connection.execute( + "insert into api_rate_control_counters " + "(control_scope, key_digest, window_started_at, window_expires_at, " + "request_count, updated_at) values " + "('first_access', decode(repeat('00', 32), 'hex'), " + "clock_timestamp(), clock_timestamp() + interval '1 minute', 1, " + "clock_timestamp())" + ) + finally: + await connection.close() + + assert actual_tables == set(PROTECTED_TEST_TABLES) | set(RESETTABLE_TEST_TABLES) + await reset_test_database_state(postgres_database_url) + await reset_test_database_state(postgres_database_url) + + connection = await asyncpg.connect(url) + try: + control = await connection.fetchrow( + "select bootstrap_completed, bootstrap_grant_id, version " + "from authority_control" + ) + counter_count = await connection.fetchval( + "select count(*) from api_rate_control_counters" + ) + assert await _protected_state(connection) == protected_before + await _assert_guards_enabled(connection) + finally: + await connection.close() + + assert dict(control) == { + "bootstrap_completed": False, + "bootstrap_grant_id": None, + "version": 0, + } + assert counter_count == 0 + + asyncio.run(exercise()) + + +@pytest.mark.parametrize( + "database_url", + ( + "postgresql+asyncpg://postgres:x@localhost/postgres", + "postgresql+asyncpg://workstream:x@localhost/workstream", + "postgresql+asyncpg://workstream_role_aaaaaaaaaaaa:x@localhost/" + "workstream_test_bbbbbbbbbbbb", + "postgresql://workstream_role_aaaaaaaaaaaa:x@localhost/" + "workstream_test_aaaaaaaaaaaa", + "postgresql+asyncpg://workstream_role_aaaaaaaaaaaa:x@example.test/" + "workstream_test_aaaaaaaaaaaa", + "postgresql+asyncpg://workstream_role_aaaaaaaaaaaa:x@localhost/" + "workstream_test_aaaaaaaaaaaa?ssl=require", + "postgresql+asyncpg://workstream_role_aaaaaaaaaaaa:x@localhost/" + "workstream_test_aaaaaaaaaaaa#fragment", + ), +) +def test_database_reset_rejects_non_runner_urls(database_url: str) -> None: + """Admin, default, mismatched, and non-asyncpg URLs fail before a query.""" + connection = AsyncMock() + with pytest.raises(RuntimeError, match="unsafe test database target"): + asyncio.run(_assert_owned_test_database(connection, database_url)) + connection.fetchrow.assert_not_awaited() + + +@pytest.mark.parametrize( + ("override", "expected"), + ( + ({"database_name": "workstream_test_bbbbbbbbbbbb"}, "custody"), + ({"session_role": "workstream_role_bbbbbbbbbbbb"}, "custody"), + ({"owner_role": "workstream_role_bbbbbbbbbbbb"}, "custody"), + ({"rolsuper": True}, "custody"), + ({"rolcreatedb": True}, "custody"), + ({"rolcreaterole": True}, "custody"), + ({"rolinherit": True}, "custody"), + ({"rolreplication": True}, "custody"), + ({"rolbypassrls": True}, "custody"), + ({"membership_count": 1}, "custody"), + ), +) +def test_database_reset_rejects_invalid_live_custody( + override: dict[str, object], expected: str +) -> None: + """The connected database, owner, role, and privileges are authoritative.""" + custody: dict[str, object] = { + "database_name": "workstream_test_aaaaaaaaaaaa", + "session_role": "workstream_role_aaaaaaaaaaaa", + "owner_role": "workstream_role_aaaaaaaaaaaa", + "rolsuper": False, + "rolcreatedb": False, + "rolcreaterole": False, + "rolinherit": False, + "rolreplication": False, + "rolbypassrls": False, + "membership_count": 0, + } + custody.update(override) + connection = AsyncMock() + connection.fetchrow.return_value = custody + with pytest.raises(RuntimeError, match=expected): + asyncio.run( + _assert_owned_test_database( + connection, + "postgresql+asyncpg://workstream_role_aaaaaaaaaaaa:x@localhost/" + "workstream_test_aaaaaaaaaaaa", + ) + ) + + +def test_database_reset_rejects_unexpected_table_before_mutation( + postgres_database_url: str, + reset_test_database_state: Callable[..., Awaitable[None]], +) -> None: + """Schema drift fails before guarded triggers or existing rows are touched.""" + + async def exercise() -> None: + url = postgres_database_url.replace("+asyncpg", "") + connection = await asyncpg.connect(url) + try: + await connection.execute("create table unexpected_reset_target (id integer)") + await connection.execute("insert into unexpected_reset_target values (1)") + with pytest.raises(RuntimeError, match="unexpected=unexpected_reset_target"): + await reset_test_database_state(postgres_database_url) + assert await connection.fetchval( + "select count(*) from unexpected_reset_target" + ) == 1 + await _assert_guards_enabled(connection) + finally: + await connection.execute("drop table if exists unexpected_reset_target") + await connection.close() + + asyncio.run(exercise()) + + +def test_database_reset_rejects_unexpected_non_table_object( + postgres_database_url: str, + reset_test_database_state: Callable[..., Awaitable[None]], +) -> None: + """A public function outside the canonical fingerprint blocks reset.""" + + async def exercise() -> None: + url = postgres_database_url.replace("+asyncpg", "") + connection = await asyncpg.connect(url) + try: + await connection.execute( + "create function unexpected_reset_function() returns integer " + "language sql immutable as 'select 1'" + ) + with pytest.raises( + RuntimeError, match="unexpected public schema object fingerprint" + ): + await reset_test_database_state(postgres_database_url) + assert await connection.fetchval("select unexpected_reset_function()") == 1 + await _assert_guards_enabled(connection) + finally: + await connection.execute("drop function if exists unexpected_reset_function()") + await connection.close() + + asyncio.run(exercise()) + + +@pytest.mark.parametrize( + ("create_sql", "exists_sql", "drop_sql"), + ( + ( + "create type unexpected_reset_composite as (value integer)", + "select exists (select 1 from pg_type t join pg_namespace n " + "on n.oid = t.typnamespace where n.nspname = 'public' " + "and t.typname = 'unexpected_reset_composite')", + "drop type if exists unexpected_reset_composite", + ), + ( + 'create collation unexpected_reset_collation from "C"', + "select exists (select 1 from pg_collation c join pg_namespace n " + "on n.oid = c.collnamespace where n.nspname = 'public' " + "and c.collname = 'unexpected_reset_collation')", + "drop collation if exists unexpected_reset_collation", + ), + ( + "alter table api_rate_control_counters " + "add column unexpected_reset_column integer", + "select exists (select 1 from information_schema.columns " + "where table_schema = 'public' " + "and table_name = 'api_rate_control_counters' " + "and column_name = 'unexpected_reset_column')", + "alter table api_rate_control_counters " + "drop column if exists unexpected_reset_column", + ), + ( + "create trigger unexpected_reset_trigger before truncate " + "on api_rate_control_counters for each statement " + "execute function reject_audit_event_mutation()", + "select exists (select 1 from pg_trigger " + "where tgname = 'unexpected_reset_trigger')", + "drop trigger if exists unexpected_reset_trigger " + "on api_rate_control_counters", + ), + ), +) +def test_database_reset_rejects_structural_schema_drift( + postgres_database_url: str, + reset_test_database_state: Callable[..., Awaitable[None]], + create_sql: str, + exists_sql: str, + drop_sql: str, +) -> None: + """Types, collations, columns, and triggers outside the schema block reset.""" + + async def exercise() -> None: + url = postgres_database_url.replace("+asyncpg", "") + connection = await asyncpg.connect(url) + try: + await connection.execute(create_sql) + with pytest.raises( + RuntimeError, match="unexpected public schema object fingerprint" + ): + await reset_test_database_state(postgres_database_url) + assert await connection.fetchval(exists_sql) is True + await _assert_guards_enabled(connection) + finally: + await connection.execute(drop_sql) + await connection.close() + + asyncio.run(exercise()) + + +@pytest.mark.parametrize("failure", (RuntimeError("injected"), asyncio.CancelledError())) +def test_database_reset_rolls_back_after_trigger_disable( + postgres_database_url: str, + reset_test_database_state: Callable[..., Awaitable[None]], + failure: BaseException, +) -> None: + """Exceptions and cancellation roll back trigger and protected-state changes.""" + + async def exercise() -> None: + url = postgres_database_url.replace("+asyncpg", "") + digest_octet = "11" if isinstance(failure, RuntimeError) else "12" + connection = await asyncpg.connect(url) + try: + protected_before = await _protected_state(connection) + await connection.execute( + "insert into api_rate_control_counters " + "(control_scope, key_digest, window_started_at, window_expires_at, " + "request_count, updated_at) values " + f"('first_access', decode(repeat('{digest_octet}', 32), 'hex'), " + "clock_timestamp(), clock_timestamp() + interval '1 minute', 1, " + "clock_timestamp())" + ) + finally: + await connection.close() + + async def fail() -> None: + raise failure + + with pytest.raises(type(failure)): + await reset_test_database_state(postgres_database_url, after_disable=fail) + + connection = await asyncpg.connect(url) + try: + assert await _protected_state(connection) == protected_before + assert await connection.fetchval( + "select count(*) from api_rate_control_counters " + f"where key_digest = decode(repeat('{digest_octet}', 32), 'hex')" + ) == 1 + await _assert_guards_enabled(connection) + finally: + await connection.close() + + asyncio.run(exercise()) + + +def test_database_reset_signal_rolls_back_disabled_triggers( + postgres_database_url: str, +) -> None: + """Terminating a real reset process leaves PostgreSQL trigger state enabled.""" + marker = Path(os.environ["PYTEST_TMPDIR"]) / "reset-disabled" if os.environ.get( + "PYTEST_TMPDIR" + ) else Path("/tmp") / f"workstream-reset-disabled-{os.getpid()}" + marker.unlink(missing_ok=True) + + async def seed() -> tuple[str, str | None]: + connection = await asyncpg.connect(postgres_database_url.replace("+asyncpg", "")) + try: + protected = await _protected_state(connection) + await connection.execute( + "insert into api_rate_control_counters " + "(control_scope, key_digest, window_started_at, window_expires_at, " + "request_count, updated_at) values " + "('first_access', decode(repeat('22', 32), 'hex'), " + "clock_timestamp(), clock_timestamp() + interval '1 minute', 1, " + "clock_timestamp())" + ) + return protected + finally: + await connection.close() + + protected_before = asyncio.run(seed()) + child_code = """ +import asyncio +import os +from pathlib import Path +import runpy + +reset = runpy.run_path("tests/conftest.py")["_reset_test_database_state"] + +async def pause(): + Path(os.environ["RESET_MARKER"]).write_text("ready", encoding="utf-8") + await asyncio.sleep(3600) + +asyncio.run(reset(os.environ["WORKSTREAM_TEST_DATABASE_URL"], after_disable=pause)) +""" + child_env = dict(os.environ) + child_env["WORKSTREAM_TEST_DATABASE_URL"] = postgres_database_url + child_env["RESET_MARKER"] = str(marker) + process = subprocess.Popen([sys.executable, "-c", child_code], env=child_env) + try: + deadline = time.monotonic() + 15 + while not marker.exists() and process.poll() is None and time.monotonic() < deadline: + time.sleep(0.05) + assert marker.exists(), "reset child did not reach the disabled-trigger checkpoint" + process.terminate() + process.wait(timeout=10) + + async def verify() -> None: + connection = await asyncpg.connect(postgres_database_url.replace("+asyncpg", "")) + try: + assert await _protected_state(connection) == protected_before + assert await connection.fetchval( + "select count(*) from api_rate_control_counters " + "where key_digest = decode(repeat('22', 32), 'hex')" + ) == 1 + await _assert_guards_enabled(connection) + finally: + await connection.close() + + asyncio.run(verify()) + finally: + if process.poll() is None: + process.kill() + process.wait(timeout=10) + marker.unlink(missing_ok=True) diff --git a/backend/tests/test_outbox.py b/backend/tests/test_outbox.py index 933fdc8c..355f646b 100644 --- a/backend/tests/test_outbox.py +++ b/backend/tests/test_outbox.py @@ -8,13 +8,15 @@ from typing import Any, cast from uuid import UUID, uuid4 -import pytest -from alembic import command -from alembic.config import Config +import pytest # type: ignore[import-not-found] from pydantic import TypeAdapter -from sqlalchemy import text +from sqlalchemy import insert, text from sqlalchemy.exc import DBAPIError -from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.ext.asyncio import ( # type: ignore[import-not-found] + AsyncSession, + async_sessionmaker, + create_async_engine, +) from app.modules.outbox.schemas import ( OutboxAppendDisposition, @@ -25,24 +27,13 @@ ) from app.modules.outbox.repository import OutboxRepository from app.modules.outbox.service import OutboxService +from app.modules.projects.models import Project from tests.assertion_helpers import assert_secret_not_retained -def _alembic_config() -> Config: - backend_root = Path(__file__).resolve().parents[1] - config = Config(str(backend_root / "alembic.ini")) - config.set_main_option("script_location", str(backend_root / "alembic")) - return config - - @pytest.fixture -def outbox_database_env( - isolated_database_env: str, - migration_lock, -) -> str: - """Upgrade the isolated database to the exact shared-outbox head.""" - with migration_lock(): - command.upgrade(_alembic_config(), "head") +def outbox_database_env(isolated_database_env: str) -> str: + """Use the runner-migrated database and shared per-test reset.""" return isolated_database_env @@ -56,11 +47,13 @@ async def outbox_factory( project_id = uuid4() async with engine.begin() as connection: await connection.execute( - text( - "insert into projects(id, name, slug, status) " - "values (:id, 'Outbox test', :slug, 'active')" - ), - {"id": str(project_id), "slug": f"outbox-{project_id}"}, + insert(Project), + { + "id": str(project_id), + "name": "Outbox test", + "slug": f"outbox-{project_id}", + "status": "active", + }, ) try: yield factory, project_id @@ -312,26 +305,22 @@ def test_outbox_validation_entry_points_preserve_valid_modes() -> None: expected = _event(uuid4(), payload={"marker": "safe"}) adapter = TypeAdapter(OutboxAppendInput) python_value = OutboxAppendInput.model_validate(expected.model_dump()) - json_value = OutboxAppendInput.model_validate_json( - json.dumps(expected.model_dump(mode="json")) - ) + json_value = OutboxAppendInput.model_validate_json(json.dumps(expected.model_dump(mode="json"))) strings = expected.model_dump(mode="json") strings["event_version"] = str(strings["event_version"]) string_value = OutboxAppendInput.model_validate_strings(strings) adapter_python = adapter.validate_python(expected.model_dump()) adapter_json = adapter.validate_json(json.dumps(expected.model_dump(mode="json"))) adapter_strings = adapter.validate_strings(strings) - assert all( - value == expected - for value in ( - python_value, - json_value, - string_value, - adapter_python, - adapter_json, - adapter_strings, - ) + validated = ( + python_value, + json_value, + string_value, + adapter_python, + adapter_json, + adapter_strings, ) + assert all(value == expected for value in validated) @pytest.mark.asyncio @@ -351,9 +340,7 @@ async def test_outbox_payload_depth_nodes_members_and_budget_are_bounded() -> No ) for payload in cases: with pytest.raises(OutboxInputError, match="^outbox_invalid_input$"): - await OutboxService(cast(AsyncSession, None)).append( - _unsafe_event(project_id, payload) - ) + await OutboxService(cast(AsyncSession, None)).append(_unsafe_event(project_id, payload)) @pytest.mark.asyncio @@ -432,18 +419,16 @@ async def test_outbox_insert_trigger_rejects_preforged_operational_state( assert row.occurred_at.year >= 2026 assert row.delivery_state == "pending" assert row.attempt_count == row.claim_generation == 0 - assert all( - value is None - for value in ( - row.claim_owner, - row.claimed_at, - row.claim_expires_at, - row.last_attempt_at, - row.last_error_code, - row.finalized_at, - row.archived_at, - ) + terminal_fields = ( + row.claim_owner, + row.claimed_at, + row.claim_expires_at, + row.last_attempt_at, + row.last_error_code, + row.finalized_at, + row.archived_at, ) + assert all(value is None for value in terminal_fields) @pytest.mark.asyncio @@ -455,16 +440,22 @@ async def test_outbox_caller_rollback_removes_flushed_event( async with factory() as session: transaction = await session.begin() await OutboxService(session).append(value) - assert await session.scalar( - text("select count(*) from outbox_events where event_id=:id"), - {"id": value.event_id}, - ) == 1 + assert ( + await session.scalar( + text("select count(*) from outbox_events where event_id=:id"), + {"id": value.event_id}, + ) + == 1 + ) await transaction.rollback() async with factory() as observer: - assert await observer.scalar( - text("select count(*) from outbox_events where event_id=:id"), - {"id": value.event_id}, - ) == 0 + assert ( + await observer.scalar( + text("select count(*) from outbox_events where event_id=:id"), + {"id": value.event_id}, + ) + == 0 + ) @pytest.mark.asyncio @@ -492,10 +483,13 @@ async def fail_after_reservation( await OutboxService(session).append(value) await transaction.rollback() async with factory() as observer: - assert await observer.scalar( - text("select count(*) from outbox_events where event_id=:id"), - {"id": value.event_id}, - ) == 0 + assert ( + await observer.scalar( + text("select count(*) from outbox_events where event_id=:id"), + {"id": value.event_id}, + ) + == 0 + ) @pytest.mark.asyncio @@ -556,8 +550,7 @@ async def blocked_reserve( row = ( await session.execute( text( - "select payload, payload_digest from outbox_events " - "where event_id=:event_id" + "select payload, payload_digest from outbox_events where event_id=:event_id" ), {"event_id": value.event_id}, ) @@ -647,9 +640,7 @@ async def test_outbox_conflict_does_not_retain_stored_payload( async with factory() as session: async with session.begin(): await OutboxService(session).append(value) - drift = OutboxAppendInput( - **{**value.model_dump(), "payload": {"detail": "changed"}} - ) + drift = OutboxAppendInput(**{**value.model_dump(), "payload": {"detail": "changed"}}) async with factory() as session: async with session.begin(): with pytest.raises( diff --git a/backend/tests/test_projects.py b/backend/tests/test_projects.py index 148d6a29..1b8e7586 100644 --- a/backend/tests/test_projects.py +++ b/backend/tests/test_projects.py @@ -8,12 +8,10 @@ import types from collections.abc import AsyncIterator, Iterator from datetime import UTC, datetime -from pathlib import Path +from typing import Any, cast from uuid import uuid4 -import pytest -from alembic import command -from alembic.config import Config +import pytest # type: ignore[import-not-found] from httpx import ASGITransport, AsyncClient from sqlalchemy import select, update from sqlalchemy.dialects import postgresql @@ -85,11 +83,9 @@ @pytest.fixture def project_database_env( monkeypatch: pytest.MonkeyPatch, - postgres_database_url: str, - migration_lock, - reset_test_database_state, + clean_postgres_database: str, ) -> Iterator[str]: - monkeypatch.setenv("WORKSTREAM_DATABASE_URL", postgres_database_url) + monkeypatch.setenv("WORKSTREAM_DATABASE_URL", clean_postgres_database) monkeypatch.setenv( "WORKSTREAM_API_RATE_LIMIT_KEY_SECRET", "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=", @@ -103,30 +99,10 @@ def project_database_env( monkeypatch.setenv("WORKSTREAM_PROJECT_SETUP_PIPELINE_AUTOSTART", "false") monkeypatch.setenv("WORKSTREAM_CELERY_BROKER_URL", "memory://") get_settings.cache_clear() - asyncio.run(db_session.dispose_engine()) - - project_root = Path(__file__).resolve().parents[1] - config = Config(str(project_root / "alembic.ini")) - config.set_main_option("script_location", str(project_root / "alembic")) try: - with migration_lock(): - command.downgrade(config, "base") - try: - command.upgrade(config, "head") - yield postgres_database_url - finally: - try: - asyncio.run(reset_test_database_state(postgres_database_url)) - finally: - try: - asyncio.run(db_session.dispose_engine()) - finally: - command.downgrade(config, "base") + yield clean_postgres_database finally: - try: - asyncio.run(db_session.dispose_engine()) - finally: - get_settings.cache_clear() + get_settings.cache_clear() @pytest.fixture @@ -406,9 +382,7 @@ def test_policy_models_bind_to_denormalized_policy_hashes() -> None: ), ] - for model, constraint_name, local_columns, target_table, target_columns in ( - expected_constraints - ): + for model, constraint_name, local_columns, target_table, target_columns in expected_constraints: constraint = next( constraint for constraint in model.__table__.foreign_key_constraints @@ -422,6 +396,7 @@ def test_policy_models_bind_to_denormalized_policy_hashes() -> None: ] assert [element.column.name for element in constraint.elements] == target_columns + def test_policy_hash_pairs_are_unique_fk_targets() -> None: expected_constraints = { PostSubmitCheckerPolicy: "uq_checker_policies_id_version_hash", @@ -563,9 +538,7 @@ async def test_project_route_registers_project_manager_actor_without_auth_me( async with db_session.get_session_factory()() as session: identity_link = await session.scalar( - select(ActorIdentityLink).where( - ActorIdentityLink.subject == "project-manager-subject" - ) + select(ActorIdentityLink).where(ActorIdentityLink.subject == "project-manager-subject") ) assert identity_link is not None profile = await session.get(ActorProfile, identity_link.actor_profile_id) @@ -667,7 +640,9 @@ def capture_enqueue( ).all() policies = ( await session.scalars( - select(SubmissionArtifactPolicy).where(SubmissionArtifactPolicy.guide_id == guide["id"]) + select(SubmissionArtifactPolicy).where( + SubmissionArtifactPolicy.guide_id == guide["id"] + ) ) ).all() setup_runs = ( @@ -701,7 +676,10 @@ def test_project_setup_queue_syncs_all_setup_task_settings( ) from app.workers.task_settings import sync_task_settings - tasks = (run_pre_submit_setup_pipeline, run_post_submit_setup_continuation) + tasks = tuple( + cast(Any, task) + for task in (run_pre_submit_setup_pipeline, run_post_submit_setup_continuation) + ) original_config = { task: { "broker_url": task.app.conf.broker_url, @@ -1280,9 +1258,7 @@ async def create_generated_post_submit_setup_output( effective_policy_id=pre_submit_checker_policy["effective_policy_id"], effective_policy_hash=pre_submit_checker_policy["effective_policy_hash"], pre_submit_checker_policy_id=pre_submit_checker_policy["id"], - pre_submit_checker_bundle_hash=pre_submit_checker_policy[ - "compiled_bundle_hash" - ], + pre_submit_checker_bundle_hash=pre_submit_checker_policy["compiled_bundle_hash"], required_checkers=compiled.required_checkers, warning_checkers=compiled.warning_checkers, blocking_severities=compiled.blocking_severities, @@ -1347,8 +1323,7 @@ def test_project_setup_run_status_constraint_metadata() -> None: status_constraint = next( constraint for constraint in ProjectSetupRun.__table__.constraints - if constraint.name is not None - and constraint.name.endswith("ck_project_setup_runs_status") + if constraint.name is not None and constraint.name.endswith("ck_project_setup_runs_status") ) constraint_sql = str(status_constraint.sqltext) @@ -1523,8 +1498,7 @@ async def test_project_setup_visibility_apis_show_automatic_setup_outputs( }, ) second_setup_response = await project_client.get( - f"/api/v1/projects/{second_project['id']}/guides/{second_guide['id']}/" - "setup-runs/latest", + f"/api/v1/projects/{second_project['id']}/guides/{second_guide['id']}/setup-runs/latest", headers=auth_headers(), ) assert second_setup_response.status_code == 200, second_setup_response.text @@ -1607,18 +1581,18 @@ async def test_project_setup_visibility_apis_show_automatic_setup_outputs( "setup-runs/latest", headers=auth_headers(), ) - assert ( - same_project_other_setup_response.status_code == 200 - ), same_project_other_setup_response.text + assert same_project_other_setup_response.status_code == 200, ( + same_project_other_setup_response.text + ) same_project_other_setup_run = same_project_other_setup_response.json() same_project_other_policies_response = await project_client.get( f"/api/v1/projects/{project['id']}/guides/{same_project_other_guide['id']}/" "submission-artifact-policies", headers=auth_headers(), ) - assert ( - same_project_other_policies_response.status_code == 200 - ), same_project_other_policies_response.text + assert same_project_other_policies_response.status_code == 200, ( + same_project_other_policies_response.text + ) same_project_other_policy = same_project_other_policies_response.json()[0] same_project_other_effective = await approve_submission_artifact_policy( project_client, @@ -1630,9 +1604,9 @@ async def test_project_setup_visibility_apis_show_automatic_setup_outputs( f"/api/v1/projects/{project['id']}/guides/{guide['id']}/setup-runs/latest", headers=auth_headers(), ) - assert ( - first_setup_after_same_project_response.status_code == 200 - ), first_setup_after_same_project_response.text + assert first_setup_after_same_project_response.status_code == 200, ( + first_setup_after_same_project_response.text + ) assert first_setup_after_same_project_response.json()["id"] == setup_run["id"] wrong_same_project_report_response = await project_client.get( f"/api/v1/projects/{project['id']}/guides/{guide['id']}/sufficiency-reports/" @@ -1651,18 +1625,18 @@ async def test_project_setup_visibility_apis_show_automatic_setup_outputs( "effective-submission-artifact-policy", headers=auth_headers(), ) - assert ( - same_project_other_effective_response.status_code == 200 - ), same_project_other_effective_response.text + assert same_project_other_effective_response.status_code == 200, ( + same_project_other_effective_response.text + ) assert same_project_other_effective_response.json()["id"] == same_project_other_effective["id"] same_project_other_checker_response = await project_client.get( f"/api/v1/projects/{project['id']}/guides/{same_project_other_guide['id']}/" "pre-submit-checker-policy", headers=auth_headers(), ) - assert ( - same_project_other_checker_response.status_code == 200 - ), same_project_other_checker_response.text + assert same_project_other_checker_response.status_code == 200, ( + same_project_other_checker_response.text + ) assert ( same_project_other_checker_response.json()["effective_policy_id"] == same_project_other_effective["id"] @@ -1766,9 +1740,7 @@ async def derive_post_submit_checker_policy( ) assert resumed["post_submit_derivation_summary"]["setup_note_count"] == 1 assert "setup_notes" not in resumed["post_submit_derivation_summary"] - assert "spoofed_runtime_agent" not in json.dumps( - resumed["post_submit_derivation_summary"] - ) + assert "spoofed_runtime_agent" not in json.dumps(resumed["post_submit_derivation_summary"]) assert "sha256:" not in json.dumps(resumed["post_submit_derivation_summary"]) async with db_session.get_session_factory()() as session: post_submit_policy = await session.get( @@ -1871,9 +1843,10 @@ async def derive_post_submit_checker_policy( headers=auth_headers(), ) ).json() - assert rerun["output_post_submit_checker_policy_id"] == compiled[ - "output_post_submit_checker_policy_id" - ] + assert ( + rerun["output_post_submit_checker_policy_id"] + == compiled["output_post_submit_checker_policy_id"] + ) async def test_post_submit_continuation_running_worker_redelivery_resumes_setup( @@ -1956,9 +1929,7 @@ async def derive_post_submit_checker_policy( ) ).json() assert latest["status"] == "post_submit_policy_compiled" - assert latest["output_post_submit_checker_policy_id"] == result[ - "post_submit_checker_policy_id" - ] + assert latest["output_post_submit_checker_policy_id"] == result["post_submit_checker_policy_id"] async def test_corrected_submission_artifact_policy_resumes_post_submit_setup( @@ -2069,9 +2040,10 @@ async def derive_post_submit_checker_policy( ).json() assert after_stale["status"] == "post_submit_policy_compiled" assert after_stale["output_submission_artifact_policy_id"] == manual_policy["id"] - assert after_stale["output_post_submit_checker_policy_id"] == resumed[ - "output_post_submit_checker_policy_id" - ] + assert ( + after_stale["output_post_submit_checker_policy_id"] + == resumed["output_post_submit_checker_policy_id"] + ) async with db_session.get_session_factory()() as session: stale_policy = await session.get(PostSubmitCheckerPolicy, first_post_submit_policy_id) replacement_policy = await session.get( @@ -2087,8 +2059,7 @@ async def derive_post_submit_checker_policy( assert replacement_policy is not None assert replacement_policy.supersedes_policy_id is None setup_visibility = await project_client.get( - f"/api/v1/projects/{project['id']}/guides/{guide['id']}/" - "post-submit-checker-policy/setup", + f"/api/v1/projects/{project['id']}/guides/{guide['id']}/post-submit-checker-policy/setup", headers=auth_headers(), ) assert setup_visibility.status_code == 200 @@ -2261,14 +2232,13 @@ async def test_compiled_post_submit_setup_run_does_not_regress_from_duplicate_wo error_code="PolicySetupBlocked", error_summary="duplicate worker reported an older failure", continuation_effective_policy_id=bundle["effective_policy"]["id"], - continuation_pre_submit_checker_policy_id=bundle["pre_submit_checker_policy"][ - "id" - ], + continuation_pre_submit_checker_policy_id=bundle["pre_submit_checker_policy"]["id"], ) assert response.status == "post_submit_policy_compiled" - assert response.output_post_submit_checker_policy_id == setup_run[ - "output_post_submit_checker_policy_id" - ] + assert ( + response.output_post_submit_checker_policy_id + == setup_run["output_post_submit_checker_policy_id"] + ) latest = await session.get(ProjectSetupRun, setup_run["id"]) assert latest is not None assert latest.status == "post_submit_policy_compiled" @@ -3097,8 +3067,7 @@ async def test_project_setup_run_rejects_cross_context_worker_updates( { **source_snapshot_payload()["items"][0], "durable_ref": "inline:/guides/second/v1", - "content_hash": "sha256:" - + hashlib.sha256(b"second-guide").hexdigest(), + "content_hash": "sha256:" + hashlib.sha256(b"second-guide").hexdigest(), } ], }, @@ -3173,8 +3142,7 @@ async def test_project_setup_visibility_apis_require_project_setup_role( f"/api/v1/projects/{project['id']}/guides/{guide['id']}/" "effective-submission-artifact-policy", f"/api/v1/projects/{project['id']}/guides/{guide['id']}/pre-submit-checker-policy", - f"/api/v1/projects/{project['id']}/guides/{guide['id']}/" - "post-submit-checker-policy/setup", + f"/api/v1/projects/{project['id']}/guides/{guide['id']}/post-submit-checker-policy/setup", ] monkeypatch.setenv("WORKSTREAM_DEV_AUTH_ROLES", "admin") get_settings.cache_clear() @@ -3390,10 +3358,7 @@ async def test_source_snapshot_hash_is_server_computed_and_canonical( guide = await create_guide(project_client, project["id"], complete_guide_payload()) snapshot = await create_source_snapshot(project_client, project["id"], guide["id"]) - guide_material = { - field: guide[field] - for field in sorted(GUIDE_SOURCE_MATERIAL_FIELDS) - } + guide_material = {field: guide[field] for field in sorted(GUIDE_SOURCE_MATERIAL_FIELDS)} expected_manifest = { "schema_version": "guide_source_snapshot.v1", "items": sorted( @@ -4053,7 +4018,9 @@ async def derive_submission_artifact_policy( assert representative_task.content_excerpt == ( "Representative task: solve a STEM prompt and submit a reasoned answer." ) - assert any(item.durable_ref == representative_task.durable_ref for item in material.source_items) + assert any( + item.durable_ref == representative_task.durable_ref for item in material.source_items + ) async def test_source_snapshot_integrity_accepts_v1_manifest_without_content_excerpt( @@ -4127,7 +4094,9 @@ def test_policy_derivation_prompt_prohibits_self_conflicting_policies() -> None: assert "one exact safe relative file path" in instructions assert "must not be directories" in instructions assert "must not contain globs" in instructions - assert "Forbidden artifact patterns may use globs; required artifact paths may not" in instructions + assert ( + "Forbidden artifact patterns may use globs; required artifact paths may not" in instructions + ) def test_post_submit_policy_derivation_prompt_preserves_runtime_boundary() -> None: @@ -4561,7 +4530,9 @@ async def test_derivation_agent_allows_warning_report_without_acknowledgement_an project_client.post(endpoint, headers=auth_headers()), ) - assert inspect.iscoroutinefunction(ProjectService.run_submission_artifact_policy_derivation_agent) + assert inspect.iscoroutinefunction( + ProjectService.run_submission_artifact_policy_derivation_agent + ) assert {first.status_code, second.status_code} == {200, 201} assert first.json()["id"] == second.json()["id"] assert first.json()["source_snapshot_id"] == snapshot["id"] @@ -5059,8 +5030,9 @@ async def test_submission_artifact_policy_approval_persists_effective_policy_has assert pre_submit_checker_policy.effective_policy_hash == effective["effective_policy_hash"] assert pre_submit_checker_policy.compiler_version == "workstream-pre-submit-compiler-v0.1" assert pre_submit_checker_policy.compiled_bundle_hash is not None - assert pre_submit_checker_policy.compiled_bundle["effective_policy_hash"] == ( - effective["effective_policy_hash"] + assert ( + pre_submit_checker_policy.compiled_bundle["effective_policy_hash"] + == (effective["effective_policy_hash"]) ) assert "require_file" in pre_submit_checker_policy.checker_configs @@ -5258,7 +5230,9 @@ async def test_approved_submission_artifact_policy_is_immutable( guide["id"], snapshot["id"], ) - await approve_submission_artifact_policy(project_client, project["id"], guide["id"], policy["id"]) + await approve_submission_artifact_policy( + project_client, project["id"], guide["id"], policy["id"] + ) response = await project_client.patch( f"/api/v1/projects/{project['id']}/guides/{guide['id']}/submission-artifact-policies/" @@ -5302,9 +5276,7 @@ async def test_draft_submission_artifact_policy_can_be_updated( assert updated["id"] == policy["id"] assert updated["lifecycle_status"] == "draft" assert updated["policy_hash"] != policy["policy_hash"] - assert updated["policy_body"]["required_artifacts"][0]["path"] == ( - "outputs/final-answer.md" - ) + assert updated["policy_body"]["required_artifacts"][0]["path"] == ("outputs/final-answer.md") assert updated["change_summary"] == "Use final answer artifact path." @@ -5397,9 +5369,9 @@ async def test_approving_replacement_policy_supersedes_prior_rows( assert second_persisted.supersedes_policy_id == first_persisted.id assert first_effective_persisted.lifecycle_status == "superseded" assert first_effective_persisted.superseded_at is not None - assert first_effective_persisted.effective_policy_hash == first_effective[ - "effective_policy_hash" - ] + assert ( + first_effective_persisted.effective_policy_hash == first_effective["effective_policy_hash"] + ) assert second_effective_persisted.lifecycle_status == "approved" assert second_effective_persisted.supersedes_effective_policy_id == ( first_effective_persisted.id @@ -5413,9 +5385,7 @@ async def test_approving_replacement_policy_supersedes_prior_rows( ) assert old_pre_submit.superseded_at is not None assert current_pre_submit.effective_policy_id == second_effective_persisted.id - assert current_pre_submit.supersedes_pre_submit_checker_policy_id == ( - old_pre_submit.id - ) + assert current_pre_submit.supersedes_pre_submit_checker_policy_id == (old_pre_submit.id) assert current_policy.id == second_persisted.id assert current_effective.id == second_effective_persisted.id @@ -5510,8 +5480,9 @@ async def test_replacement_policy_requires_complete_prior_effective_context( ) assert response.status_code == 409 - assert "effective project submission artifact policy chain is incomplete" in ( - response.json()["detail"] + assert ( + "effective project submission artifact policy chain is incomplete" + in (response.json()["detail"]) ) @@ -5606,21 +5577,30 @@ async def test_concurrent_policy_approvals_do_not_fork_current_chain( "compiled", "superseded", } - assert len({policy.supersedes_policy_id for policy in policies if policy.supersedes_policy_id}) == 1 - assert len( - { - policy.supersedes_effective_policy_id - for policy in effective_policies - if policy.supersedes_effective_policy_id - } - ) == 1 - assert len( - { - policy.supersedes_pre_submit_checker_policy_id - for policy in pre_submit_policies - if policy.supersedes_pre_submit_checker_policy_id - } - ) == 1 + assert ( + len({policy.supersedes_policy_id for policy in policies if policy.supersedes_policy_id}) + == 1 + ) + assert ( + len( + { + policy.supersedes_effective_policy_id + for policy in effective_policies + if policy.supersedes_effective_policy_id + } + ) + == 1 + ) + assert ( + len( + { + policy.supersedes_pre_submit_checker_policy_id + for policy in pre_submit_policies + if policy.supersedes_pre_submit_checker_policy_id + } + ) + == 1 + ) async def test_material_guide_edit_after_source_snapshot_is_blocked( @@ -5664,7 +5644,9 @@ async def test_activation_rejects_policy_bound_to_stale_source_snapshot( project = await create_project(project_client) guide = await create_guide(project_client, project["id"], complete_guide_payload()) first_snapshot = await create_source_snapshot(project_client, project["id"], guide["id"]) - await create_sufficiency_report(project_client, project["id"], guide["id"], first_snapshot["id"]) + await create_sufficiency_report( + project_client, project["id"], guide["id"], first_snapshot["id"] + ) policy = await create_submission_artifact_policy( project_client, project["id"], @@ -5677,9 +5659,7 @@ async def test_activation_rejects_policy_bound_to_stale_source_snapshot( guide["id"], policy["id"], ) - newer_payload = source_snapshot_payload( - durable_ref="https://docs.flow.test/stem/guide-v2.md" - ) + newer_payload = source_snapshot_payload(durable_ref="https://docs.flow.test/stem/guide-v2.md") newer_response = await project_client.post( f"/api/v1/projects/{project['id']}/guides/{guide['id']}/source-snapshots", headers=auth_headers(), @@ -6088,51 +6068,35 @@ async def test_submission_artifact_policy_rejects_forbidden_required_artifacts( "attestation terms", ), ( - project_submission_artifact_policy_body( - artifact_path="outputs/%2E%2E/secret.txt" - ), + project_submission_artifact_policy_body(artifact_path="outputs/%2E%2E/secret.txt"), "percent-encoded", ), ( - project_submission_artifact_policy_body( - artifact_path="outputs/100%complete.md" - ), + project_submission_artifact_policy_body(artifact_path="outputs/100%complete.md"), "percent-encoded", ), ( - project_submission_artifact_policy_body( - artifact_path="outputs/final\nanswer.md" - ), + project_submission_artifact_policy_body(artifact_path="outputs/final\nanswer.md"), "control characters", ), ( - project_submission_artifact_policy_body( - artifact_path="C:/Users/alice/output.md" - ), + project_submission_artifact_policy_body(artifact_path="C:/Users/alice/output.md"), "safe relative paths", ), ( - project_submission_artifact_policy_body( - artifact_path="C:\\Users\\alice\\output.md" - ), + project_submission_artifact_policy_body(artifact_path="C:\\Users\\alice\\output.md"), "safe relative paths", ), ( - project_submission_artifact_policy_body( - artifact_path="outputs\\final-answer.md" - ), + project_submission_artifact_policy_body(artifact_path="outputs\\final-answer.md"), "local path separators", ), ( - project_submission_artifact_policy_body( - artifact_path="s3:bucket/key.md" - ), + project_submission_artifact_policy_body(artifact_path="s3:bucket/key.md"), "storage refs or URLs", ), ( - project_submission_artifact_policy_body( - artifact_path="file:output.md" - ), + project_submission_artifact_policy_body(artifact_path="file:output.md"), "storage refs or URLs", ), ( @@ -6529,8 +6493,7 @@ async def test_post_submit_setup_visibility_redacts_source_hash_and_policy_body( ) response = await project_client.get( - f"/api/v1/projects/{project['id']}/guides/{guide['id']}/" - "post-submit-checker-policy/setup", + f"/api/v1/projects/{project['id']}/guides/{guide['id']}/post-submit-checker-policy/setup", headers=auth_headers(), ) @@ -6585,8 +6548,7 @@ async def test_post_submit_checker_policy_approval_uses_server_provenance( monkeypatch.setenv("WORKSTREAM_DEV_AUTH_ROLES", "admin") get_settings.cache_clear() retry = await project_client.post( - f"/api/v1/projects/{project['id']}/guides/{guide['id']}/" - "post-submit-checker-policy/approve", + f"/api/v1/projects/{project['id']}/guides/{guide['id']}/post-submit-checker-policy/approve", headers=auth_headers(), json={}, ) @@ -6653,10 +6615,7 @@ def capture_enqueue( "post-submit-checker-policy/request-correction", headers=auth_headers(), json={ - "correction_reason": ( - "Regenerate without sk-" - "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" - ) + "correction_reason": ("Regenerate without sk-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") }, ) @@ -6668,12 +6627,14 @@ def capture_enqueue( correction_summary = body["setup_run"]["post_submit_derivation_summary"] assert correction_summary["status"] == "correction_requested" assert correction_summary["reason"] == "redacted" - assert correction_summary["post_submit_checker_policy_id"] == ( - bundle["post_submit_checker_policy"]["id"] + assert ( + correction_summary["post_submit_checker_policy_id"] + == (bundle["post_submit_checker_policy"]["id"]) ) assert correction_summary["correction_requested_by_role"] == "project_manager" - assert correction_summary["correction_requested_by_actor"] == ( - bundle["submission_artifact_policy"]["created_by"] + assert ( + correction_summary["correction_requested_by_actor"] + == (bundle["submission_artifact_policy"]["created_by"]) ) assert correction_summary["correction_requested_at"] assert body["post_submit_checker_policy"] is None @@ -6681,16 +6642,18 @@ def capture_enqueue( correction_history = body["correction_history"][0] assert correction_history["policy_id"] == bundle["post_submit_checker_policy"]["id"] assert correction_history["policy_hash"] == bundle["post_submit_checker_policy"]["policy_hash"] - assert correction_history["required_checkers"] == bundle["post_submit_checker_policy"][ - "required_checkers" - ] + assert ( + correction_history["required_checkers"] + == bundle["post_submit_checker_policy"]["required_checkers"] + ) assert correction_history["warning_checkers"] == [] assert correction_history["blocking_severities"] == ["critical", "high"] assert correction_history["correction_reason"] == "redacted" assert correction_history["correction_requested_by_role"] == "project_manager" - assert correction_history["correction_requested_by_actor"] == bundle[ - "submission_artifact_policy" - ]["created_by"] + assert ( + correction_history["correction_requested_by_actor"] + == bundle["submission_artifact_policy"]["created_by"] + ) assert correction_history["correction_requested_at"] assert enqueued == [ { @@ -6753,9 +6716,7 @@ async def derive_post_submit_checker_policy( PostSubmitCheckerPolicyReason( checker_name="check_acceptance_criteria_present", rationale="Correction requires explicit acceptance criteria checks.", - evidence_refs=[ - PostSubmitCheckerPolicyEvidenceRef(ref="project_guide") - ], + evidence_refs=[PostSubmitCheckerPolicyEvidenceRef(ref="project_guide")], ) ], unsupported_required_checks=[], @@ -6823,16 +6784,18 @@ async def derive_post_submit_checker_policy( session.add(new_setup_run) await session.commit() new_context_service = ProjectService(session, agent_runtime=NewContextRuntime()) - new_context_policy, created, _ = ( - await new_context_service.run_post_submit_checker_policy_derivation_agent( - project_setup_pipeline_actor(), - project["id"], - guide["id"], - bundle["source_snapshot"]["id"], - next_effective_policy["id"], - next_pre_submit_policy["id"], - new_setup_run.id, - ) + ( + new_context_policy, + created, + _, + ) = await new_context_service.run_post_submit_checker_policy_derivation_agent( + project_setup_pipeline_actor(), + project["id"], + guide["id"], + bundle["source_snapshot"]["id"], + next_effective_policy["id"], + next_pre_submit_policy["id"], + new_setup_run.id, ) assert created is True persisted_new_context_policy = await session.get( @@ -6843,8 +6806,7 @@ async def derive_post_submit_checker_policy( assert persisted_new_context_policy.supersedes_policy_id is None setup_visibility = await project_client.get( - f"/api/v1/projects/{project['id']}/guides/{guide['id']}/" - "post-submit-checker-policy/setup", + f"/api/v1/projects/{project['id']}/guides/{guide['id']}/post-submit-checker-policy/setup", headers=auth_headers(), ) assert setup_visibility.status_code == 200 @@ -6939,7 +6901,9 @@ async def test_database_rejects_post_submit_checker_approved_by_non_setup_role( guide["id"], ) async with db_session.get_session_factory()() as session: - policy = await session.get(PostSubmitCheckerPolicy, bundle["post_submit_checker_policy"]["id"]) + policy = await session.get( + PostSubmitCheckerPolicy, bundle["post_submit_checker_policy"]["id"] + ) assert policy is not None policy.approved_by_role = "worker" with pytest.raises(IntegrityError): @@ -7258,8 +7222,9 @@ async def test_active_guide_read_rejects_mismatched_effective_policy_body_hash( ) assert response.status_code == 422 - assert "effective project submission artifact policy body hash mismatch" in ( - response.json()["detail"] + assert ( + "effective project submission artifact policy body hash mismatch" + in (response.json()["detail"]) ) @@ -7319,27 +7284,33 @@ async def test_guide_activation_and_active_guide_retrieval(project_client: Async assert active.json()["post_submit_checker_policy"]["required_checkers"] == [ "check_policy_context_present" ] - assert active.json()["guide_source_snapshot"]["bundle_hash"] == ( - bundle["source_snapshot"]["bundle_hash"] + assert ( + active.json()["guide_source_snapshot"]["bundle_hash"] + == (bundle["source_snapshot"]["bundle_hash"]) ) assert active.json()["guide_sufficiency_report"]["status"] == "passed" assert active.json()["submission_artifact_policy"]["lifecycle_status"] == "approved" - assert active.json()["effective_submission_artifact_policy"]["effective_policy_hash"] == ( - bundle["effective_policy"]["effective_policy_hash"] + assert ( + active.json()["effective_submission_artifact_policy"]["effective_policy_hash"] + == (bundle["effective_policy"]["effective_policy_hash"]) ) assert active.json()["pre_submit_checker_policy"]["lifecycle_status"] == "compiled" - assert active.json()["pre_submit_checker_policy"]["effective_policy_id"] == ( - bundle["effective_policy"]["id"] + assert ( + active.json()["pre_submit_checker_policy"]["effective_policy_id"] + == (bundle["effective_policy"]["id"]) ) - assert active.json()["pre_submit_checker_policy"]["compiled_bundle_hash"] == ( - bundle["pre_submit_checker_policy"]["compiled_bundle_hash"] + assert ( + active.json()["pre_submit_checker_policy"]["compiled_bundle_hash"] + == (bundle["pre_submit_checker_policy"]["compiled_bundle_hash"]) ) assert "compiled_bundle" not in active.json()["pre_submit_checker_policy"] - assert active.json()["pre_submit_checker_policy"]["checker_names"] == ( - bundle["pre_submit_checker_policy"]["checker_names"] + assert ( + active.json()["pre_submit_checker_policy"]["checker_names"] + == (bundle["pre_submit_checker_policy"]["checker_names"]) ) - assert active.json()["pre_submit_checker_policy"]["checker_configs"] == ( - bundle["pre_submit_checker_policy"]["checker_configs"] + assert ( + active.json()["pre_submit_checker_policy"]["checker_configs"] + == (bundle["pre_submit_checker_policy"]["checker_configs"]) ) assert active.json()["revision_policy"]["max_revision_rounds"] == 7 assert active.json()["revision_policy"]["auto_reject_after_limit"] is True diff --git a/backend/tests/test_tasks.py b/backend/tests/test_tasks.py index dc2b763f..288d1d3b 100644 --- a/backend/tests/test_tasks.py +++ b/backend/tests/test_tasks.py @@ -7,17 +7,22 @@ from datetime import UTC, datetime, timedelta from decimal import Decimal from pathlib import Path +from typing import Any, cast from unittest.mock import AsyncMock, MagicMock from uuid import uuid4 -import pytest -from alembic import command +import pytest # type: ignore[import-not-found] +from alembic import command # type: ignore[attr-defined] from alembic.config import Config from httpx import ASGITransport, AsyncClient from sqlalchemy import func, inspect, select, text from sqlalchemy.dialects import postgresql from sqlalchemy.exc import IntegrityError, OperationalError -from sqlalchemy.ext.asyncio import AsyncConnection, AsyncSession, create_async_engine +from sqlalchemy.ext.asyncio import ( # type: ignore[import-not-found] + AsyncConnection, + AsyncSession, + create_async_engine, +) from sqlalchemy.schema import CreateIndex from app.adapters.auth.dev import actor_id_from_external_identity @@ -91,9 +96,7 @@ async def test_task_repository_delegates_audit_persistence() -> None: assert await repository.add_audit_event(event) is persisted assert await repository.list_audit_events("task", "task-1") is listed repository._audit_repository.add_audit_event.assert_awaited_once_with(event) - repository._audit_repository.list_audit_events.assert_awaited_once_with( - "task", "task-1" - ) + repository._audit_repository.list_audit_events.assert_awaited_once_with("task", "task-1") async def test_task_contributor_revalidation_maps_failures_and_rolls_back() -> None: @@ -132,9 +135,7 @@ async def test_task_contributor_revalidation_maps_failures_and_rolls_back() -> N ), ) for source_error, expected_error, code in cases: - service._actors.require_active_human_write_actor = AsyncMock( - side_effect=source_error - ) + service._actors.require_active_human_write_actor = AsyncMock(side_effect=source_error) with pytest.raises(expected_error) as failure: await service._require_active_contributor(actor) assert failure.value.code == code @@ -170,6 +171,7 @@ async def test_task_service_create_persists_canonical_attribution_and_audit() -> result = await service.create_task(actor, "project-1", payload) + assert service._repo.add_task.await_args is not None task = service._repo.add_task.await_args.args[0] assert result is response assert isinstance(task, WorkstreamTask) @@ -212,17 +214,12 @@ async def test_task_service_read_contexts_preserve_visibility_and_operator_scope ) service._task_response = MagicMock(return_value=task_response) service._work_context_response = MagicMock(return_value=work_response) - service._submission_requirements_response = MagicMock( - return_value=requirements_response - ) + service._submission_requirements_response = MagicMock(return_value=requirements_response) service._locked_context_response = MagicMock(return_value=locked_response) assert await service.get_task(actor, task.id) is task_response assert await service.get_task_work_context(actor, task.id) is work_response - assert ( - await service.get_task_submission_requirements(actor, task.id) - is requirements_response - ) + assert await service.get_task_submission_requirements(actor, task.id) is requirements_response assert await service.get_task_locked_context(actor, task.id) is locked_response assert service._get_task.await_count == 4 @@ -352,9 +349,7 @@ async def test_task_service_finalize_requeues_locked_latest_submission( repair_snapshot = {"status": "failed", "repairable": True} requester_provenance = {"request_id": "request-1", "correlation_id": "correlation-1"} checker_service = MagicMock() - checker_service.pre_review_gate_repair_snapshot = AsyncMock( - return_value=repair_snapshot - ) + checker_service.pre_review_gate_repair_snapshot = AsyncMock(return_value=repair_snapshot) monkeypatch.setattr( "app.modules.tasks.service.CheckerService", MagicMock(return_value=checker_service), @@ -477,8 +472,7 @@ async def test_task_service_finalization_provenance_fails_closed_without_lock_au ) assert ( - await service._submission_finalization_requester_provenance(task, submission) - == provenance + await service._submission_finalization_requester_provenance(task, submission) == provenance ) with pytest.raises( TaskTransitionBlocked, @@ -580,20 +574,14 @@ async def delete_audit_fixture_as_owner(session: AsyncSession, event_id: str) -> """Construct missing-evidence corruption under explicit test-owner custody.""" await session.execute(text("lock table audit_events in access exclusive mode")) await session.execute( - text( - "alter table audit_events disable trigger " - "audit_events_reject_update_delete" - ) + text("alter table audit_events disable trigger audit_events_reject_update_delete") ) await session.execute( text("delete from audit_events where id = :event_id"), {"event_id": event_id}, ) await session.execute( - text( - "alter table audit_events enable trigger " - "audit_events_reject_update_delete" - ) + text("alter table audit_events enable trigger audit_events_reject_update_delete") ) await session.commit() @@ -601,11 +589,9 @@ async def delete_audit_fixture_as_owner(session: AsyncSession, event_id: str) -> @pytest.fixture def task_database_env( monkeypatch: pytest.MonkeyPatch, - postgres_database_url: str, - migration_lock, - reset_test_database_state, + clean_postgres_database: str, ) -> Iterator[str]: - monkeypatch.setenv("WORKSTREAM_DATABASE_URL", postgres_database_url) + monkeypatch.setenv("WORKSTREAM_DATABASE_URL", clean_postgres_database) monkeypatch.setenv("WORKSTREAM_CELERY_TASK_ALWAYS_EAGER", "true") monkeypatch.setenv( "WORKSTREAM_API_RATE_LIMIT_KEY_SECRET", @@ -613,28 +599,10 @@ def task_database_env( ) set_dev_actor(monkeypatch, roles="project_manager", subject="project-manager-subject") get_settings.cache_clear() - asyncio.run(db_session.dispose_engine()) - - config = alembic_config() try: - with migration_lock(): - command.downgrade(config, "base") - try: - command.upgrade(config, "head") - yield postgres_database_url - finally: - try: - asyncio.run(reset_test_database_state(postgres_database_url)) - finally: - try: - asyncio.run(db_session.dispose_engine()) - finally: - command.downgrade(config, "base") + yield clean_postgres_database finally: - try: - asyncio.run(db_session.dispose_engine()) - finally: - get_settings.cache_clear() + get_settings.cache_clear() @pytest.fixture @@ -885,8 +853,7 @@ async def delete_generated_post_submit_output_for_pre_submit( """Remove generated post-submit output before test-only pre-submit corruption.""" post_submit_policy = await session.scalar( select(PostSubmitCheckerPolicy).where( - PostSubmitCheckerPolicy.pre_submit_checker_policy_id - == pre_submit_checker_policy_id + PostSubmitCheckerPolicy.pre_submit_checker_policy_id == pre_submit_checker_policy_id ) ) if post_submit_policy is None: @@ -1140,7 +1107,9 @@ async def create_active_project(client: AsyncClient) -> dict: return project -async def create_draft_task(client: AsyncClient, project_id: str, payload: dict | None = None) -> dict: +async def create_draft_task( + client: AsyncClient, project_id: str, payload: dict | None = None +) -> dict: response = await client.post( f"/api/v1/projects/{project_id}/tasks", headers=auth_headers(), @@ -1293,10 +1262,7 @@ async def _task_contributor_race_snapshot( """Capture every task-owned write surface relevant to contributor races.""" task = ( await connection.execute( - text( - "select status, assigned_to from workstream_tasks " - "where id = :task_id" - ), + text("select status, assigned_to from workstream_tasks where id = :task_id"), {"task_id": task_id}, ) ).one() @@ -1508,10 +1474,7 @@ async def _restore_contributor_after_lifecycle_race( reset = await connection.begin() try: await connection.execute( - text( - "alter table actor_profiles disable trigger " - "actor_profile_history_guard" - ) + text("alter table actor_profiles disable trigger actor_profile_history_guard") ) await connection.execute( text( @@ -1554,8 +1517,7 @@ async def _restore_contributor_after_lifecycle_race( ) await connection.execute( text( - "alter table actor_profiles enable trigger " - "actor_profile_history_guard" + "alter table actor_profiles enable trigger actor_profile_history_guard" ) ) await enable.commit() @@ -1648,9 +1610,7 @@ def count_pre_review_enqueue( async with db_session.get_session_factory()() as session: identity_link_id = await session.scalar( - select(ActorIdentityLink.id).where( - ActorIdentityLink.actor_profile_id == contributor_id - ) + select(ActorIdentityLink.id).where(ActorIdentityLink.actor_profile_id == contributor_id) ) assert identity_link_id is not None contributor_lifecycle_race_cleanup.append((contributor_id, identity_link_id)) @@ -1667,9 +1627,7 @@ def count_pre_review_enqueue( task_id = task["id"] before = await _read_task_contributor_race_snapshot(task_database_env, task_id) task_application_name = f"ws-race-{operation}-{transition}-{ordering}-task" - lifecycle_application_name = ( - f"ws-race-{operation}-{transition}-{ordering}-lifecycle" - ) + lifecycle_application_name = f"ws-race-{operation}-{transition}-{ordering}-lifecycle" if ordering == "lifecycle_first": lifecycle_entered = asyncio.Event() @@ -1724,10 +1682,13 @@ def count_pre_review_enqueue( assert lifecycle_result is None assert isinstance(task_result, ActiveContributorRequired) assert task_result.code == "active_contributor_required" - assert await _read_task_contributor_race_snapshot( - task_database_env, - task_id, - ) == before + assert ( + await _read_task_contributor_race_snapshot( + task_database_env, + task_id, + ) + == before + ) if operation == "submission": assert checker_calls == [] assert enqueue_calls == [] @@ -1941,6 +1902,7 @@ async def test_chunk4_migration_creates_expected_tables(task_database_env: str) }.issubset(table_names) +@pytest.mark.postgres_schema_contract def test_chunk4_migration_downgrade_removes_task_tables(task_database_env: str) -> None: config = alembic_config() asyncio.run(db_session.dispose_engine()) @@ -1996,9 +1958,7 @@ def test_submission_create_openapi_documents_domain_error() -> None: def test_task_context_openapi_documents_locked_context_domain_error() -> None: schema = create_app().openapi() - responses = schema["paths"]["/api/v1/tasks/{task_id}/work-context"]["get"][ - "responses" - ] + responses = schema["paths"]["/api/v1/tasks/{task_id}/work-context"]["get"]["responses"] response_422 = responses["422"]["content"]["application/json"]["schema"] assert {"$ref": "#/components/schemas/HTTPValidationError"} in response_422["oneOf"] @@ -2143,9 +2103,7 @@ async def fail_with_service_error(*_args, **_kwargs): assert response.status_code == 400 assert response.json()["detail"] == "bounded task failure" assert response.json()["error"]["code"] == "invalid_request" - assert response.json()["error"]["correlation_id"] == response.headers[ - "x-correlation-id" - ] + assert response.json()["error"]["correlation_id"] == response.headers["x-correlation-id"] async def fail_with_permission_error(*_args, **_kwargs): raise PermissionDenied("bounded permission failure") @@ -2219,13 +2177,17 @@ async def test_legacy_eligibility_service_updates_existing_submitter_row( async with db_session.get_session_factory()() as session: worker_rows = ( - await session.execute( - select(LegacyWorkflowEligibility).where( - LegacyWorkflowEligibility.actor_id == worker_actor.actor_id, - LegacyWorkflowEligibility.profile_type == "worker", + ( + await session.execute( + select(LegacyWorkflowEligibility).where( + LegacyWorkflowEligibility.actor_id == worker_actor.actor_id, + LegacyWorkflowEligibility.profile_type == "worker", + ) ) ) - ).scalars().all() + .scalars() + .all() + ) assert updated_worker.id == first_worker.id assert updated_worker.skill_tags == ["stem", "analysis"] @@ -2574,7 +2536,9 @@ async def test_task_context_apis_return_worker_requirements_and_operator_provena payload = complete_task_payload() payload["import_batch_id"] = "private-import-batch" payload["external_task_id"] = "private-external-task" - started_task = await create_started_task(task_client, project["id"], monkeypatch, payload=payload) + started_task = await create_started_task( + task_client, project["id"], monkeypatch, payload=payload + ) work_context = await task_client.get( f"/api/v1/tasks/{started_task['id']}/work-context", @@ -2666,14 +2630,14 @@ async def test_task_context_apis_return_worker_requirements_and_operator_provena locked_body = locked_context.json() assert locked_body["locked_guide_version"] == "v1" assert locked_body["locked_guide_source_snapshot_hash"].startswith("sha256:") - assert locked_body[ - "locked_effective_project_submission_artifact_policy_hash" - ].startswith("sha256:") + assert locked_body["locked_effective_project_submission_artifact_policy_hash"].startswith( + "sha256:" + ) assert locked_body["locked_pre_submit_checker_bundle_hash"].startswith("sha256:") assert locked_body["locked_post_submit_checker_policy_hash"].startswith("sha256:") - assert locked_body["locked_post_submit_checker_policy_body_summary"][ - "required_checkers" - ] == ["check_policy_context_present"] + assert locked_body["locked_post_submit_checker_policy_body_summary"]["required_checkers"] == [ + "check_policy_context_present" + ] async def test_ready_worker_work_context_omits_private_task_source_fields( @@ -2989,9 +2953,7 @@ async def test_task_context_apis_use_v1_locked_requirements_after_v2_activation( assert requirements.status_code == 200, requirements.text assert work_context.json()["guide"]["version"] == "v1" assert requirements.json()["guide_version"] == "v1" - assert requirements.json()["required_artifacts"] == v1_requirements.json()[ - "required_artifacts" - ] + assert requirements.json()["required_artifacts"] == v1_requirements.json()["required_artifacts"] assert requirements.json()["required_artifacts"][0]["path"] == "answer.md" @@ -3049,9 +3011,7 @@ async def test_submission_runtime_uses_locked_project_policy_not_task_required_f "worker-two", complete_task_payload(), ) - non_contract_artifact_payload = complete_submission_payload( - "sha256:non-contract-package" - ) + non_contract_artifact_payload = complete_submission_payload("sha256:non-contract-package") non_contract_artifact_payload["artifact_hash_manifest"] = [ { "artifact": "non-contract-only.md", @@ -3304,9 +3264,7 @@ async def test_disabled_legacy_eligibility_after_claim_blocks_assigned_submitter ) assert start.status_code == 403 assert "active legacy submitter eligibility" in start.json()["detail"] - read = await task_client.get( - f"/api/v1/tasks/{ready_task['id']}", headers=auth_headers() - ) + read = await task_client.get(f"/api/v1/tasks/{ready_task['id']}", headers=auth_headers()) assert read.status_code == 200 assert read.json()["status"] == "claimed" context = await task_client.get( @@ -3354,16 +3312,14 @@ async def test_disabled_eligibility_suppresses_submit_lifecycle_affordances( async with db_session.get_session_factory()() as session: before = { "submissions": await session.scalar( - select(func.count()).select_from(Submission).where( - Submission.task_id == started_task["id"] - ) + select(func.count()) + .select_from(Submission) + .where(Submission.task_id == started_task["id"]) ), "checker_runs": await session.scalar( select(func.count()).select_from(db_models.CheckerRun) ), - "audit_events": await session.scalar( - select(func.count()).select_from(AuditEvent) - ), + "audit_events": await session.scalar(select(func.count()).select_from(AuditEvent)), } submission = await task_client.post( f"/api/v1/tasks/{started_task['id']}/submissions", @@ -3375,16 +3331,14 @@ async def test_disabled_eligibility_suppresses_submit_lifecycle_affordances( async with db_session.get_session_factory()() as session: after = { "submissions": await session.scalar( - select(func.count()).select_from(Submission).where( - Submission.task_id == started_task["id"] - ) + select(func.count()) + .select_from(Submission) + .where(Submission.task_id == started_task["id"]) ), "checker_runs": await session.scalar( select(func.count()).select_from(db_models.CheckerRun) ), - "audit_events": await session.scalar( - select(func.count()).select_from(AuditEvent) - ), + "audit_events": await session.scalar(select(func.count()).select_from(AuditEvent)), } assert after == before @@ -3636,7 +3590,9 @@ async def test_worker_profile_requires_worker_role( assert "actor lacks required role" in response.json()["detail"] -async def test_second_claim_is_rejected(task_client: AsyncClient, monkeypatch: pytest.MonkeyPatch) -> None: +async def test_second_claim_is_rejected( + task_client: AsyncClient, monkeypatch: pytest.MonkeyPatch +) -> None: project = await create_active_project(task_client) ready_task = await create_ready_task(task_client, project["id"]) await seed_worker_profile("worker-one") @@ -3826,8 +3782,9 @@ async def test_assigned_worker_submit_auto_enters_pre_review_gate( assert "artifact_hash_manifest" not in submission_event["event_payload"] assert "locked_guide_source_snapshot_id" not in submission_event["event_payload"] assert "locked_guide_source_snapshot_hash" not in submission_event["event_payload"] - assert "locked_effective_project_submission_artifact_policy_hash" not in ( - submission_event["event_payload"] + assert ( + "locked_effective_project_submission_artifact_policy_hash" + not in (submission_event["event_payload"]) ) assert "locked_pre_submit_checker_bundle_hash" not in submission_event["event_payload"] assert "locked_post_submit_checker_policy_hash" not in submission_event["event_payload"] @@ -3990,16 +3947,26 @@ async def test_pre_submit_failure_writes_audit_event_without_submission( async with db_session.get_session_factory()() as session: submissions = ( - await session.execute(select(Submission).where(Submission.task_id == started_task["id"])) - ).scalars().all() + ( + await session.execute( + select(Submission).where(Submission.task_id == started_task["id"]) + ) + ) + .scalars() + .all() + ) audit_events = ( - await session.execute( - select(AuditEvent).where( - AuditEvent.entity_type == "task", - AuditEvent.entity_id == started_task["id"], + ( + await session.execute( + select(AuditEvent).where( + AuditEvent.entity_type == "task", + AuditEvent.entity_id == started_task["id"], + ) ) ) - ).scalars().all() + .scalars() + .all() + ) checker_runs = (await session.execute(select(db_models.CheckerRun))).scalars().all() task = await session.get(WorkstreamTask, started_task["id"]) assert submissions == [] @@ -4009,10 +3976,7 @@ async def test_pre_submit_failure_writes_audit_event_without_submission( assert new_audit_events[0].from_status == "in_progress" assert new_audit_events[0].to_status == "in_progress" assert new_audit_events[0].event_payload["pre_submit_check"]["status"] == "failed" - assert ( - new_audit_events[0].event_payload["pre_submit_check"]["eligible_to_submit"] - is False - ) + assert new_audit_events[0].event_payload["pre_submit_check"]["eligible_to_submit"] is False assert checker_runs == [] assert task is not None assert task.status == "in_progress" @@ -4069,8 +4033,14 @@ async def test_submission_pre_submit_requires_specific_evidence_key( async with db_session.get_session_factory()() as session: submissions = ( - await session.execute(select(Submission).where(Submission.task_id == started_task["id"])) - ).scalars().all() + ( + await session.execute( + select(Submission).where(Submission.task_id == started_task["id"]) + ) + ) + .scalars() + .all() + ) assert submissions == [] @@ -4107,8 +4077,14 @@ async def test_submission_pre_submit_requires_project_attestation_terms( async with db_session.get_session_factory()() as session: submissions = ( - await session.execute(select(Submission).where(Submission.task_id == started_task["id"])) - ).scalars().all() + ( + await session.execute( + select(Submission).where(Submission.task_id == started_task["id"]) + ) + ) + .scalars() + .all() + ) assert submissions == [] @@ -4147,8 +4123,14 @@ async def test_submission_pre_submit_rejects_mutated_effective_policy_body( async with db_session.get_session_factory()() as session: submissions = ( - await session.execute(select(Submission).where(Submission.task_id == started_task["id"])) - ).scalars().all() + ( + await session.execute( + select(Submission).where(Submission.task_id == started_task["id"]) + ) + ) + .scalars() + .all() + ) checker_runs = (await session.execute(select(db_models.CheckerRun))).scalars().all() assert submissions == [] assert checker_runs == [] @@ -4243,8 +4225,10 @@ async def test_submission_pre_submit_rejects_hash_consistent_malformed_effective async with db_session.get_session_factory()() as session: submissions = ( - await session.execute(select(Submission).where(Submission.task_id == task["id"])) - ).scalars().all() + (await session.execute(select(Submission).where(Submission.task_id == task["id"]))) + .scalars() + .all() + ) checker_runs = (await session.execute(select(db_models.CheckerRun))).scalars().all() assert submissions == [] assert checker_runs == [] @@ -4339,8 +4323,10 @@ async def test_submission_pre_submit_rejects_hash_consistent_malformed_packaging async with db_session.get_session_factory()() as session: submissions = ( - await session.execute(select(Submission).where(Submission.task_id == task["id"])) - ).scalars().all() + (await session.execute(select(Submission).where(Submission.task_id == task["id"]))) + .scalars() + .all() + ) checker_runs = (await session.execute(select(db_models.CheckerRun))).scalars().all() assert submissions == [] assert checker_runs == [] @@ -4378,8 +4364,14 @@ async def test_submission_pre_submit_rejects_mutated_compiled_checker_bundle( async with db_session.get_session_factory()() as session: submissions = ( - await session.execute(select(Submission).where(Submission.task_id == started_task["id"])) - ).scalars().all() + ( + await session.execute( + select(Submission).where(Submission.task_id == started_task["id"]) + ) + ) + .scalars() + .all() + ) checker_runs = (await session.execute(select(db_models.CheckerRun))).scalars().all() assert submissions == [] assert checker_runs == [] @@ -4449,8 +4441,10 @@ async def test_submission_pre_submit_rejects_hash_consistent_incomplete_checker_ async with db_session.get_session_factory()() as session: submissions = ( - await session.execute(select(Submission).where(Submission.task_id == task["id"])) - ).scalars().all() + (await session.execute(select(Submission).where(Submission.task_id == task["id"]))) + .scalars() + .all() + ) checker_runs = (await session.execute(select(db_models.CheckerRun))).scalars().all() assert submissions == [] assert checker_runs == [] @@ -4485,8 +4479,14 @@ async def test_submission_pre_submit_checker_setup_error_is_controlled( async with db_session.get_session_factory()() as session: submissions = ( - await session.execute(select(Submission).where(Submission.task_id == started_task["id"])) - ).scalars().all() + ( + await session.execute( + select(Submission).where(Submission.task_id == started_task["id"]) + ) + ) + .scalars() + .all() + ) assert submissions == [] @@ -4522,8 +4522,14 @@ async def test_submission_uses_locked_post_submit_policy_body_after_setup_mutati async with db_session.get_session_factory()() as session: task = await session.get(WorkstreamTask, started_task["id"]) submissions = ( - await session.execute(select(Submission).where(Submission.task_id == started_task["id"])) - ).scalars().all() + ( + await session.execute( + select(Submission).where(Submission.task_id == started_task["id"]) + ) + ) + .scalars() + .all() + ) checker_runs = (await session.execute(select(db_models.CheckerRun))).scalars().all() assert task is not None assert task.status == "review_pending" @@ -4556,8 +4562,14 @@ async def test_database_rejects_null_post_submit_context_on_non_draft_task( async with db_session.get_session_factory()() as session: task = await session.get(WorkstreamTask, started_task["id"]) submissions = ( - await session.execute(select(Submission).where(Submission.task_id == started_task["id"])) - ).scalars().all() + ( + await session.execute( + select(Submission).where(Submission.task_id == started_task["id"]) + ) + ) + .scalars() + .all() + ) checker_runs = (await session.execute(select(db_models.CheckerRun))).scalars().all() assert task is not None assert task.status == "in_progress" @@ -5379,21 +5391,29 @@ def fail_enqueue(*, checker_run_id: str, requester_provenance: dict) -> str: async with db_session.get_session_factory()() as session: checker_runs = ( - await session.execute( - select(db_models.CheckerRun).where( - db_models.CheckerRun.submission_id == submission_id + ( + await session.execute( + select(db_models.CheckerRun).where( + db_models.CheckerRun.submission_id == submission_id + ) ) ) - ).scalars().all() + .scalars() + .all() + ) task = await session.get(WorkstreamTask, started_task["id"]) dispatch_failed_events = ( - await session.execute( - select(AuditEvent).where( - AuditEvent.entity_id == started_task["id"], - AuditEvent.event_type == "pre_review_gate_dispatch_failed", + ( + await session.execute( + select(AuditEvent).where( + AuditEvent.entity_id == started_task["id"], + AuditEvent.event_type == "pre_review_gate_dispatch_failed", + ) ) ) - ).scalars().all() + .scalars() + .all() + ) assert len(checker_runs) == 1 failed_claim = checker_runs[0] assert failed_claim.status == "failed" @@ -5461,13 +5481,17 @@ def fail_enqueue(*, checker_run_id: str, requester_provenance: dict) -> str: assert len(repeated_checker_runs.json()) == 1 async with db_session.get_session_factory()() as session: repair_events = ( - await session.execute( - select(AuditEvent).where( - AuditEvent.entity_id == started_task["id"], - AuditEvent.event_type == "pre_review_gate_repair_requested", + ( + await session.execute( + select(AuditEvent).where( + AuditEvent.entity_id == started_task["id"], + AuditEvent.event_type == "pre_review_gate_repair_requested", + ) ) ) - ).scalars().all() + .scalars() + .all() + ) assert len(repair_events) == 1 @@ -5501,9 +5525,7 @@ def fail_enqueue(*, checker_run_id: str, requester_provenance: dict) -> str: async with db_session.get_session_factory()() as session: failed_run = await session.scalar( - select(db_models.CheckerRun).where( - db_models.CheckerRun.submission_id == submission_id - ) + select(db_models.CheckerRun).where(db_models.CheckerRun.submission_id == submission_id) ) assert failed_run is not None assert failed_run.status == "failed" @@ -5530,20 +5552,28 @@ def hold_enqueue(*, checker_run_id: str, requester_provenance: dict) -> str: async with db_session.get_session_factory()() as session: checker_runs = ( - await session.execute( - select(db_models.CheckerRun).where( - db_models.CheckerRun.submission_id == submission_id + ( + await session.execute( + select(db_models.CheckerRun).where( + db_models.CheckerRun.submission_id == submission_id + ) ) ) - ).scalars().all() + .scalars() + .all() + ) repair_events = ( - await session.execute( - select(AuditEvent).where( - AuditEvent.entity_id == started_task["id"], - AuditEvent.event_type == "pre_review_gate_repair_requested", + ( + await session.execute( + select(AuditEvent).where( + AuditEvent.entity_id == started_task["id"], + AuditEvent.event_type == "pre_review_gate_repair_requested", + ) ) ) - ).scalars().all() + .scalars() + .all() + ) assert repair_enqueue_calls == [failed_run.id] assert len(checker_runs) == 1 @@ -5590,19 +5620,21 @@ def fail_enqueue(*, checker_run_id: str, requester_provenance: dict) -> str: async with db_session.get_session_factory()() as session: moved_run = await session.scalar( - select(db_models.CheckerRun).where( - db_models.CheckerRun.submission_id == submission_id - ) + select(db_models.CheckerRun).where(db_models.CheckerRun.submission_id == submission_id) ) dispatch_events = ( - await session.execute( - select(AuditEvent).where( - AuditEvent.entity_type == "task", - AuditEvent.entity_id == started_task["id"], - AuditEvent.event_type == "pre_review_gate_dispatch_failed", + ( + await session.execute( + select(AuditEvent).where( + AuditEvent.entity_type == "task", + AuditEvent.entity_id == started_task["id"], + AuditEvent.event_type == "pre_review_gate_dispatch_failed", + ) ) ) - ).scalars().all() + .scalars() + .all() + ) assert moved_run is not None assert moved_run.status == "queued" @@ -5633,9 +5665,7 @@ def hold_initial_enqueue(*, checker_run_id: str, requester_provenance: dict) -> async with db_session.get_session_factory()() as session: failed_run = await session.scalar( - select(db_models.CheckerRun).where( - db_models.CheckerRun.submission_id == submission_id - ) + select(db_models.CheckerRun).where(db_models.CheckerRun.submission_id == submission_id) ) assert failed_run is not None failed_run.status = "failed" @@ -5698,9 +5728,7 @@ async def test_nonrepairable_failed_gate_does_not_return_success( async with db_session.get_session_factory()() as session: failed_run = await session.scalar( - select(db_models.CheckerRun).where( - db_models.CheckerRun.submission_id == submission_id - ) + select(db_models.CheckerRun).where(db_models.CheckerRun.submission_id == submission_id) ) assert failed_run is not None failed_run.status = "failed" @@ -5754,9 +5782,7 @@ async def fail_run_queued_gate( async with db_session.get_session_factory()() as session: failed_run = await session.scalar( - select(db_models.CheckerRun).where( - db_models.CheckerRun.submission_id == submission_id - ) + select(db_models.CheckerRun).where(db_models.CheckerRun.submission_id == submission_id) ) task = await session.get(WorkstreamTask, started_task["id"]) dispatch_failed_event = await session.scalar( @@ -5833,9 +5859,7 @@ async def test_finalize_repairs_stale_running_pre_review_gate( stale_started_at = datetime.now(UTC) - timedelta(hours=1) async with db_session.get_session_factory()() as session: queued_run = await session.scalar( - select(db_models.CheckerRun).where( - db_models.CheckerRun.submission_id == submission_id - ) + select(db_models.CheckerRun).where(db_models.CheckerRun.submission_id == submission_id) ) assert queued_run is not None queued_run.status = "running" @@ -5916,9 +5940,7 @@ def hold_initial_enqueue(*, checker_run_id: str, requester_provenance: dict) -> stale_started_at = datetime.now(UTC) - timedelta(hours=1) async with db_session.get_session_factory()() as session: queued_run = await session.scalar( - select(db_models.CheckerRun).where( - db_models.CheckerRun.submission_id == submission_id - ) + select(db_models.CheckerRun).where(db_models.CheckerRun.submission_id == submission_id) ) assert queued_run is not None queued_run.status = "running" @@ -5946,20 +5968,28 @@ def hold_repair_enqueue(*, checker_run_id: str, requester_provenance: dict) -> s async with db_session.get_session_factory()() as session: checker_runs = ( - await session.execute( - select(db_models.CheckerRun) - .where(db_models.CheckerRun.submission_id == submission_id) - .order_by(db_models.CheckerRun.attempt_number.asc()) + ( + await session.execute( + select(db_models.CheckerRun) + .where(db_models.CheckerRun.submission_id == submission_id) + .order_by(db_models.CheckerRun.attempt_number.asc()) + ) ) - ).scalars().all() + .scalars() + .all() + ) repair_events = ( - await session.execute( - select(AuditEvent).where( - AuditEvent.entity_id == started_task["id"], - AuditEvent.event_type == "pre_review_gate_repair_requested", + ( + await session.execute( + select(AuditEvent).where( + AuditEvent.entity_id == started_task["id"], + AuditEvent.event_type == "pre_review_gate_repair_requested", + ) ) ) - ).scalars().all() + .scalars() + .all() + ) assert len(checker_runs) == 2 stale_run, replacement_run = checker_runs @@ -6029,17 +6059,25 @@ def hold_enqueue(*, checker_run_id: str, requester_provenance: dict) -> str: async with db_session.get_session_factory()() as session: checker_runs = ( - await session.execute( - select(db_models.CheckerRun).where( - db_models.CheckerRun.submission_id == submission_id + ( + await session.execute( + select(db_models.CheckerRun).where( + db_models.CheckerRun.submission_id == submission_id + ) ) ) - ).scalars().all() + .scalars() + .all() + ) audit_events = ( - await session.execute( - select(AuditEvent).where(AuditEvent.entity_id == started_task["id"]) + ( + await session.execute( + select(AuditEvent).where(AuditEvent.entity_id == started_task["id"]) + ) ) - ).scalars().all() + .scalars() + .all() + ) assert len(checker_runs) == 1 assert checker_runs[0].id == enqueue_calls[0]["checker_run_id"] @@ -6082,12 +6120,16 @@ async def test_manual_checker_run_cannot_replace_queued_automatic_gate( assert "automatic pre-review gate must be repaired" in manual_run.json()["detail"] async with db_session.get_session_factory()() as session: checker_runs = ( - await session.execute( - select(db_models.CheckerRun).where( - db_models.CheckerRun.submission_id == created.json()["id"] + ( + await session.execute( + select(db_models.CheckerRun).where( + db_models.CheckerRun.submission_id == created.json()["id"] + ) ) ) - ).scalars().all() + .scalars() + .all() + ) assert len(checker_runs) == 1 assert checker_runs[0].status == "queued" assert checker_runs[0].attempt_number == 1 @@ -6119,9 +6161,7 @@ async def test_manual_checker_run_cannot_bypass_failed_automatic_gate( async with db_session.get_session_factory()() as session: queued_run = await session.scalar( - select(db_models.CheckerRun).where( - db_models.CheckerRun.submission_id == submission_id - ) + select(db_models.CheckerRun).where(db_models.CheckerRun.submission_id == submission_id) ) lock_audit = await session.scalar( select(AuditEvent).where( @@ -6134,7 +6174,7 @@ async def test_manual_checker_run_cannot_bypass_failed_automatic_gate( await delete_audit_fixture_as_owner(session, lock_audit.id) with pytest.raises(CheckerExecutionBlocked): - run_pre_review_gate.run( + cast(Any, run_pre_review_gate).run( queued_run.id, expected_worker_requester_provenance(), ) @@ -6150,12 +6190,16 @@ async def test_manual_checker_run_cannot_bypass_failed_automatic_gate( async with db_session.get_session_factory()() as session: checker_runs = ( - await session.execute( - select(db_models.CheckerRun).where( - db_models.CheckerRun.submission_id == submission_id + ( + await session.execute( + select(db_models.CheckerRun).where( + db_models.CheckerRun.submission_id == submission_id + ) ) ) - ).scalars().all() + .scalars() + .all() + ) assert len(checker_runs) == 1 assert checker_runs[0].id == queued_run.id assert checker_runs[0].status == "failed" @@ -6191,9 +6235,7 @@ async def test_queued_gate_policy_error_is_failed_and_repairable( submission = await session.get(Submission, submission_id) task = await session.get(WorkstreamTask, started_task["id"]) queued_run = await session.scalar( - select(db_models.CheckerRun).where( - db_models.CheckerRun.submission_id == submission_id - ) + select(db_models.CheckerRun).where(db_models.CheckerRun.submission_id == submission_id) ) assert submission is not None assert task is not None @@ -6210,7 +6252,7 @@ async def test_queued_gate_policy_error_is_failed_and_repairable( await session.commit() with pytest.raises(CheckerPolicyInvalid): - run_pre_review_gate.run( + cast(Any, run_pre_review_gate).run( queued_run.id, expected_worker_requester_provenance(), ) @@ -6285,14 +6327,12 @@ async def test_queued_gate_rejects_tampered_requester_provenance( async with db_session.get_session_factory()() as session: queued_run = await session.scalar( - select(db_models.CheckerRun).where( - db_models.CheckerRun.submission_id == submission_id - ) + select(db_models.CheckerRun).where(db_models.CheckerRun.submission_id == submission_id) ) assert queued_run is not None with pytest.raises(CheckerExecutionBlocked): - run_pre_review_gate.run( + cast(Any, run_pre_review_gate).run( queued_run.id, { "requester_actor_id": actor_id("attacker"), @@ -6306,14 +6346,18 @@ async def test_queued_gate_rejects_tampered_requester_provenance( failed_run = await session.get(db_models.CheckerRun, queued_run.id) task = await session.get(WorkstreamTask, started_task["id"]) gate_events = ( - await session.execute( - select(AuditEvent).where( - AuditEvent.entity_type == "task", - AuditEvent.entity_id == started_task["id"], - AuditEvent.event_type.like("pre_review_gate_%"), + ( + await session.execute( + select(AuditEvent).where( + AuditEvent.entity_type == "task", + AuditEvent.entity_id == started_task["id"], + AuditEvent.event_type.like("pre_review_gate_%"), + ) ) ) - ).scalars().all() + .scalars() + .all() + ) assert failed_run is not None assert failed_run.status == "failed" assert failed_run.failure_code == "requester_provenance_mismatch" @@ -6372,9 +6416,7 @@ async def test_queued_gate_fails_closed_when_lock_audit_is_missing( async with db_session.get_session_factory()() as session: queued_run = await session.scalar( - select(db_models.CheckerRun).where( - db_models.CheckerRun.submission_id == submission_id - ) + select(db_models.CheckerRun).where(db_models.CheckerRun.submission_id == submission_id) ) lock_audit = await session.scalar( select(AuditEvent).where( @@ -6387,7 +6429,7 @@ async def test_queued_gate_fails_closed_when_lock_audit_is_missing( await delete_audit_fixture_as_owner(session, lock_audit.id) with pytest.raises(CheckerExecutionBlocked): - run_pre_review_gate.run( + cast(Any, run_pre_review_gate).run( queued_run.id, expected_worker_requester_provenance(), ) @@ -6450,7 +6492,7 @@ async def test_stale_queued_pre_review_gate_skips_before_task_status_check( ) assert v2.status_code == 201, v2.text - result = run_pre_review_gate.run( + result = cast(Any, run_pre_review_gate).run( v1_run.id, { **expected_worker_requester_provenance(), @@ -6468,14 +6510,18 @@ async def test_stale_queued_pre_review_gate_skips_before_task_status_check( ) ) audit_events = ( - await session.execute( - select(AuditEvent).where( - AuditEvent.entity_type == "task", - AuditEvent.entity_id == started_task["id"], - AuditEvent.event_type.like("pre_review_gate_%"), + ( + await session.execute( + select(AuditEvent).where( + AuditEvent.entity_type == "task", + AuditEvent.entity_id == started_task["id"], + AuditEvent.event_type.like("pre_review_gate_%"), + ) ) ) - ).scalars().all() + .scalars() + .all() + ) assert stale_run is not None assert stale_run.status == "failed"