From e22e9fba681f6f076d67433c90a268218be7662b Mon Sep 17 00:00:00 2001 From: Konan Date: Tue, 21 Jul 2026 20:13:50 +0100 Subject: [PATCH 1/2] ci(backend): cut test runtime with semantic lanes --- .../CHUNK_MAP.md | 15 +- .../WS-CI-001-backend-ci-acceleration/PLAN.md | 235 +++--- .../RISKS.md | 29 +- .../STATUS.md | 21 +- ...-CI-001-02-runtime-stabilization-timing.md | 134 ++++ .../WS-CI-001-02-safe-routing-cache-timing.md | 74 -- .github/workflows/backend.yml | 239 +----- backend/pyproject.toml | 3 + backend/scripts/ci_test_shards.py | 703 ------------------ backend/scripts/run_isolated_tests.py | 53 +- backend/scripts/run_test_lanes.py | 378 ++++++++++ backend/tests/conftest.py | 163 ++-- backend/tests/test_actors.py | 109 ++- backend/tests/test_alembic.py | 97 +-- backend/tests/test_api_rate_controls.py | 60 +- backend/tests/test_artifact_admission.py | 84 +-- backend/tests/test_audit.py | 52 +- backend/tests/test_auth.py | 298 ++++---- backend/tests/test_authorization.py | 579 +++++++-------- backend/tests/test_checkers.py | 294 ++++---- backend/tests/test_ci_test_lanes.py | 190 +++++ backend/tests/test_ci_test_shards.py | 448 ----------- backend/tests/test_database_reset.py | 78 ++ .../tests/test_isolated_database_runner.py | 194 ++++- backend/tests/test_outbox.py | 127 ++-- backend/tests/test_projects.py | 363 +++++---- backend/tests/test_tasks.py | 551 +++++++------- docs/operations_backend_testing.md | 172 +++-- scripts/test_agent_gates.py | 176 ++--- 29 files changed, 2651 insertions(+), 3268 deletions(-) create mode 100644 .agent-loop/initiatives/WS-CI-001-backend-ci-acceleration/chunks/WS-CI-001-02-runtime-stabilization-timing.md delete mode 100644 .agent-loop/initiatives/WS-CI-001-backend-ci-acceleration/chunks/WS-CI-001-02-safe-routing-cache-timing.md delete mode 100644 backend/scripts/ci_test_shards.py create mode 100644 backend/scripts/run_test_lanes.py create mode 100644 backend/tests/test_ci_test_lanes.py delete mode 100644 backend/tests/test_ci_test_shards.py create mode 100644 backend/tests/test_database_reset.py diff --git a/.agent-loop/initiatives/WS-CI-001-backend-ci-acceleration/CHUNK_MAP.md b/.agent-loop/initiatives/WS-CI-001-backend-ci-acceleration/CHUNK_MAP.md index ea79bc58..218c2815 100644 --- a/.agent-loop/initiatives/WS-CI-001-backend-ci-acceleration/CHUNK_MAP.md +++ b/.agent-loop/initiatives/WS-CI-001-backend-ci-acceleration/CHUNK_MAP.md @@ -1,12 +1,13 @@ # CHUNK MAP: WS-CI-001 | Chunk | Title | Risk | Status | -|---|---|---:|---| +| --- | --- | ---: | --- | | `WS-CI-001-01` | Parallel Full-Suite Coverage | L1 | Completed and merged in PR #163 | -| `WS-CI-001-01R1` | Timeout Cleanup Repair | L1 | Implemented and internally reviewed; awaiting hosted repair PR proof/human review | -| `WS-CI-001-02` | Safe Routing, Cache, and Timing Refinement | L1 | Future; not started and requires separate approval | +| `WS-CI-001-01R1` | Timeout Cleanup Repair | L1 | Completed and merged in PR #164 | +| `WS-CI-001-02` | Migrate-Once Backend Test Runtime | L1 | Source-level fixture consolidation in local verification; canonical start required before PR | -Each chunk maps to one PR. Chunk 01 preserves the full suite and every coverage -gate. Chunk 01R1 is the bounded post-merge response to CodeRabbit's timeout -cleanup finding. Chunk 02 may be planned only from measured 01 evidence and -cannot be activated without separate human approval. +Each chunk maps to one PR. Chunk 01 introduced parallel coverage and chunk 01R1 +repaired timeout cleanup. Measured evidence later proved that repeated per-test +migrations—not module assignment—were the primary bottleneck. The user directed +chunk 02 to fix that source problem locally and remove redundant sharding. It +must not be pushed or opened as a PR before canonical start and internal review. diff --git a/.agent-loop/initiatives/WS-CI-001-backend-ci-acceleration/PLAN.md b/.agent-loop/initiatives/WS-CI-001-backend-ci-acceleration/PLAN.md index b5d247be..9c1b9b3e 100644 --- a/.agent-loop/initiatives/WS-CI-001-backend-ci-acceleration/PLAN.md +++ b/.agent-loop/initiatives/WS-CI-001-backend-ci-acceleration/PLAN.md @@ -2,149 +2,102 @@ ## Objective -Reduce Backend workflow wall-clock time while preserving complete test execution, -real service integration, exact-head provenance, and every current coverage and -branch-protection gate. - -## Proposed approach - -### 1. Deterministic inventory and partitioning - -Add a repository-owned planner with one shared validation implementation. -Canonical module completeness comes from symlink-safe filesystem discovery of -`backend/tests/test_*.py`, with an explicit declaration excluding only -`test_isolated_database_runner.py`. A successful pytest collection supplies the -canonical per-test-base cardinality signatures and per-module weights; every node must map to one -discovered canonical module. Collection errors, zero-test modules, foreign paths, -malformed node IDs, and parameterized-node collisions fail. - -Assign every non-excluded module exactly once using deterministic -largest-weight-first bin packing with lexical tie-breaking. Emit canonical, -schema-versioned JSON binding schema, actual checked-out tree SHA from -`git rev-parse HEAD`, ordered normalized module paths, explicit exclusion, -ordered stable node signatures, collected counts, shard count, weights, and assignments. Its -SHA-256 digest identifies the exact executable inventory and plan. - -The initial weight is collected test count per module. Runtime telemetry may -replace or augment weights only in the later optional chunk after its provenance -and stability are reviewed. - -### 2. Fast prerequisite job - -Retain evidence, install, lint, docstring, isolated-runner self-test, collection, -and shard-plan validation ahead of the expensive matrix. Upload the immutable -manifest for the current commit. A collection or planning failure prevents all -shards. - -### 3. Isolated parallel shards - -Run four matrix jobs initially. Each validates its actual checked-out tree SHA -against the manifest, provisions its own PostgreSQL service and independently -owned migrated database through `run_isolated_tests.py`, starts MinIO, and runs -only manifest modules through Python argv/subprocess construction rather than -shell-expanded module strings. It executes the manifest's validated whole-module -paths and uses repository-owned pytest hooks to record final collection and -runtime completion in that same pytest process. It requires those exact runtime -sets to match and their stable test-base cardinalities to match preflight, writes -a unique coverage file, and uploads a fixed-name bundle containing shard ID, -tree SHA, manifest digest, collected/completed node IDs and counts, -allowlisted non-secret metadata, coverage, and SHA-256 of the coverage bytes. - -No shard shares a database, role, filesystem artifact, or coverage filename. -No test is selected by an untrusted PR-provided shell fragment. - -### 4. Concurrent API contract proof - -Run the existing real API contract E2E command as a separate dependent job after -fast prerequisites and concurrently with shards. Preserve its isolated database -and timeout behavior. - -### 5. Fail-closed final fan-in - -Keep workflow name `Backend` and final job ID `test`, preserving the existing -`Backend / test` required-check context. Other jobs use different IDs. Run the -final job after every shard and API proof with `if: always()`. It must reject any -failed, cancelled, or skipped prerequisite; missing/extra shard; tree-SHA or -manifest-digest mismatch; malformed metadata; duplicate/omitted module or node; -collection-count mismatch; missing coverage; coverage-digest mismatch; symlink, -path traversal, non-regular file, or unexpected path. It accepts exactly four -fixed artifact names containing tree SHA and shard ID, downloads without wildcard -surplus, and recomputes canonical bindings rather than trusting repeated labels. - -Only after provenance validation does it combine coverage data and enforce the -unchanged repository-wide and twelve protected coverage reports. The final job -uploads concise timing and shard-balance evidence. - -### 6. Evidence and rollout - -Test the planner and fan-in validator locally, including a real repository -collection-to-plan-to-four-shard-selection-to-fan-in dry run. Statically test the -workflow in the agent-gate suite and run the implementation PR on GitHub. Compare hosted -wall time and shard distribution with the PR #161 baseline. Do not claim a -latency target passed until exact hosted evidence exists. - -Matrix job state supplies visible progress; final shard duration/balance metadata -supplies timing evidence. Live pytest output is not required because the -unchanged isolated runner intentionally buffers and redacts child output. - -## Alternatives rejected - -- Test or coverage sampling. -- Path-based Backend skipping in the first chunk. -- Shared-database xdist processes. -- Unpinned marketplace sharding actions. -- Mutable external timing data without commit/inventory provenance. -- Allowing the aggregator to pass when an upstream job is skipped or cancelled. - -## Architecture and security boundaries - -- Planning and execution logic remain CI-only under backend scripts and GitHub - workflows; product modules are not imported as orchestration services. -- Planner input is treated as data and emitted module paths are validated before - command construction. -- Artifacts are accepted only for the actual checked-out tree and expected - canonical manifest digest, and coverage bytes are independently hashed. -- Top-level and job permissions explicitly set `contents: read` plus only minimum - run-scoped artifact access; no repository write exists and - `pull_request_target` is prohibited. -- No database URL/password, MinIO credential, environment dump, or runner - database metadata outside an allowlisted non-secret schema enters artifacts. - -## Verification strategy - -- Planner tests: filesystem/node completeness, parameterized nodes, zero-test and - collection failure, determinism, exact-once assignment, stable ties, - empty/malformed rejection, traversal/symlink rejection, shard bounds, and - canonical manifest-digest binding. -- Fan-in tests: missing, duplicate, extra, foreign-SHA, foreign-digest, malformed, - failed, cancelled, skipped, missing/duplicate/foreign node, count mismatch, - coverage-byte mismatch, symlink, traversal, unexpected-file, and coverage-file - mismatch rejection. -- Workflow tests: immutable action pins, fixed matrix size, isolated database per - job, no shared coverage path, explicit failure propagation, stable final check, - unchanged coverage floors, and no path-based skip. -- Existing runner, agent-gate, backend, coverage, and API E2E tests. -- Hosted exact-checked-out-tree timing report against PR #161. - -## Operator documentation - -Update canonical `docs/operations_backend_testing.md` with preflight, shard, API, -and fan-in diagnosis; fixed artifact contents and retention; whole-workflow versus -failed-job reruns and stale rejection; parallel runner cost; stable check identity; -progress semantics; and one-PR rollback. +Reduce Backend workflow wall-clock time without test sampling, arbitrary +weighted shards, weaker coverage, or weaker PostgreSQL contract evidence. + +## Selected design + +### 1. Dependency-based process lanes + +`backend/scripts/run_test_lanes.py` assigns every test module exactly once to the +no-PostgreSQL, schema-contract, control-plane, or execution-plane lane. All four +processes run concurrently in one job and write private coverage files. The +three database lanes share one PostgreSQL server but invoke +`run_isolated_tests.py` with independent temporary databases and roles. Both +runners emit secret-free heartbeats and bound every lane at 20 minutes. + +### 2. Cheap isolation for ordinary tests + +`backend/tests/conftest.py` owns the normal database reset. Before each +database-backed test it disposes pooled connections and truncates all mutable +public tables in one transaction while preserving: + +- `alembic_version`; +- immutable `actor_profile_migration_state` evidence; and +- the schema, constraints, and triggers installed by migrations. + +The fixture temporarily disables only the five known truncate guards, restores +the baseline `authority_control` row, and re-enables every guard in the same +transaction. Route and service modules do not run Alembic. + +### 3. Explicit PostgreSQL schema-contract lane + +Tests marked `postgres_schema_contract` own whole-schema Alembic transitions. +They receive a rebuilt head schema rather than the truncate reset and restore +`head` during teardown. This intentionally slower path is limited to migration +contracts. Lock, trigger, deferred-constraint, `asyncpg`, and concurrent +transaction proofs remain on real PostgreSQL. + +PGlite is not adopted for the Python suite. The schema migrated in the spike, +but the socket multiplexer did not close `asyncpg` connections reliably and does +not guarantee production PostgreSQL concurrency behavior. + +### 4. One required GitHub job + +The required context remains `Backend / test`. One job provides one PostgreSQL +service and one MinIO service, then runs in this order: + +1. internal evidence, lint, docstrings, and isolated-runner tests; +2. four semantic processes with exact module inventory validation; +3. local coverage combine and the destructive real API contract drill; +4. the unchanged 78 percent repository floor and every protected 90 percent + floor. + +There is no GitHub matrix, arbitrary planner, timing artifact, or cross-job +fan-in. Product tests execute exactly once across the lanes; runner lifecycle +tests execute once in their dedicated prerequisite step. + +## Correctness boundaries + +- No skipped, sampled, or reclassified product tests. +- No mutable database is shared between pytest processes. +- No production database is accepted by the isolated runner. +- No database URL, password, MinIO credential, or environment dump enters logs + or metadata. +- Destructive migration tests remain serialized by the database-scoped DDL lock. +- Coverage cannot compensate for a failed test or API contract drill. + +## Local verification + +- Cross-module smoke: 9 passed in 8.19 seconds. +- Projects: 235 passed in 179.53 seconds, down from 706–729 seconds. +- Tasks: 142 passed in 145.89 seconds, down from 535–798 seconds. +- Converted database modules: 534 passed in 124.21 seconds. +- Schema/reset contracts: 34 passed in 178.63 seconds. +- Semantic lanes: 1,826 passed exactly once in 314.40 seconds; 330.70 seconds + including process startup and local coverage combine. +- Previous single process: 1,815 passed in 919.48 seconds; 975.62 seconds wall. +- Lane inventory and failure-custody tests: 11 passed. +- Isolated-runner lifecycle: 17 passed in 13.52 seconds. +- Agent workflow gates: 91 passed. +- Real API contract drill: passed. +- Coverage: 87.58 percent global; protected reports range from 90.90 to + 100.00 percent. +- Ruff and `git diff --check`: passed. + +Hosted timing and exact checked-out-commit proof remain required before claiming +the GitHub workflow result. ## Rollback -If hosted sharding is unstable or slower, revert the implementation PR as one -workflow/script unit to restore the existing sequential job. Do not lower -thresholds, disable shards, or silently fall back inside the workflow. +If semantic lanes violate exact execution or resource isolation, revert the lane +runner and workflow invocation while retaining the central reset. Restore +correctness through one isolated runner; do not lower coverage, skip tests, or +add arbitrary weighted shards to hide fixture cost. -## Delivery order +## Delivery state -1. `WS-CI-001-01`: deterministic parallel full-suite execution and centralized - coverage fan-in. -2. `WS-CI-001-02`: optional measured routing/cache/telemetry improvements after - 01 has stable hosted evidence. - -Implementation does not begin until the reviewed 01 contract receives explicit -human approval. +`WS-CI-001-02` supersedes the earlier shard/fan-in implementation. Local +implementation and proof are complete under the user's direction. Push or PR is +still prohibited until canonical loop state records an explicit signed start for +this chunk. diff --git a/.agent-loop/initiatives/WS-CI-001-backend-ci-acceleration/RISKS.md b/.agent-loop/initiatives/WS-CI-001-backend-ci-acceleration/RISKS.md index 46196ab5..37f502f6 100644 --- a/.agent-loop/initiatives/WS-CI-001-backend-ci-acceleration/RISKS.md +++ b/.agent-loop/initiatives/WS-CI-001-backend-ci-acceleration/RISKS.md @@ -1,18 +1,17 @@ # RISKS: WS-CI-001 - Backend CI Acceleration | ID | Risk | Severity | Mitigation | -|---|---|---:|---| -| R1 | A test is omitted or duplicated | Critical | Filesystem modules plus stable preflight cardinality signatures and exact same-process collection/completion validation | -| R2 | Coverage is combined from incomplete, foreign, or altered evidence | Critical | Bind fixed artifacts and coverage SHA-256 to checked-out tree, shard ID, schema, and manifest digest | -| R3 | Upstream failure is hidden by dependency skipping | Critical | Always-run final check explicitly validates every dependency result | -| R4 | Shards interfere through shared database state | Critical | One isolated migrated database and role per shard process | -| R5 | Coverage thresholds are weakened | Critical | Preserve exact 78/90 commands and add workflow regression assertions | -| R6 | MinIO tests run without a real provider | High | Start pinned MinIO in all shards initially or prove a safe module map before narrowing | -| R7 | One large module controls wall time | Medium | Measure hosted shard duration; consider reviewed node-level split only later | -| R8 | Parallel jobs cost more runner minutes | Medium | Begin with four shards and compare aggregate minutes with PR #161 | -| R9 | Artifact names collide | High | Per-commit/per-shard names and strict unique-set fan-in | -| R10 | Untrusted paths reach shell execution | High | Canonical path validation and argument-array execution in repository script | -| R11 | Required check identity changes | Critical | Preserve final Backend `test` job and verify in workflow tests/GitHub PR | -| R12 | Plan scope expands into path-based skipping | High | Defer routing to separately approved 02 contract | -| R13 | Mutable PostgreSQL tag changes CI behavior | High | Replace `postgres:16` with a reviewed digest pin in 01 | -| R14 | Parameter display values change across pytest processes | Critical | Execute whole modules; compare exact collection/completion within one process and bind only stable test-base cardinalities across processes | +| --- | --- | ---: | --- | +| R1 | A test is omitted or duplicated | Critical | One ordinary pytest process discovers and executes the complete suite exactly once | +| R2 | Reset leaks data between tests | Critical | Truncate every mutable public table in one transaction before each database-backed test | +| R3 | Reset damages schema or migration evidence | Critical | Preserve `alembic_version` and actor migration evidence; run destructive migration proofs separately | +| R4 | Immutable triggers block reset or remain disabled | Critical | Disable only five reviewed truncate guards and re-enable them in the same transaction | +| R5 | Coverage thresholds are weakened | Critical | Preserve the exact 78/90 commands and workflow regression assertions | +| R6 | MinIO tests run without a real provider | High | Start one pinned MinIO service before the complete suite | +| R7 | The child hangs silently | High | Emit a secret-free heartbeat every 60 seconds and enforce a 20-minute child bound | +| R8 | PGlite diverges from production PostgreSQL | High | Keep asyncpg, locks, triggers, migrations, and concurrency proofs on real PostgreSQL | +| R9 | Parallel infrastructure returns without evidence | High | Regression-test that the workflow has one job and no matrix, shard tool, or fan-in artifacts | +| R10 | API drill state contaminates pytest | High | Run the API drill in a sequential isolated-runner invocation | +| R11 | Required check identity changes | Critical | Preserve the single `Backend / test` job and verify it in workflow tests | +| R12 | Fixture consolidation changes application behavior | High | Limit implementation to tests, runner supervision, workflow, and documentation | +| R13 | Mutable PostgreSQL tag changes CI behavior | High | Retain the reviewed digest-pinned PostgreSQL service image | 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 08d75b0c..f05370df 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,9 +1,16 @@ # STATUS: WS-CI-001 - Backend CI Acceleration -- Phase: post-merge timeout cleanup repair -- Active implementation chunk: `WS-CI-001-01R1` -- Proposed first chunk: `WS-CI-001-01` -- Human direction: prioritize full-suite CI acceleration before - `WS-ENG-001-04B` -- Current gate: exact rebased-head review, repair PR CI, and human review -- `WS-ENG-001-04B`: remains inactive +- Phase: local implementation and verification complete; hosted proof pending +- Canonical active implementation chunk: none for this initiative +- Local candidate chunk: `WS-CI-001-02` +- Human direction: keep production code unchanged; optimize only tests, + fixtures, runners, CI, and test documentation +- Verified runtime wins: projects 706–729s to 179.53s; tasks 535–798s to + 145.89s; converted database modules 534 tests in 124.21s +- Complete local proof: four dependency lanes executed all 1,826 tests once in + 314.40s and 330.70s wall, down from 975.62s wall; real API contract passed; + global coverage 87.58%; protected reports 90.90–100%; no temporary database or + role remained; agent workflow gates and Ruff passed +- Remaining environment proof: exact checked-out commit on hosted `Backend / test` +- Current repository gate: `WS-CI-001-02` requires an explicit signed start + before push or PR diff --git a/.agent-loop/initiatives/WS-CI-001-backend-ci-acceleration/chunks/WS-CI-001-02-runtime-stabilization-timing.md b/.agent-loop/initiatives/WS-CI-001-backend-ci-acceleration/chunks/WS-CI-001-02-runtime-stabilization-timing.md new file mode 100644 index 00000000..a55144fd --- /dev/null +++ b/.agent-loop/initiatives/WS-CI-001-backend-ci-acceleration/chunks/WS-CI-001-02-runtime-stabilization-timing.md @@ -0,0 +1,134 @@ +# Chunk Contract: WS-CI-001-02 — Migrate-Once Backend Test Runtime + +## Parent initiative + +`WS-CI-001` — Backend CI Acceleration + +## Goal + +Remove repeated per-test Alembic migrations, consolidate database isolation +behind one deep fixture, and run dependency-based processes in one CI job with +one PostgreSQL service without weakening tests or coverage. + +## Why this chunk exists + +Timing evidence showed that sharding was treating the symptom: + +- projects spent 589 of 706 seconds in setup and teardown; +- tasks spent 375 of 535 seconds in setup and teardown; +- actor and artifact suites had the same pattern; and +- nine fixtures independently repeated migration and cleanup logic. + +The isolated runner already migrates its owned database before pytest starts. +Ordinary fixtures then downgraded and upgraded that same schema for each test. +The source fix is to migrate once and restore an empty data baseline cheaply. + +## Risk class + +L1 + +## Allowed files + +- `.github/workflows/backend.yml` +- `backend/scripts/run_isolated_tests.py` +- `backend/scripts/run_test_lanes.py` +- `backend/tests/conftest.py` +- test-infrastructure and database-backed files under `backend/tests/` +- `scripts/test_agent_gates.py` +- `docs/operations_backend_testing.md` +- `.agent-loop/initiatives/WS-CI-001-backend-ci-acceleration/**` +- `.agent-loop/merge-intents/WS-CI-001-02.json` + +## Not allowed + +- backend application behavior or production migrations; +- skipping, sampling, reclassifying, or weakening tests; +- lowering the 78 percent repository floor or any protected 90 percent floor; +- using PGlite for lock, trigger, migration, or concurrent transaction proofs; +- shared mutable state between test processes; +- arbitrary weighted shards, cross-job coverage fan-in, or artifact-based + fallbacks. + +## Acceptance criteria + +- [x] The isolated runner migrates once per pytest process. +- [x] One central fixture resets all mutable public tables before each + database-backed test. +- [x] Alembic and immutable actor migration evidence survive every reset. +- [x] Truncate guards are disabled only for the five known guarded tables and + are re-enabled in the same transaction. +- [x] Module-local migration fixtures are removed. +- [x] Projects, tasks, auth, actors, checkers, audit, authorization, rate + controls, and artifact admission share the central reset seam. +- [x] The GitHub workflow uses one job, one PostgreSQL service, and one MinIO + service with four exact dependency lanes and local coverage combine. +- [x] Both runners emit secret-free heartbeats every 60 seconds and bound every + test process at 20 minutes. +- [x] Every retained backend test executes exactly once. +- [x] The 78 percent repository floor and all protected 90 percent floors remain + unchanged. +- [x] Local benchmark evidence shows projects and tasks are materially faster + than the measured sharded baseline. + +## Current benchmark evidence + +- Cross-module smoke: 9 tests passed in 8.19 seconds. +- Projects: 235 tests passed in 179.53 seconds, down from 706–729 seconds. +- Tasks: 142 tests passed in 145.89 seconds, down from 535–798 seconds. +- Converted database modules: 534 tests passed in 124.21 seconds. +- Schema and reset contracts: 34 tests passed in 178.63 seconds. +- Previous full coverage process: 1,815 tests passed in 919.48 seconds and + 975.62 seconds wall. +- Semantic lanes: all 1,826 tests passed exactly once in 314.40 seconds and + 330.70 seconds wall. +- Lane inventory and failure-custody tests: 11 passed. +- Runner lifecycle: 17 tests passed in 13.52 seconds. +- Real API contract: passed. +- Coverage: 87.58 percent global; protected reports 90.90–100.00 percent. +- PGlite probe: all migrations applied, but `asyncpg` and Alembic hung while + closing socket connections; PGlite remains unsuitable for the current Python + and concurrency seam. + +## Verification commands + +```bash +cd backend +ruff check tests scripts +python -m pytest -q tests/test_isolated_database_runner.py +# Run with a local administrator URL and real MinIO endpoint: +rm -f .coverage .coverage.* +python scripts/run_test_lanes.py \ + --metadata-dir /tmp/workstream-test-lanes \ + --summary-json /tmp/workstream-test-lanes.json \ + --timeout-seconds 1200 +coverage combine +cd .. +python -m pytest -q scripts/test_agent_gates.py +``` + +Local commands above passed. The exact reviewed commit still requires hosted +`Backend / test` proof for the semantic lanes, PostgreSQL, MinIO, API drill, and +every coverage floor. + +## Required reviewers + +- [ ] senior engineering +- [ ] QA/test +- [ ] security/auth +- [ ] product/ops +- [ ] architecture +- [ ] CI integrity +- [ ] docs +- [ ] reuse/dedup +- [ ] test delta + +## Human review focus + +Whether the reset preserves schema and trigger invariants, whether destructive +migration tests remain isolated, and whether any simplification suppresses +required test or coverage proof. + +## Stop conditions + +Local implementation and verification are user-directed. Stop before push or PR +if canonical loop state has not recorded a signed start for this chunk. diff --git a/.agent-loop/initiatives/WS-CI-001-backend-ci-acceleration/chunks/WS-CI-001-02-safe-routing-cache-timing.md b/.agent-loop/initiatives/WS-CI-001-backend-ci-acceleration/chunks/WS-CI-001-02-safe-routing-cache-timing.md deleted file mode 100644 index 90354075..00000000 --- a/.agent-loop/initiatives/WS-CI-001-backend-ci-acceleration/chunks/WS-CI-001-02-safe-routing-cache-timing.md +++ /dev/null @@ -1,74 +0,0 @@ -# Chunk Contract: WS-CI-001-02 — Safe Routing, Cache, and Timing Refinement - -## Parent initiative - -`WS-CI-001` — Backend CI Acceleration - -## Goal - -Use measured 01 evidence to consider fail-closed path routing, dependency cache, -and durable shard weights without weakening full-suite requirements. - -## Why this chunk exists - -Parallelization addresses elapsed test time first. Routing, caching, and timing -data have different trust and invalidation risks and require a separate review. - -## Approved plan reference - -- INTENT: `.agent-loop/initiatives/WS-CI-001-backend-ci-acceleration/INTENT.md` -- PLAN: `.agent-loop/initiatives/WS-CI-001-backend-ci-acceleration/PLAN.md` -- CHUNK_MAP: `.agent-loop/initiatives/WS-CI-001-backend-ci-acceleration/CHUNK_MAP.md` - -## Risk class - -L1 - -## SLA - -P2 - -## Start phase - -`planning` - -## Allowed files - -To be fixed only after 01 hosted evidence and separate discovery. - -## Not allowed - -Implementation, activation, or successor declaration before a separate human -approval; any coverage/test weakening; backend product changes; 04B activation. - -## Acceptance criteria - -- [ ] A separate reviewed amendment defines exact files and fail-closed routing. -- [ ] Cache and timing provenance cannot cross dependency or commit boundaries. -- [ ] Full-suite-required change classes default closed on ambiguity. - -## Verification commands - -To be defined from 01 hosted evidence. - -## Required reviewers - -- [ ] senior engineering -- [ ] QA/test -- [ ] security/auth -- [ ] product/ops -- [ ] architecture -- [ ] CI integrity -- [ ] docs -- [ ] reuse/dedup -- [ ] test delta - -## Human review focus - -Whether any optimization can suppress required proof and whether added cache or -telemetry complexity is justified by measured results. - -## Stop conditions - -Stop if 01 is not merged and stable, if scope is not explicit, or if the user has -not separately approved this chunk. diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml index 41c37726..e8f9b4a6 100644 --- a/.github/workflows/backend.yml +++ b/.github/workflows/backend.yml @@ -13,11 +13,9 @@ env: MINIO_IMAGE: quay.io/minio/minio:latest@sha256:14cea493d9a34af32f524e538b8346cf79f3321eff8e708c1e2960462bd8936e jobs: - preflight: + test: runs-on: ubuntu-latest - timeout-minutes: 30 - outputs: - tree_sha: ${{ steps.identity.outputs.tree_sha }} + timeout-minutes: 45 services: postgres: @@ -44,17 +42,6 @@ jobs: with: python-version: "3.12" - - id: identity - name: Bind checked-out tree - shell: bash - run: | - set -euo pipefail - tree_sha=$(git rev-parse HEAD) - if [[ ! "${tree_sha}" =~ ^[0-9a-f]{40}$ ]]; then - exit 1 - fi - echo "tree_sha=${tree_sha}" >> "${GITHUB_OUTPUT}" - - name: Internal review evidence gate run: python3 scripts/check_internal_review_evidence.py @@ -78,71 +65,6 @@ jobs: WORKSTREAM_TEST_ADMIN_DATABASE_URL: postgresql+asyncpg://workstream:workstream@localhost:5433/postgres run: python -m pytest -q tests/test_isolated_database_runner.py - - name: Collect and plan exact test inventory - shell: bash - run: | - set -euo pipefail - mkdir -p .ci/plan - python3 backend/scripts/ci_test_shards.py plan \ - --repository-root . \ - --tree-sha "${{ steps.identity.outputs.tree_sha }}" \ - --shards 4 \ - --output .ci/plan/shard-manifest.json - - - name: Upload immutable shard plan - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 - with: - name: backend-shard-plan-${{ steps.identity.outputs.tree_sha }} - path: .ci/plan/shard-manifest.json - if-no-files-found: error - retention-days: 7 - - shards: - needs: preflight - runs-on: ubuntu-latest - timeout-minutes: 90 - strategy: - fail-fast: false - matrix: - shard: [1, 2, 3, 4] - - services: - postgres: - image: public.ecr.aws/docker/library/postgres:16@sha256:33f923b05f64ca54ac4401c01126a6b92afe839a0aa0a52bc5aeb5cc958e5f20 - env: - POSTGRES_DB: workstream_test - POSTGRES_USER: workstream - POSTGRES_PASSWORD: workstream - ports: - - 5433:5432 - options: >- - --health-cmd "pg_isready -U workstream -d workstream_test" - --health-interval 5s - --health-timeout 5s - --health-retries 10 - - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 - with: - persist-credentials: false - fetch-depth: 0 - - - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 - with: - python-version: "3.12" - - - name: Install backend - working-directory: backend - run: | - python -m pip install --upgrade pip - python -m pip install -e ".[dev]" - - - name: Download exact shard plan - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 - with: - name: backend-shard-plan-${{ needs.preflight.outputs.tree_sha }} - path: .ci/plan - - name: Start real MinIO artifact provider shell: bash run: | @@ -161,65 +83,21 @@ jobs: docker logs workstream-minio exit 1 - - name: Run isolated shard ${{ matrix.shard }} + - name: Backend semantic-lane coverage + working-directory: backend env: + COVERAGE_FILE: .coverage + PYTEST_DISABLE_PLUGIN_AUTOLOAD: "1" WORKSTREAM_TEST_ADMIN_DATABASE_URL: postgresql+asyncpg://workstream:workstream@localhost:5433/postgres WORKSTREAM_TEST_MINIO_ENDPOINT: http://127.0.0.1:9000 - run: >- - python3 backend/scripts/ci_test_shards.py run-shard - --repository-root . - --manifest .ci/plan/shard-manifest.json - --shard ${{ matrix.shard }} - --bundle-dir .ci/shard-bundle - --database-metadata "${RUNNER_TEMP}/database-${{ matrix.shard }}.json" - - - name: Upload authenticated shard ${{ matrix.shard }} - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 - with: - name: backend-coverage-${{ needs.preflight.outputs.tree_sha }}-${{ matrix.shard }} - path: .ci/shard-bundle - if-no-files-found: error - retention-days: 7 - - api_e2e: - needs: preflight - runs-on: ubuntu-latest - timeout-minutes: 30 - - services: - postgres: - image: public.ecr.aws/docker/library/postgres:16@sha256:33f923b05f64ca54ac4401c01126a6b92afe839a0aa0a52bc5aeb5cc958e5f20 - env: - POSTGRES_DB: workstream_test - POSTGRES_USER: workstream - POSTGRES_PASSWORD: workstream - ports: - - 5433:5432 - options: >- - --health-cmd "pg_isready -U workstream -d workstream_test" - --health-interval 5s - --health-timeout 5s - --health-retries 10 - - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 - with: - persist-credentials: false - fetch-depth: 0 - - - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 - with: - python-version: "3.12" - - - name: Verify checked-out tree - shell: bash - run: test "$(git rev-parse HEAD)" = "${{ needs.preflight.outputs.tree_sha }}" - - - name: Install backend - working-directory: backend run: | - python -m pip install --upgrade pip - python -m pip install -e ".[dev]" + set -euo pipefail + rm -f .coverage .coverage.* + python scripts/run_test_lanes.py \ + --metadata-dir "${RUNNER_TEMP}/backend-lanes" \ + --summary-json "${RUNNER_TEMP}/backend-lane-summary.json" \ + --timeout-seconds 1200 + coverage combine - name: API contract real API e2e working-directory: backend @@ -228,97 +106,10 @@ jobs: run: >- python scripts/run_isolated_tests.py --metadata-json "${RUNNER_TEMP}/api-database.json" - --timeout-seconds 1500 + --timeout-seconds 600 -- python scripts/api_contract_e2e.py - test: - if: ${{ always() }} - needs: [preflight, shards, api_e2e] - runs-on: ubuntu-latest - timeout-minutes: 30 - - steps: - - name: Require every upstream proof - env: - PREFLIGHT_RESULT: ${{ needs.preflight.result }} - SHARDS_RESULT: ${{ needs.shards.result }} - API_E2E_RESULT: ${{ needs.api_e2e.result }} - shell: bash - run: | - set -euo pipefail - test "${PREFLIGHT_RESULT}" = success - test "${SHARDS_RESULT}" = success - test "${API_E2E_RESULT}" = success - - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 - with: - persist-credentials: false - fetch-depth: 0 - - - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 - with: - python-version: "3.12" - - - name: Install backend - working-directory: backend - run: | - python -m pip install --upgrade pip - python -m pip install -e ".[dev]" - - - name: Download exact shard plan - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 - with: - name: backend-shard-plan-${{ needs.preflight.outputs.tree_sha }} - path: .ci/plan - - - name: Download shard 1 - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 - with: - name: backend-coverage-${{ needs.preflight.outputs.tree_sha }}-1 - path: .ci/bundles/shard-1 - - - name: Download shard 2 - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 - with: - name: backend-coverage-${{ needs.preflight.outputs.tree_sha }}-2 - path: .ci/bundles/shard-2 - - - name: Download shard 3 - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 - with: - name: backend-coverage-${{ needs.preflight.outputs.tree_sha }}-3 - path: .ci/bundles/shard-3 - - - name: Download shard 4 - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 - with: - name: backend-coverage-${{ needs.preflight.outputs.tree_sha }}-4 - path: .ci/bundles/shard-4 - - - name: Validate exact fan-in and combine coverage - shell: bash - run: | - set -euo pipefail - test "$(git rev-parse HEAD)" = "${{ needs.preflight.outputs.tree_sha }}" - python3 backend/scripts/ci_test_shards.py fan-in \ - --manifest .ci/plan/shard-manifest.json \ - --tree-sha "${{ needs.preflight.outputs.tree_sha }}" \ - --bundles-root .ci/bundles \ - --output-dir .ci/combined-coverage \ - --summary-output .ci/fan-in-summary.json - python3 -m json.tool .ci/fan-in-summary.json - cd backend - coverage combine ../.ci/combined-coverage - - - name: Upload authenticated timing and balance report - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 - with: - name: backend-fan-in-${{ needs.preflight.outputs.tree_sha }} - path: .ci/fan-in-summary.json - if-no-files-found: error - retention-days: 7 - - - name: Backend full-suite coverage + - name: Repository coverage floor working-directory: backend run: coverage report --precision=2 --fail-under=78 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/scripts/ci_test_shards.py b/backend/scripts/ci_test_shards.py deleted file mode 100644 index 601f5c61..00000000 --- a/backend/scripts/ci_test_shards.py +++ /dev/null @@ -1,703 +0,0 @@ -#!/usr/bin/env python3 -"""Plan, execute, and validate isolated backend CI test shards.""" - -from __future__ import annotations - -import argparse -import hashlib -import json -import os -from pathlib import Path, PurePosixPath -import re -import shutil -import subprocess -import sys -import tempfile -import time -from typing import Any - -SCHEMA_VERSION = 2 -SHARD_COUNT = 4 -EXCLUDED_MODULE = "backend/tests/test_isolated_database_runner.py" -MODULE_RE = re.compile(r"backend/tests/test_[a-z0-9_]+\.py") -TREE_RE = re.compile(r"[0-9a-f]{40}") -BUNDLE_FILES = {"coverage.data", "result.json"} -COLLECTED_NODES_ENV = "WORKSTREAM_CI_COLLECTED_NODES" -COMPLETED_NODES_ENV = "WORKSTREAM_CI_COMPLETED_NODES" -PYTEST_PLUGINS = ( - "-p", - "pytest_asyncio.plugin", - "-p", - "pytest_cov.plugin", - "-p", - "scripts.ci_test_shards", -) - - -class ShardError(RuntimeError): - """A stable CI shard planning or evidence failure.""" - - -def _append_node(destination: str, node_id: str) -> None: - data = (json.dumps(node_id) + "\n").encode() - flags = os.O_WRONLY | os.O_APPEND - if hasattr(os, "O_NOFOLLOW"): - flags |= os.O_NOFOLLOW - descriptor = os.open(destination, flags) - try: - os.write(descriptor, data) - finally: - os.close(descriptor) - - -def pytest_collection_finish(session: Any) -> None: - """Record the final selected inventory from the executing pytest process.""" - destination = os.environ.get(COLLECTED_NODES_ENV) - if not destination: - return - for item in session.items: - _append_node(destination, item.nodeid) - - -def pytest_runtest_logfinish(nodeid: str, location: tuple[str, int | None, str]) -> None: - """Record a node only after its actual pytest lifecycle has finished.""" - del location - destination = os.environ.get(COMPLETED_NODES_ENV) - if not destination: - return - _append_node(destination, nodeid) - - -def _json_bytes(value: Any) -> bytes: - return (json.dumps(value, indent=2, sort_keys=True) + "\n").encode() - - -def _sha256(data: bytes) -> str: - return hashlib.sha256(data).hexdigest() - - -def _regular_file(path: Path) -> bool: - return not path.is_symlink() and path.is_file() - - -def _tree_sha(repository_root: Path) -> str: - result = subprocess.run( - ["git", "rev-parse", "HEAD"], - cwd=repository_root, - check=True, - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ).stdout.strip() - if TREE_RE.fullmatch(result) is None: - raise ShardError("invalid_tree_sha") - return result - - -def _validate_tree_sha(value: str) -> str: - if TREE_RE.fullmatch(value) is None: - raise ShardError("invalid_tree_sha") - return value - - -def discover_modules(repository_root: Path) -> list[str]: - """Return the canonical symlink-free backend test module inventory.""" - tests_root = repository_root / "backend/tests" - if tests_root.is_symlink() or not tests_root.is_dir(): - raise ShardError("invalid_tests_root") - modules: list[str] = [] - for path in sorted(tests_root.glob("test_*.py")): - relative = path.relative_to(repository_root).as_posix() - if MODULE_RE.fullmatch(relative) is None or not _regular_file(path): - raise ShardError("invalid_test_module") - if relative != EXCLUDED_MODULE: - modules.append(relative) - if not modules or len(modules) != len(set(modules)): - raise ShardError("invalid_module_inventory") - return modules - - -def _module_from_node(node_id: str) -> str: - if "::" not in node_id: - raise ShardError("invalid_node_id") - module_part = node_id.split("::", 1)[0] - candidate = f"backend/{module_part}" - if MODULE_RE.fullmatch(candidate) is None: - raise ShardError("invalid_node_module") - return candidate - - -def _node_base(node_id: str) -> str: - """Remove only pytest's final parameter display value from a node ID.""" - _module_from_node(node_id) - if node_id.endswith("]") and "[" in node_id: - return node_id.split("[", 1)[0] - return node_id - - -def _node_signature(nodes: list[str]) -> list[dict[str, Any]]: - counts: dict[str, int] = {} - for node in nodes: - base = _node_base(node) - counts[base] = counts.get(base, 0) + 1 - return [{"base": base, "count": counts[base]} for base in sorted(counts)] - - -def _read_node_log(path: Path) -> list[str]: - try: - nodes = [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines()] - except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc: - raise ShardError("invalid_runtime_nodes") from exc - if not nodes or not all(isinstance(node, str) for node in nodes): - raise ShardError("invalid_runtime_nodes") - return sorted(nodes) - - -def collect_nodes(repository_root: Path, modules: list[str]) -> dict[str, list[str]]: - """Collect canonical pytest node IDs for exactly the supplied modules.""" - backend_root = repository_root / "backend" - relative_modules = [str(PurePosixPath(path).relative_to("backend")) for path in modules] - collection_env = os.environ.copy() - collection_env["PYTEST_DISABLE_PLUGIN_AUTOLOAD"] = "1" - result = subprocess.run( - [ - sys.executable, - "-m", - "pytest", - "--collect-only", - "-q", - *PYTEST_PLUGINS, - *relative_modules, - ], - cwd=backend_root, - env=collection_env, - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - check=False, - ) - if result.returncode != 0: - raise ShardError("pytest_collection_failed") - expected = set(modules) - collected: dict[str, list[str]] = {module: [] for module in modules} - seen: set[str] = set() - for raw_line in result.stdout.splitlines(): - line = raw_line.strip() - if not line or "::" not in line: - continue - module = _module_from_node(line) - if module not in expected or line in seen: - raise ShardError("foreign_or_duplicate_node") - collected[module].append(line) - seen.add(line) - if not seen or any(not nodes for nodes in collected.values()): - raise ShardError("zero_test_module") - return {module: sorted(nodes) for module, nodes in sorted(collected.items())} - - -def _manifest_body( - tree_sha: str, module_rows: list[dict[str, Any]], shard_count: int -) -> dict[str, Any]: - _validate_tree_sha(tree_sha) - if shard_count != SHARD_COUNT: - raise ShardError("invalid_shard_count") - ordered_modules = sorted(row["path"] for row in module_rows) - if not ordered_modules or len(ordered_modules) != len(set(ordered_modules)): - raise ShardError("invalid_module_inventory") - rows_by_module = {row["path"]: row for row in module_rows} - if len(rows_by_module) != len(module_rows): - raise ShardError("invalid_module_inventory") - for module, row in rows_by_module.items(): - if MODULE_RE.fullmatch(module) is None or module == EXCLUDED_MODULE: - raise ShardError("invalid_test_module") - signature, weight = row.get("node_signature"), row.get("weight") - if not isinstance(signature, list) or not signature or not isinstance(weight, int): - raise ShardError("invalid_node_inventory") - if any( - not isinstance(item, dict) - or set(item) != {"base", "count"} - or not isinstance(item["base"], str) - or not isinstance(item["count"], int) - or isinstance(item["count"], bool) - or item["count"] < 1 - or _module_from_node(item["base"]) != module - for item in signature - ): - raise ShardError("node_module_mismatch") - if signature != sorted(signature, key=lambda item: item["base"]): - raise ShardError("invalid_node_inventory") - if len({item["base"] for item in signature}) != len(signature) or weight != sum( - item["count"] for item in signature - ): - raise ShardError("invalid_node_inventory") - - bins: list[dict[str, Any]] = [ - {"id": shard_id, "modules": [], "weight": 0} for shard_id in range(1, shard_count + 1) - ] - weighted = sorted( - ((rows_by_module[module]["weight"], module) for module in ordered_modules), - key=lambda item: (-item[0], item[1]), - ) - for weight, module in weighted: - target = min(bins, key=lambda item: (item["weight"], item["id"])) - target["modules"].append(module) - target["weight"] += weight - for shard in bins: - shard["modules"].sort() - if not shard["modules"]: - raise ShardError("empty_shard") - - canonical_rows = [rows_by_module[module] for module in ordered_modules] - return { - "excluded_modules": [EXCLUDED_MODULE], - "modules": canonical_rows, - "schema_version": SCHEMA_VERSION, - "shard_count": shard_count, - "shards": bins, - "tree_sha": tree_sha, - } - - -def build_manifest( - tree_sha: str, modules_to_nodes: dict[str, list[str]], shard_count: int -) -> dict[str, Any]: - """Build a canonical manifest with a digest over its executable body.""" - for module, nodes in modules_to_nodes.items(): - if MODULE_RE.fullmatch(module) is None or module == EXCLUDED_MODULE: - raise ShardError("invalid_test_module") - if not nodes or nodes != sorted(set(nodes)): - raise ShardError("invalid_node_inventory") - if any(_module_from_node(node) != module for node in nodes): - raise ShardError("node_module_mismatch") - rows = [ - {"node_signature": _node_signature(nodes), "path": module, "weight": len(nodes)} - for module, nodes in sorted(modules_to_nodes.items()) - ] - body = _manifest_body(tree_sha, rows, shard_count) - return {**body, "manifest_sha256": _sha256(_json_bytes(body))} - - -def validate_manifest(manifest: dict[str, Any]) -> dict[str, Any]: - """Validate and canonically reproduce one shard manifest.""" - required = { - "excluded_modules", - "manifest_sha256", - "modules", - "schema_version", - "shard_count", - "shards", - "tree_sha", - } - if not isinstance(manifest, dict) or set(manifest) != required: - raise ShardError("invalid_manifest") - digest = manifest.get("manifest_sha256") - modules = manifest.get("modules") - shard_count = manifest.get("shard_count") - if ( - manifest.get("schema_version") != SCHEMA_VERSION - or manifest.get("excluded_modules") != [EXCLUDED_MODULE] - or not isinstance(digest, str) - or re.fullmatch(r"[0-9a-f]{64}", digest) is None - or not isinstance(modules, list) - or not isinstance(shard_count, int) - or isinstance(shard_count, bool) - or not isinstance(manifest.get("shards"), list) - ): - raise ShardError("invalid_manifest") - rows: list[dict[str, Any]] = [] - for row in modules: - if not isinstance(row, dict) or set(row) != {"node_signature", "path", "weight"}: - raise ShardError("invalid_manifest") - if not isinstance(row["path"], str): - raise ShardError("invalid_manifest") - rows.append(row) - body = _manifest_body(str(manifest.get("tree_sha", "")), rows, shard_count) - reproduced = {**body, "manifest_sha256": _sha256(_json_bytes(body))} - if manifest != reproduced or digest != reproduced["manifest_sha256"]: - raise ShardError("manifest_digest_mismatch") - return reproduced - - -def load_manifest(path: Path) -> dict[str, Any]: - if not _regular_file(path): - raise ShardError("invalid_manifest_file") - try: - value = json.loads(path.read_text(encoding="utf-8")) - except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc: - raise ShardError("invalid_manifest_file") from exc - return validate_manifest(value) - - -def _shard(manifest: dict[str, Any], shard_id: int) -> dict[str, Any]: - matches = [row for row in manifest["shards"] if row["id"] == shard_id] - if len(matches) != 1: - raise ShardError("invalid_shard_id") - return matches[0] - - -def _assert_checked_out_tree(repository_root: Path, expected: str) -> None: - if _tree_sha(repository_root) != _validate_tree_sha(expected): - raise ShardError("checked_out_tree_mismatch") - - -def _safe_empty_directory(path: Path) -> None: - if path.is_symlink() or (path.exists() and (not path.is_dir() or any(path.iterdir()))): - raise ShardError("invalid_output_directory") - path.mkdir(parents=True, exist_ok=True) - - -def _write_json(path: Path, value: Any) -> None: - if path.exists() or path.is_symlink(): - raise ShardError("output_exists") - path.write_bytes(_json_bytes(value)) - - -def run_shard( - repository_root: Path, - manifest_path: Path, - shard_id: int, - bundle_dir: Path, - database_metadata: Path, -) -> None: - """Run one manifest shard and emit a fixed, non-secret evidence bundle.""" - manifest = load_manifest(manifest_path) - _assert_checked_out_tree(repository_root, manifest["tree_sha"]) - shard = _shard(manifest, shard_id) - expected_rows = [row for row in manifest["modules"] if row["path"] in shard["modules"]] - - _safe_empty_directory(bundle_dir) - coverage_path = bundle_dir / "coverage.data" - collected_path = database_metadata.with_name(f"collected-nodes-{shard_id}.jsonl") - completed_path = database_metadata.with_name(f"completed-nodes-{shard_id}.jsonl") - for path in (collected_path, completed_path): - if path.exists() or path.is_symlink(): - raise ShardError("runtime_nodes_path_exists") - descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) - os.close(descriptor) - env = os.environ.copy() - env["COVERAGE_FILE"] = str(coverage_path.resolve()) - env["PYTEST_DISABLE_PLUGIN_AUTOLOAD"] = "1" - env[COLLECTED_NODES_ENV] = str(collected_path.resolve()) - env[COMPLETED_NODES_ENV] = str(completed_path.resolve()) - started = time.monotonic() - command = [ - sys.executable, - "scripts/run_isolated_tests.py", - "--metadata-json", - str(database_metadata), - "--timeout-seconds", - "4800", - "--", - sys.executable, - "-m", - "pytest", - "-q", - *PYTEST_PLUGINS, - *(str(PurePosixPath(module).relative_to("backend")) for module in shard["modules"]), - "--cov=app", - "--cov-report=", - ] - result = subprocess.run(command, cwd=repository_root / "backend", env=env, check=False) - if result.returncode != 0: - raise ShardError("shard_tests_failed") - try: - collected_nodes = _read_node_log(collected_path) - completed_nodes = _read_node_log(completed_path) - except ShardError: - raise - if collected_nodes != completed_nodes or len(collected_nodes) != len(set(collected_nodes)): - raise ShardError("shard_execution_mismatch") - runtime_by_module = {module: [] for module in shard["modules"]} - for node in collected_nodes: - module = _module_from_node(node) - if module not in runtime_by_module: - raise ShardError("shard_execution_mismatch") - runtime_by_module[module].append(node) - runtime_signatures = [ - { - "node_signature": _node_signature(runtime_by_module[row["path"]]), - "path": row["path"], - "weight": len(runtime_by_module[row["path"]]), - } - for row in expected_rows - ] - if runtime_signatures != expected_rows: - raise ShardError("shard_signature_mismatch") - if not _regular_file(coverage_path): - raise ShardError("missing_coverage") - coverage_digest = _sha256(coverage_path.read_bytes()) - metadata = { - "coverage_file": "coverage.data", - "coverage_sha256": coverage_digest, - "duration_seconds": round(time.monotonic() - started, 3), - "manifest_sha256": manifest["manifest_sha256"], - "modules": shard["modules"], - "collected_node_ids": collected_nodes, - "completed_node_ids": completed_nodes, - "schema_version": SCHEMA_VERSION, - "shard_id": shard_id, - "tree_sha": manifest["tree_sha"], - } - _write_json(bundle_dir / "result.json", metadata) - - -def _safe_bundle_files(bundle: Path) -> dict[str, Path]: - if bundle.is_symlink() or not bundle.is_dir(): - raise ShardError("invalid_bundle_directory") - found: dict[str, Path] = {} - for path in bundle.iterdir(): - if path.name not in BUNDLE_FILES or not _regular_file(path): - raise ShardError("unexpected_bundle_path") - found[path.name] = path - if set(found) != BUNDLE_FILES: - raise ShardError("incomplete_bundle") - return found - - -def validate_fan_in( - manifest: dict[str, Any], - bundles_root: Path, - output_dir: Path, - summary_output: Path | None = None, -) -> list[Path]: - """Validate the exact shard set and copy authenticated coverage for combine.""" - if bundles_root.is_symlink() or not bundles_root.is_dir(): - raise ShardError("invalid_bundles_root") - expected_dirs = {f"shard-{index}" for index in range(1, SHARD_COUNT + 1)} - entries = {path.name for path in bundles_root.iterdir()} - if entries != expected_dirs: - raise ShardError("unexpected_bundle_set") - - expected_node_count = sum(module["weight"] for module in manifest["modules"]) - observed_modules: list[str] = [] - coverage_sources: list[Path] = [] - durations: list[float] = [] - for shard_id in range(1, SHARD_COUNT + 1): - files = _safe_bundle_files(bundles_root / f"shard-{shard_id}") - try: - result = json.loads(files["result.json"].read_text(encoding="utf-8")) - except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc: - raise ShardError("invalid_result_metadata") from exc - required = { - "coverage_file", - "coverage_sha256", - "duration_seconds", - "manifest_sha256", - "modules", - "collected_node_ids", - "completed_node_ids", - "schema_version", - "shard_id", - "tree_sha", - } - if not isinstance(result, dict) or set(result) != required: - raise ShardError("invalid_result_metadata") - expected_shard = _shard(manifest, shard_id) - if ( - result["schema_version"] != SCHEMA_VERSION - or result["shard_id"] != shard_id - or result["tree_sha"] != manifest["tree_sha"] - or result["manifest_sha256"] != manifest["manifest_sha256"] - or result["modules"] != expected_shard["modules"] - or result["coverage_file"] != "coverage.data" - or not isinstance(result["duration_seconds"], (int, float)) - or isinstance(result["duration_seconds"], bool) - or result["duration_seconds"] < 0 - ): - raise ShardError("result_provenance_mismatch") - collected = result["collected_node_ids"] - completed = result["completed_node_ids"] - if ( - not isinstance(collected, list) - or not all(isinstance(node, str) for node in collected) - or collected != completed - or collected != sorted(set(collected)) - ): - raise ShardError("shard_node_mismatch") - runtime_by_module = {module: [] for module in expected_shard["modules"]} - for node in collected: - module = _module_from_node(node) - if module not in runtime_by_module: - raise ShardError("shard_node_mismatch") - runtime_by_module[module].append(node) - expected_rows = [ - row for row in manifest["modules"] if row["path"] in expected_shard["modules"] - ] - runtime_rows = [ - { - "node_signature": _node_signature(runtime_by_module[row["path"]]), - "path": row["path"], - "weight": len(runtime_by_module[row["path"]]), - } - for row in expected_rows - ] - if runtime_rows != expected_rows: - raise ShardError("shard_node_mismatch") - coverage = files["coverage.data"] - if result["coverage_sha256"] != _sha256(coverage.read_bytes()): - raise ShardError("coverage_digest_mismatch") - observed_modules.extend(result["modules"]) - coverage_sources.append(coverage) - durations.append(float(result["duration_seconds"])) - expected_modules = sorted(module["path"] for module in manifest["modules"]) - if sorted(observed_modules) != expected_modules or len(observed_modules) != len( - set(observed_modules) - ): - raise ShardError("fan_in_module_mismatch") - - _safe_empty_directory(output_dir) - outputs: list[Path] = [] - for shard_id, source in enumerate(coverage_sources, 1): - target = output_dir / f".coverage.shard-{shard_id}" - shutil.copyfile(source, target, follow_symlinks=False) - outputs.append(target) - if summary_output is not None: - _write_json( - summary_output, - { - "manifest_sha256": manifest["manifest_sha256"], - "module_count": len(expected_modules), - "node_count": expected_node_count, - "schema_version": SCHEMA_VERSION, - "timing": { - "imbalance_seconds": round(max(durations) - min(durations), 3), - "maximum_seconds": max(durations), - "total_runner_seconds": round(sum(durations), 3), - }, - "shards": [ - { - "duration_seconds": durations[index - 1], - "id": index, - "module_count": len(_shard(manifest, index)["modules"]), - "node_count": _shard(manifest, index)["weight"], - } - for index in range(1, SHARD_COUNT + 1) - ], - "tree_sha": manifest["tree_sha"], - }, - ) - return outputs - - -def _plan(repository_root: Path, tree_sha: str, shard_count: int) -> dict[str, Any]: - modules = discover_modules(repository_root) - return build_manifest(tree_sha, collect_nodes(repository_root, modules), shard_count) - - -def _dry_run(repository_root: Path, shard_count: int) -> None: - manifest = _plan(repository_root, _tree_sha(repository_root), shard_count) - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - bundles = root / "bundles" - bundles.mkdir() - for shard in manifest["shards"]: - bundle = bundles / f"shard-{shard['id']}" - bundle.mkdir() - coverage = bundle / "coverage.data" - coverage.write_bytes(f"dry-run-{shard['id']}".encode()) - nodes = [ - f"{item['base']}[{index}]" if item["count"] > 1 else item["base"] - for module in manifest["modules"] - if module["path"] in shard["modules"] - for item in module["node_signature"] - for index in range(item["count"]) - ] - nodes.sort() - _write_json( - bundle / "result.json", - { - "coverage_file": "coverage.data", - "coverage_sha256": _sha256(coverage.read_bytes()), - "duration_seconds": 0, - "manifest_sha256": manifest["manifest_sha256"], - "modules": shard["modules"], - "collected_node_ids": nodes, - "completed_node_ids": nodes, - "schema_version": SCHEMA_VERSION, - "shard_id": shard["id"], - "tree_sha": manifest["tree_sha"], - }, - ) - validate_fan_in(manifest, bundles, root / "combined") - print( - json.dumps( - { - "manifest_sha256": manifest["manifest_sha256"], - "modules": len(manifest["modules"]), - "nodes": sum(row["weight"] for row in manifest["modules"]), - "shard_weights": [row["weight"] for row in manifest["shards"]], - "tree_sha": manifest["tree_sha"], - }, - sort_keys=True, - ) - ) - - -def main() -> int: - """Run the requested shard lifecycle command.""" - parser = argparse.ArgumentParser() - subparsers = parser.add_subparsers(dest="command", required=True) - - plan = subparsers.add_parser("plan") - plan.add_argument("--repository-root", type=Path, required=True) - plan.add_argument("--tree-sha", required=True) - plan.add_argument("--shards", type=int, default=SHARD_COUNT) - plan.add_argument("--output", type=Path, required=True) - - run = subparsers.add_parser("run-shard") - run.add_argument("--repository-root", type=Path, required=True) - run.add_argument("--manifest", type=Path, required=True) - run.add_argument("--shard", type=int, required=True) - run.add_argument("--bundle-dir", type=Path, required=True) - run.add_argument("--database-metadata", type=Path, required=True) - - fan_in = subparsers.add_parser("fan-in") - fan_in.add_argument("--manifest", type=Path, required=True) - fan_in.add_argument("--tree-sha", required=True) - fan_in.add_argument("--bundles-root", type=Path, required=True) - fan_in.add_argument("--output-dir", type=Path, required=True) - fan_in.add_argument("--summary-output", type=Path, required=True) - - dry_run = subparsers.add_parser("dry-run") - dry_run.add_argument("--repository-root", type=Path, required=True) - dry_run.add_argument("--shards", type=int, default=SHARD_COUNT) - - args = parser.parse_args() - try: - if args.command == "plan": - repository_root = args.repository_root.resolve() - manifest = _plan(repository_root, args.tree_sha, args.shards) - if args.output.exists() or args.output.is_symlink(): - raise ShardError("output_exists") - args.output.parent.mkdir(parents=True, exist_ok=True) - args.output.write_bytes(_json_bytes(manifest)) - print(manifest["manifest_sha256"]) - elif args.command == "run-shard": - run_shard( - args.repository_root.resolve(), - args.manifest, - args.shard, - args.bundle_dir, - args.database_metadata, - ) - elif args.command == "fan-in": - manifest = load_manifest(args.manifest) - if manifest["tree_sha"] != _validate_tree_sha(args.tree_sha): - raise ShardError("checked_out_tree_mismatch") - validate_fan_in( - manifest, - args.bundles_root, - args.output_dir, - args.summary_output, - ) - else: - _dry_run(args.repository_root.resolve(), args.shards) - except (OSError, subprocess.SubprocessError, ShardError) as exc: - code = exc.args[0] if isinstance(exc, ShardError) else "ci_shard_operation_failed" - print(f"backend CI shard operation failed: {code}", file=sys.stderr) - return 1 - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/backend/scripts/run_isolated_tests.py b/backend/scripts/run_isolated_tests.py index 2d980589..a35b534e 100644 --- a/backend/scripts/run_isolated_tests.py +++ b/backend/scripts/run_isolated_tests.py @@ -13,11 +13,13 @@ import secrets import signal import subprocess +from subprocess import TimeoutExpired import sys +import time from urllib.parse import quote, urlsplit, urlunsplit -import asyncpg -from alembic.config import Config +import asyncpg # type: ignore[import-not-found] +from alembic.config import Config # type: ignore[import-not-found] from alembic.script import ScriptDirectory ROOT = Path(__file__).resolve().parents[1] @@ -28,6 +30,7 @@ OVERRIDE_ENV = "WORKSTREAM_ALLOW_NONLOCAL_E2E_DATABASE" INTERRUPTED = False TERMINATION_GRACE_SECONDS = 2.0 +HEARTBEAT_SECONDS = 60.0 class RunnerError(RuntimeError): @@ -40,10 +43,12 @@ def _urls(admin_url: str) -> tuple[str, str, str, str]: raise RunnerError("unsafe_admin_database") if not parsed.path.strip("/") or parsed.query or parsed.fragment: raise RunnerError("unsafe_admin_database") - suffix = hashlib.sha256( - f"{ROOT.resolve()}:{secrets.token_hex(16)}".encode() - ).hexdigest()[:12] - name, role, password = f"workstream_test_{suffix}", f"workstream_role_{suffix}", secrets.token_hex(24) + suffix = hashlib.sha256(f"{ROOT.resolve()}:{secrets.token_hex(16)}".encode()).hexdigest()[:12] + name, role, password = ( + f"workstream_test_{suffix}", + f"workstream_role_{suffix}", + secrets.token_hex(24), + ) _identifiers(name, role) try: host = f"[{parsed.hostname}]" if ":" in parsed.hostname else parsed.hostname @@ -124,11 +129,14 @@ async def _drop(admin_url: str, name: str, role: str) -> None: await connection.fetch( "SELECT pg_terminate_backend(pid) FROM pg_stat_activity " "WHERE (datname = $1 OR usename = $2) AND pid <> pg_backend_pid()", - name, role, + name, + role, ) if owner: await connection.execute(f'DROP DATABASE "{name}"') - if await connection.fetchval("SELECT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = $1)", role): + if await connection.fetchval( + "SELECT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = $1)", role + ): await connection.execute(f'DROP ROLE "{role}"') finally: await connection.close() @@ -173,18 +181,31 @@ def _run( stderr=subprocess.PIPE, start_new_session=True, ) + started_at = time.monotonic() try: - stdout, stderr = process.communicate(timeout=timeout) - return process.returncode, stdout, stderr - except subprocess.TimeoutExpired: - _signal_group(process, signal.SIGKILL) - stdout, stderr = process.communicate() - return 124, stdout, stderr + while True: + elapsed = time.monotonic() - started_at + if timeout is not None and elapsed >= timeout: + _signal_group(process, signal.SIGKILL) + stdout, stderr = process.communicate() + return 124, stdout, stderr + wait_seconds = HEARTBEAT_SECONDS + if timeout is not None: + wait_seconds = min(wait_seconds, timeout - elapsed) + try: + stdout, stderr = process.communicate(timeout=wait_seconds) + return process.returncode, stdout, stderr + except TimeoutExpired: + print( + "isolated-test child active: " + f"elapsed_seconds={time.monotonic() - started_at:.0f}", + flush=True, + ) except KeyboardInterrupt: _signal_group(process, signal.SIGTERM) try: stdout, stderr = process.communicate(timeout=TERMINATION_GRACE_SECONDS) - except subprocess.TimeoutExpired: + except TimeoutExpired: _signal_group(process, signal.SIGKILL) stdout, stderr = process.communicate() return 130, stdout, stderr @@ -220,6 +241,8 @@ def main() -> int: INTERRUPTED = False admin_url = os.environ.pop(ADMIN_ENV, "") owned = False + name = "" + role = "" result = 2 previous_sigint = signal.signal(signal.SIGINT, _defer_interrupt) previous_sigterm = signal.signal(signal.SIGTERM, _defer_interrupt) diff --git a/backend/scripts/run_test_lanes.py b/backend/scripts/run_test_lanes.py new file mode 100644 index 00000000..89109899 --- /dev/null +++ b/backend/scripts/run_test_lanes.py @@ -0,0 +1,378 @@ +from __future__ import annotations + +import argparse +from collections import Counter +from dataclasses import dataclass +import json +import os +from pathlib import Path, PurePosixPath +import re +import signal +import subprocess +import sys +import time +from typing import TextIO + +ROOT = Path(__file__).resolve().parents[1] +TESTS_DIR = ROOT / "tests" +ISOLATED_RUNNER = ROOT / "scripts" / "run_isolated_tests.py" +ADMIN_ENV = "WORKSTREAM_TEST_ADMIN_DATABASE_URL" +HEARTBEAT_SECONDS = 60.0 +CLEANUP_GRACE_SECONDS = 120.0 +LANE_NAME_PATTERN = re.compile(r"^[a-z][a-z0-9_]*$") + + +class LaneError(RuntimeError): + """The semantic test-lane contract is invalid.""" + + +@dataclass(frozen=True) +class TestLane: + """One dependency-based pytest process.""" + + name: str + modules: tuple[str, ...] + requires_postgres: bool + + +LANES = ( + TestLane( + name="no_postgres", + requires_postgres=False, + modules=( + "tests/test_actor_legacy_classification.py", + "tests/test_actor_migration_tools.py", + "tests/test_agent_runtime.py", + "tests/test_api_contract_e2e.py", + "tests/test_api_controls.py", + "tests/test_app.py", + "tests/test_artifact_architecture.py", + "tests/test_artifact_cleanup_wiring.py", + "tests/test_artifact_preparation.py", + "tests/test_artifact_store_conformance.py", + "tests/test_artifact_verification.py", + "tests/test_artifacts.py", + "tests/test_assertion_helpers.py", + "tests/test_aws_credential_isolation.py", + "tests/test_ci_test_lanes.py", + "tests/test_config.py", + "tests/test_coverage_contract.py", + "tests/test_external_service_adapters.py", + "tests/test_local_artifact_store.py", + "tests/test_s3_artifact_store.py", + ), + ), + TestLane( + name="schema_contracts", + requires_postgres=True, + modules=( + "tests/test_alembic.py", + "tests/test_database_reset.py", + ), + ), + TestLane( + name="control_plane", + requires_postgres=True, + modules=( + "tests/test_actors.py", + "tests/test_api_rate_controls.py", + "tests/test_audit.py", + "tests/test_auth.py", + "tests/test_authorization.py", + "tests/test_projects.py", + ), + ), + TestLane( + name="execution_plane", + requires_postgres=True, + modules=( + "tests/test_artifact_admission.py", + "tests/test_artifact_recovery.py", + "tests/test_checkers.py", + "tests/test_db_session.py", + "tests/test_outbox.py", + "tests/test_tasks.py", + ), + ), +) +EXCLUDED_MODULES = ("tests/test_isolated_database_runner.py",) +INTERRUPTED = False + + +@dataclass +class ActiveLane: + """Runtime custody for one lane process and its private log.""" + + lane: TestLane + process: subprocess.Popen[bytes] + log: TextIO + log_path: Path + started_at: float + interrupted_at: float | None = None + timed_out: bool = False + + +def discover_test_modules(tests_dir: Path = TESTS_DIR, root: Path = ROOT) -> tuple[str, ...]: + """Discover regular top-level backend test modules.""" + modules: list[str] = [] + for path in sorted(tests_dir.glob("test_*.py")): + if path.is_symlink() or not path.is_file(): + raise LaneError("invalid_test_module") + try: + modules.append(path.relative_to(root).as_posix()) + except ValueError as exc: + raise LaneError("invalid_test_module") from exc + if not modules: + raise LaneError("empty_test_inventory") + return tuple(modules) + + +def validate_lane_inventory( + discovered: tuple[str, ...], + *, + lanes: tuple[TestLane, ...] = LANES, + excluded: tuple[str, ...] = EXCLUDED_MODULES, +) -> None: + """Require every discovered module in exactly one lane or explicit exclusion.""" + lane_names = [lane.name for lane in lanes] + if len(set(lane_names)) != len(lane_names) or any( + LANE_NAME_PATTERN.fullmatch(name) is None for name in lane_names + ): + raise LaneError("invalid_lane_names") + assigned = [module for lane in lanes for module in lane.modules] + declared = [*assigned, *excluded] + if any(not _safe_module_path(module) for module in declared): + raise LaneError("invalid_lane_module") + if duplicates := sorted(name for name, count in Counter(declared).items() if count != 1): + raise LaneError(f"duplicate_lane_modules:{','.join(duplicates)}") + discovered_set = set(discovered) + declared_set = set(declared) + if missing := sorted(discovered_set - declared_set): + raise LaneError(f"missing_lane_modules:{','.join(missing)}") + if foreign := sorted(declared_set - discovered_set): + raise LaneError(f"foreign_lane_modules:{','.join(foreign)}") + + +def _safe_module_path(value: str) -> bool: + path = PurePosixPath(value) + return ( + not path.is_absolute() + and path.parent == PurePosixPath("tests") + and path.name.startswith("test_") + and path.suffix == ".py" + and ".." not in path.parts + ) + + +def lane_environment(lane: TestLane) -> dict[str, str]: + """Build one private process environment and coverage filename.""" + env = os.environ.copy() + env["COVERAGE_FILE"] = f".coverage.{lane.name}" + env["PYTEST_DISABLE_PLUGIN_AUTOLOAD"] = "1" + if not lane.requires_postgres: + for name in ( + ADMIN_ENV, + "WORKSTREAM_DATABASE_URL", + "WORKSTREAM_TEST_DATABASE_URL", + ): + env.pop(name, None) + return env + + +def lane_command(lane: TestLane, metadata_dir: Path, timeout_seconds: float) -> list[str]: + """Construct argv without shell interpolation or credentials.""" + pytest_command = [ + sys.executable, + "-m", + "pytest", + "-q", + "-p", + "pytest_asyncio.plugin", + "-p", + "pytest_cov.plugin", + "--cov=app", + "--cov-report=", + "--durations=25", + *lane.modules, + ] + if not lane.requires_postgres: + return pytest_command + return [ + sys.executable, + str(ISOLATED_RUNNER), + "--metadata-json", + str(metadata_dir / f"{lane.name}.json"), + "--timeout-seconds", + f"{timeout_seconds:g}", + "--", + *pytest_command, + ] + + +def _signal_lane(active: ActiveLane, value: int = signal.SIGINT) -> None: + if active.process.poll() is not None: + return + try: + os.killpg(active.process.pid, value) + except ProcessLookupError: + pass + + +def _handle_interrupt(_signum: int, _frame: object) -> None: + global INTERRUPTED + INTERRUPTED = True + + +def _emit_lane_log(active: ActiveLane, exit_code: int, elapsed: float) -> None: + active.log.flush() + active.log.close() + print( + f"=== test lane {active.lane.name}: exit_code={exit_code} " + f"elapsed_seconds={elapsed:.2f} ===", + flush=True, + ) + data = active.log_path.read_bytes() + if data: + sys.stdout.buffer.write(data) + if not data.endswith(b"\n"): + sys.stdout.buffer.write(b"\n") + sys.stdout.buffer.flush() + + +def _prepare_outputs(metadata_dir: Path, summary_json: Path) -> None: + if ( + metadata_dir.exists() + or metadata_dir.is_symlink() + or summary_json.exists() + or summary_json.is_symlink() + or not summary_json.parent.is_dir() + ): + raise LaneError("invalid_lane_outputs") + for lane in LANES: + coverage_path = ROOT / f".coverage.{lane.name}" + if coverage_path.exists() or coverage_path.is_symlink(): + raise LaneError("stale_lane_coverage") + metadata_dir.mkdir(mode=0o700) + + +def run_lanes(metadata_dir: Path, summary_json: Path, timeout_seconds: float) -> int: + """Run all semantic lanes concurrently and retain exact local evidence.""" + if timeout_seconds <= 0: + raise LaneError("invalid_lane_timeout") + validate_lane_inventory(discover_test_modules(), lanes=LANES) + if any(lane.requires_postgres for lane in LANES) and not os.environ.get(ADMIN_ENV): + raise LaneError("missing_admin_database_url") + _prepare_outputs(metadata_dir, summary_json) + + previous_sigint = signal.signal(signal.SIGINT, _handle_interrupt) + previous_sigterm = signal.signal(signal.SIGTERM, _handle_interrupt) + active: dict[str, ActiveLane] = {} + results: dict[str, dict[str, object]] = {} + started = time.monotonic() + next_heartbeat = started + HEARTBEAT_SECONDS + stopping = False + try: + for lane in LANES: + log_path = metadata_dir / f"{lane.name}.log" + log = log_path.open("w", encoding="utf-8") + process = subprocess.Popen( + lane_command(lane, metadata_dir, timeout_seconds), + cwd=ROOT, + env=lane_environment(lane), + stdout=log, + stderr=subprocess.STDOUT, + start_new_session=True, + ) + active[lane.name] = ActiveLane( + lane=lane, + process=process, + log=log, + log_path=log_path, + started_at=time.monotonic(), + ) + + while active: + now = time.monotonic() + if INTERRUPTED and not stopping: + stopping = True + for item in active.values(): + item.interrupted_at = now + _signal_lane(item) + for name, item in list(active.items()): + elapsed = now - item.started_at + if elapsed >= timeout_seconds and item.interrupted_at is None: + item.timed_out = True + item.interrupted_at = now + _signal_lane(item) + elif ( + item.interrupted_at is not None + and now - item.interrupted_at >= CLEANUP_GRACE_SECONDS + ): + _signal_lane(item, signal.SIGKILL) + exit_code = item.process.poll() + if exit_code is None: + continue + _emit_lane_log(item, exit_code, elapsed) + results[name] = { + "elapsed_seconds": round(elapsed, 3), + "exit_code": exit_code, + "modules": list(item.lane.modules), + "requires_postgres": item.lane.requires_postgres, + "timed_out": item.timed_out, + } + del active[name] + if exit_code != 0 and not stopping: + stopping = True + for other in active.values(): + other.interrupted_at = now + _signal_lane(other) + if active and now >= next_heartbeat: + names = ",".join(sorted(active)) + print( + f"test lanes active: elapsed_seconds={int(now - started)} lanes={names}", + flush=True, + ) + next_heartbeat = now + HEARTBEAT_SECONDS + if active: + time.sleep(0.1) + finally: + for item in active.values(): + _signal_lane(item, signal.SIGKILL) + item.log.close() + signal.signal(signal.SIGINT, previous_sigint) + signal.signal(signal.SIGTERM, previous_sigterm) + + summary = { + "elapsed_seconds": round(time.monotonic() - started, 3), + "lanes": results, + "schema_version": 1, + } + summary_json.write_text( + json.dumps(summary, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + return ( + 0 + if len(results) == len(LANES) + and all(result["exit_code"] == 0 for result in results.values()) + else 1 + ) + + +def main() -> int: + """Validate arguments and run the dependency lanes.""" + parser = argparse.ArgumentParser() + parser.add_argument("--metadata-dir", required=True, type=Path) + parser.add_argument("--summary-json", required=True, type=Path) + parser.add_argument("--timeout-seconds", default=1200.0, type=float) + args = parser.parse_args() + try: + return run_lanes(args.metadata_dir, args.summary_json, args.timeout_seconds) + except (LaneError, OSError) as exc: + code = exc.args[0] if isinstance(exc, LaneError) else "lane_operation_failed" + print(f"test lane runner failed: {code}", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 8df8f8a0..070550fd 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -1,19 +1,56 @@ 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 -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 DDL_LOCK_DIRECTORY = Path("/tmp") +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]] + + +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: + connection = await asyncpg.connect(database_url.replace("+asyncpg", "")) + try: + 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 _reset_test_database_state( @@ -21,78 +58,32 @@ async def _reset_test_database_state( *, include_canonical_actors: bool = False, ) -> None: - """Reset test-owned append-only state under explicit privileged custody.""" + """Restore the already-migrated isolated database to its empty baseline.""" + del include_canonical_actors connection = await asyncpg.connect(database_url.replace("+asyncpg", "")) try: async with connection.transaction(): - 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" + rows = await connection.fetch( + "select tablename, quote_ident(tablename) as identifier " + "from pg_tables where schemaname = 'public' " + "and tablename not in ('alembic_version', 'actor_profile_migration_state') " + "order by tablename" ) + tables = {row["tablename"]: row["identifier"] for row in rows} + if not tables or not set(TRUNCATE_GUARDED_TABLES).issubset(tables): + raise RuntimeError("test database is not migrated to the expected schema") + for name in TRUNCATE_GUARDED_TABLES: + await connection.execute(f"alter table {tables[name]} disable trigger user") 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 +103,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 +114,42 @@ 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()) + if owns_schema: + with postgres_ddl_lock(postgres_database_url): + command.upgrade(_alembic_config(), "head") + 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..22b466b8 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, @@ -411,12 +392,12 @@ class Repository: calls: list[str] = [] async def get_actor_profile(self, _actor_profile_id, *, for_update=False): - assert for_update is True + assert for_update self.calls.append("profile") return self.locked_profile async def get_identity_link_by_id(self, _identity_link_id, *, for_update=False): - assert for_update is True + assert for_update self.calls.append("link") return self.locked_link @@ -478,12 +459,12 @@ class Repository: calls: list[tuple[str, str, str]] = [] async def get_actor_profile(self, actor_profile_id, *, for_update=False): - assert for_update is True + assert for_update self.calls.append(("profile", actor_profile_id, "")) return self.locked_profile async def get_identity_link(self, issuer, subject, *, for_update=False): - assert for_update is True + assert for_update self.calls.append(("link", issuer, subject)) return self.locked_link @@ -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 @@ -586,7 +569,7 @@ async def test_actor_timestamp_touch_fails_closed_before_writes_on_missing_rows( repository = ActorRepository(object()) # type: ignore[arg-type] async def missing_profile(_actor_profile_id, *, for_update=False): - assert for_update is True + assert for_update return None repository.get_actor_profile = missing_profile # type: ignore[method-assign] @@ -594,11 +577,11 @@ async def missing_profile(_actor_profile_id, *, for_update=False): await repository.touch_verified_actor(profile, link) async def locked_profile(_actor_profile_id, *, for_update=False): - assert for_update is True + assert for_update return profile async def missing_link(_identity_link_id, *, for_update=False): - assert for_update is True + assert for_update return None repository.get_actor_profile = locked_profile # type: ignore[method-assign] @@ -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 @@ -857,7 +840,7 @@ async def fail_update(*_args, **_kwargs): assert response.status_code == 503 assert response.json()["error"]["code"] == "service_unavailable" - assert response.json()["error"]["retryable"] is True + assert response.json()["error"]["retryable"] async with db_session.get_session_factory()() as session: update_evidence = await session.scalar( select(func.count()) @@ -905,7 +888,7 @@ async def fail_evidence(*_args, **_kwargs): assert response.status_code == 503 assert response.json()["error"]["code"] == "service_unavailable" - assert response.json()["error"]["retryable"] is True + assert response.json()["error"]["retryable"] async with db_session.get_session_factory()() as session: timestamps_after = ( await session.execute( @@ -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..949d2a75 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] @@ -691,7 +693,7 @@ def test_post_submit_policy_upgrade_blocks_pre_provenance_runtime_rows( finally: command.downgrade(config, "base") - assert columns_exist is False + assert not columns_exist def test_canonical_actor_registry_separates_authority_from_legacy_workflow_metadata( @@ -1068,17 +1070,15 @@ 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, - } - ) - assert lock_observed is True + 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 def test_bootstrap_admin_grant_schema_is_immutable_and_guarded( @@ -1239,10 +1239,11 @@ 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 - assert mapped["private_evidence_columns"] is False + for digest_key in ("source_digest", "manifest_digest", "envelope_digest"): + digest = mapped[digest_key] + assert isinstance(digest, str) + assert len(digest) == 64 + assert not mapped["private_evidence_columns"] assert asyncio.run(_service_identity_guards(isolated_database_env, service_id)) == { "identity_update_rejected": True, "kind_update_rejected": True, @@ -1472,7 +1473,7 @@ async def cleanup() -> None: state = asyncio.run(schema_state()) assert state["nullable"] == "YES" assert state["default"] is None - assert state["human_verified"] is True + assert state["human_verified"] assert "subject_kind" in str(state["constraint"]) assert "last_verified_at IS NOT NULL" in str(state["constraint"]) asyncio.run(insert_service_and_reject_null_human()) @@ -1728,7 +1729,7 @@ def guarded_upgrade() -> None: upgrade_future = pool.submit(guarded_upgrade) assert upgrade_started.wait(timeout=5) time.sleep(0.2) - assert upgrade_future.done() is False + assert not upgrade_future.done() release_insert.set() insert_future.result(timeout=5) with pytest.raises( @@ -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)) == ( @@ -2684,7 +2693,7 @@ def guarded_downgrade() -> None: downgrade_future = pool.submit(guarded_downgrade) assert downgrade_started.wait(timeout=5) time.sleep(0.2) - assert downgrade_future.done() is False + assert not downgrade_future.done() release_insert.set() insert_future.result(timeout=5) with pytest.raises(RuntimeError, match="non-empty API rate controls"): @@ -3145,7 +3154,7 @@ def test_authority_idempotency_schema_preserves_audit_and_guards_downgrade( "truncate": True, "database_timestamps": True, } - assert downgrade_lock_observed is True + assert downgrade_lock_observed assert refused == {"revision": "0019_authority_idempotency", "records": 1, "orphan": 1} assert preserved == {"revision": "0018_authority_audit_evidence", "records": None, "orphan": 1} @@ -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, @@ -7599,12 +7603,11 @@ async def _exercise_contributor_lineage_guards( text("update task_assignments set assigned_by='updated' where id=:id"), {"id": assignment_id}, ) - results["unrelated_update_preserved"] = ( + results["unrelated_update_preserved"] = bool( await connection.scalar( text("select contributor_id=:actor from task_assignments where id=:id"), {"id": assignment_id, "actor": human_id}, ) - is True ) for actor_id, status in ( (suspended_id, "suspended"), diff --git a/backend/tests/test_api_rate_controls.py b/backend/tests/test_api_rate_controls.py index 12f65689..5919a1c8 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 " @@ -425,7 +411,7 @@ async def consume_once(): ), {"scope": FIRST_ACCESS_SCOPE, "digest": probe_digest}, ) - assert independent.allowed is True + assert independent.allowed assert probe_count == 0 assert ( await _stored_rate_row(rate_control_factory, FIRST_ACCESS_SCOPE, independent_digest) @@ -474,7 +460,7 @@ async def test_rate_control_saturates_bigint_and_prunes_only_bounded_other_rows( ) ) assert decision.request_count == 9_223_372_036_854_775_807 - assert decision.allowed is False + assert not decision.allowed assert expired == 25 @@ -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 @@ -587,7 +571,7 @@ async def fail_execute(*_args, **_kwargs): window_seconds=60, secret=RATE_SECRET, ) - assert session.rolled_back is True + assert session.rolled_back async def successful_consume(*_args, **_kwargs): return ConsumedCounter(now, now, now + timedelta(seconds=60), 1) @@ -610,7 +594,7 @@ async def fail_prune(*_args, **_kwargs): window_seconds=60, secret=RATE_SECRET, ) - assert prune_session.rolled_back is True + assert prune_session.rolled_back commit_session = _FakeSession(commit_error=True) monkeypatch.setattr(ApiRateControlRepository, "consume", successful_consume) @@ -624,7 +608,7 @@ async def fail_prune(*_args, **_kwargs): window_seconds=60, secret=RATE_SECRET, ) - assert commit_session.rolled_back is True + assert commit_session.rolled_back async def test_retry_after_uses_only_returned_database_clock( @@ -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..a088f69b 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 @@ -2687,10 +2680,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 +2699,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 +2979,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, ) @@ -3090,7 +3084,7 @@ async def test_exact_replay_returns_one_attempt_and_one_charge_set( namespace, ).admit(request) - assert replay.replayed is True + assert replay.replayed assert replay.attempt_id == first.attempt_id assert replay.charge_ids == first.charge_ids assert await _count(session, ArtifactPutAttempt) == 1 @@ -3209,7 +3203,7 @@ async def test_exact_replay_reacquires_released_charges_under_capacity( first_request ) - assert replay.replayed is True + assert replay.replayed assert replay.attempt_id == first.attempt_id refreshed_first_charges = ( ( @@ -3369,7 +3363,7 @@ async def admit(item_id: str, context: AuthorizationContext): ) async with factory() as session: - assert all(result.replayed is False for result in results) + assert all(not result.replayed for result in results) counters = (await session.execute(select(ArtifactAdmissionScope))).scalars().all() assert {counter.counted_bytes for counter in counters} == {4} assert await _count(session, ArtifactAdmissionCharge) == 7 @@ -3943,14 +3937,15 @@ 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") def test_artifact_admission_migration_refuses_populated_downgrade( @@ -4038,3 +4033,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_audit.py b/backend/tests/test_audit.py index ee98e3d8..a4251740 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: @@ -941,7 +932,7 @@ async def test_authority_writer_persists_typed_privacy_neutral_events(audit_fact assert event["claim_snapshot"] == {} assert event["event_payload"] == {} assert event["auth_source"] == "local_authority" - assert event["is_dev_auth"] is False + assert not event["is_dev_auth"] assert stored_values[0]["idempotency_reference"] is None assert future_idempotency is not None @@ -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..e807d5c1 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: @@ -1158,9 +1134,9 @@ async def test_pre_submit_check_returns_feedback_without_durable_run( assert response.status_code == 200, response.text body = response.json() - assert body["authoritative"] is False + assert not body["authoritative"] assert body["status"] == "failed" - assert body["eligible_to_submit"] is False + assert not body["eligible_to_submit"] result_names = {result["checker_name"] for result in body["results"]} assert { "check_submission_packet", @@ -1171,8 +1147,7 @@ async def test_pre_submit_check_returns_feedback_without_durable_run( "check_confidentiality_attestation", }.issubset(result_names) assert any( - result["checker_name"] == "check_evidence_integrity" - and result["would_block_if_submitted"] is True + result["checker_name"] == "check_evidence_integrity" and result["would_block_if_submitted"] for result in body["results"] ) @@ -1199,20 +1174,17 @@ async def test_pre_submit_chunk8_matrix_flags_missing_evidence_and_warning( assert response.status_code == 200, response.text body = response.json() - assert body["authoritative"] is False + assert not body["authoritative"] assert body["status"] == "failed" - assert body["eligible_to_submit"] is False + assert not body["eligible_to_submit"] result_by_name = {result["checker_name"]: result for result in body["results"]} assert result_by_name["check_evidence_present"]["status"] == "failed" - assert result_by_name["check_evidence_present"]["would_block_if_submitted"] is True + assert result_by_name["check_evidence_present"]["would_block_if_submitted"] assert result_by_name["check_required_files"]["status"] == "passed" assert result_by_name["check_forbidden_files"]["status"] == "passed" 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"] - is False - ) + assert not result_by_name["check_low_quality_generated_artifacts"]["would_block_if_submitted"] async with db_session.get_session_factory()() as session: rows = (await session.execute(CheckerRun.__table__.select())).all() @@ -1233,7 +1205,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 +1216,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 +1312,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 +1378,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 +1407,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 +1441,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 +1515,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 +1570,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 +1631,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( @@ -1665,8 +1681,8 @@ async def test_checker_run_retry_supersedes_previous_current_run( ) assert listed.status_code == 200, listed.text assert [item["attempt_number"] for item in listed.json()] == [1, 2] - assert listed.json()[0]["is_current_for_submission"] is False - assert listed.json()[1]["is_current_for_submission"] is True + assert not listed.json()[0]["is_current_for_submission"] + assert listed.json()[1]["is_current_for_submission"] async def test_duplicate_artifact_fails_before_submission_row( @@ -1698,12 +1714,18 @@ async def test_duplicate_artifact_fails_before_submission_row( if result["checker_name"] == "check_evidence_integrity" ) assert duplicate_result["status"] == "failed" - assert duplicate_result["would_block_if_submitted"] is True + assert duplicate_result["would_block_if_submitted"] 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" @@ -1740,7 +1762,7 @@ async def test_chunk8_missing_required_file_fails_pre_submit_without_submission( if result["checker_name"] == "check_required_files" ) assert required_files["status"] == "failed" - assert required_files["would_block_if_submitted"] is True + assert required_files["would_block_if_submitted"] assert "missing required artifact files" in required_files["worker_message"] @@ -1801,7 +1823,7 @@ async def test_chunk8_default_blocking_checker_survives_omitted_blocking_severit if result["checker_name"] == "check_required_files" ) assert required_files["status"] == "failed" - assert required_files["would_block_if_submitted"] is True + assert required_files["would_block_if_submitted"] async def test_chunk8_forbidden_file_blocks_without_worker_path_leakage( @@ -1833,7 +1855,7 @@ async def test_chunk8_forbidden_file_blocks_without_worker_path_leakage( if result["checker_name"] == "check_forbidden_files" ) assert forbidden["status"] == "failed" - assert forbidden["would_block_if_submitted"] is True + assert forbidden["would_block_if_submitted"] assert ".env" not in forbidden["worker_message"] assert "secrets/" not in forbidden["worker_message"] assert "local://" not in forbidden["worker_message"] @@ -1861,7 +1883,7 @@ async def test_chunk8_confidentiality_attestation_blocks_generic_text( if result["checker_name"] == "check_confidentiality_attestation" ) assert attestation["status"] == "failed" - assert attestation["would_block_if_submitted"] is True + assert attestation["would_block_if_submitted"] assert "confidentiality attestation" in attestation["worker_message"] @@ -1881,7 +1903,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 @@ -1891,7 +1915,7 @@ async def test_chunk8_low_quality_generated_artifacts_warns_without_blocking( if result["checker_name"] == "check_low_quality_generated_artifacts" ) assert low_quality["status"] == "warning" - assert low_quality["blocks_review"] is False + assert not low_quality["blocks_review"] async def test_checker_caused_revision_resubmits_fixed_version_through_api( @@ -1912,7 +1936,7 @@ async def test_checker_caused_revision_resubmits_fixed_version_through_api( json={"submission": v1_payload}, ) assert precheck_v1.status_code == 200, precheck_v1.text - assert precheck_v1.json()["eligible_to_submit"] is True + assert precheck_v1.json()["eligible_to_submit"] v1 = await checker_client.post( f"/api/v1/tasks/{started_task['id']}/submissions", @@ -1930,7 +1954,7 @@ async def test_checker_caused_revision_resubmits_fixed_version_through_api( if result["checker_name"] == "check_low_quality_generated_artifacts" ) assert low_quality["status"] == "failed" - assert low_quality["blocks_review"] is True + assert low_quality["blocks_review"] assert low_quality["worker_message"] assert low_quality["worker_suggested_fix"] @@ -1941,7 +1965,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 +1977,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" ] @@ -2058,7 +2083,7 @@ async def test_checker_caused_revision_resubmits_fixed_version_through_api( json={"submission": v2_payload}, ) assert precheck_v2.status_code == 200, precheck_v2.text - assert precheck_v2.json()["eligible_to_submit"] is True + assert precheck_v2.json()["eligible_to_submit"] v2 = await checker_client.post( f"/api/v1/tasks/{started_task['id']}/submissions", headers=auth_headers(), @@ -2087,7 +2112,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 +2133,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 +2186,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( @@ -2173,8 +2197,8 @@ async def test_chunk8_task_setup_blocked_takes_priority_over_worker_revision( if result["checker_name"] == "check_acceptance_criteria_present" ) assert setup_result["status"] == "failed" - assert setup_result["blocks_review"] is True - assert setup_result["worker_visible"] is False + assert setup_result["blocks_review"] + assert not setup_result["worker_visible"] assert "worker_message" not in setup_result async with db_session.get_session_factory()() as session: task = await session.get(WorkstreamTask, started_task["id"]) @@ -2352,10 +2376,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 @@ -2441,7 +2469,7 @@ async def test_chunk10_checker_trial_runs_sample_submissions_through_real_api( if result["checker_name"] == "check_acceptance_criteria_present" ) assert setup_result["status"] == "failed" - assert setup_result["worker_visible"] is False + assert not setup_result["worker_visible"] async with db_session.get_session_factory()() as session: task = await session.get(WorkstreamTask, started_task["id"]) @@ -2537,7 +2565,7 @@ async def test_worker_can_read_only_worker_visible_checker_result_fields( assert body["results"] assert all("message" not in result for result in body["results"]) assert all(result["metadata"] == {} for result in body["results"]) - assert all(result["worker_visible"] is True for result in body["results"]) + assert all(result["worker_visible"] for result in body["results"]) listed = await checker_client.get( f"/api/v1/submissions/{created.json()['id']}/checker-runs", @@ -2767,7 +2795,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 +2808,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_ci_test_lanes.py b/backend/tests/test_ci_test_lanes.py new file mode 100644 index 00000000..2d3f9fd9 --- /dev/null +++ b/backend/tests/test_ci_test_lanes.py @@ -0,0 +1,190 @@ +from __future__ import annotations + +from dataclasses import replace +import json +from pathlib import Path +import sys +import time + +import pytest # type: ignore[import-not-found] + +import scripts.run_test_lanes as lane_runner +from scripts.run_test_lanes import ( + ADMIN_ENV, + EXCLUDED_MODULES, + LANES, + LaneError, + TestLane as LaneDefinition, + discover_test_modules, + lane_command, + lane_environment, + validate_lane_inventory, +) + + +def test_committed_semantic_lanes_cover_every_test_module_exactly_once() -> None: + discovered = discover_test_modules() + + validate_lane_inventory(discovered) + + assigned = [module for lane in LANES for module in lane.modules] + assert len(assigned) == len(set(assigned)) + assert set(assigned).isdisjoint(EXCLUDED_MODULES) + assert set(assigned) | set(EXCLUDED_MODULES) == set(discovered) + assert {lane.name for lane in LANES} == { + "no_postgres", + "schema_contracts", + "control_plane", + "execution_plane", + } + + +@pytest.mark.parametrize( + ("mutation", "message"), + ( + ("missing", "missing_lane_modules"), + ("duplicate", "duplicate_lane_modules"), + ("foreign", "foreign_lane_modules"), + ("unsafe", "invalid_lane_module"), + ("name", "invalid_lane_names"), + ), +) +def test_lane_inventory_rejects_incomplete_or_ambiguous_assignments( + mutation: str, + message: str, +) -> None: + discovered = discover_test_modules() + lanes = list(LANES) + first = lanes[0] + if mutation == "missing": + lanes[0] = replace(first, modules=first.modules[1:]) + elif mutation == "duplicate": + lanes[0] = replace(first, modules=(*first.modules, LANES[1].modules[0])) + elif mutation == "foreign": + lanes[0] = replace(first, modules=(*first.modules, "tests/test_foreign.py")) + elif mutation == "unsafe": + lanes[0] = replace(first, modules=(*first.modules, "../test_escape.py")) + else: + lanes[0] = replace(first, name="Invalid Lane") + + with pytest.raises(LaneError, match=message): + validate_lane_inventory(discovered, lanes=tuple(lanes)) + + +def test_discovery_rejects_symlinked_test_module(tmp_path: Path) -> None: + tests = tmp_path / "tests" + tests.mkdir() + target = tmp_path / "target.py" + target.write_text("def test_target(): pass\n", encoding="utf-8") + (tests / "test_link.py").symlink_to(target) + + with pytest.raises(LaneError, match="invalid_test_module"): + discover_test_modules(tests, tmp_path) + + +def test_lane_commands_use_python_argv_and_private_coverage_files(tmp_path: Path) -> None: + no_postgres = next(lane for lane in LANES if not lane.requires_postgres) + postgres = next(lane for lane in LANES if lane.requires_postgres) + + direct = lane_command(no_postgres, tmp_path, 1200) + isolated = lane_command(postgres, tmp_path, 1200) + + assert direct[:3] == [direct[0], "-m", "pytest"] + assert "scripts/run_isolated_tests.py" not in " ".join(direct) + assert isolated[1].endswith("scripts/run_isolated_tests.py") + assert isolated[isolated.index("--timeout-seconds") + 1] == "1200" + assert isolated[-len(postgres.modules) :] == list(postgres.modules) + assert "--cov=app" in direct and "--cov=app" in isolated + assert "--cov-report=" in direct and "--cov-report=" in isolated + assert "--durations=25" in direct and "--durations=25" in isolated + + +def test_no_postgres_environment_drops_database_credentials( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv(ADMIN_ENV, "postgresql+asyncpg://admin:secret@localhost/postgres") + monkeypatch.setenv("WORKSTREAM_DATABASE_URL", "postgresql+asyncpg://app:secret@host/db") + monkeypatch.setenv("WORKSTREAM_TEST_DATABASE_URL", "postgresql+asyncpg://test:secret@host/db") + no_postgres = next(lane for lane in LANES if not lane.requires_postgres) + postgres = next(lane for lane in LANES if lane.requires_postgres) + + direct_env = lane_environment(no_postgres) + postgres_env = lane_environment(postgres) + + assert ADMIN_ENV not in direct_env + assert "WORKSTREAM_DATABASE_URL" not in direct_env + assert "WORKSTREAM_TEST_DATABASE_URL" not in direct_env + assert direct_env["COVERAGE_FILE"] == ".coverage.no_postgres" + assert postgres_env[ADMIN_ENV].endswith("/postgres") + assert postgres_env["COVERAGE_FILE"].startswith(".coverage.") + + +def test_lane_runner_fails_fast_and_records_every_started_process( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + lanes = ( + LaneDefinition( + name="failing_lane", + modules=("tests/test_fake_failure.py",), + requires_postgres=False, + ), + LaneDefinition( + name="interrupted_lane", + modules=("tests/test_fake_slow.py",), + requires_postgres=False, + ), + ) + + def fake_command( + lane: LaneDefinition, + _metadata_dir: Path, + _timeout_seconds: float, + ) -> list[str]: + if lane.name == "failing_lane": + return [sys.executable, "-c", "raise SystemExit(1)"] + return [sys.executable, "-c", "import time; time.sleep(30)"] + + monkeypatch.setattr(lane_runner, "LANES", lanes) + monkeypatch.setattr(lane_runner, "lane_command", fake_command) + monkeypatch.setattr( + lane_runner, + "discover_test_modules", + lambda: ( + *(module for lane in lanes for module in lane.modules), + *EXCLUDED_MODULES, + ), + ) + lane_runner.INTERRUPTED = False + metadata_dir = tmp_path / "metadata" + summary_json = tmp_path / "summary.json" + + started = time.monotonic() + result = lane_runner.run_lanes(metadata_dir, summary_json, 10) + elapsed = time.monotonic() - started + try: + summary = json.loads(summary_json.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise AssertionError(f"lane summary was not readable: {exc}") from exc + + assert result == 1 + assert elapsed < 5 + assert set(summary["lanes"]) == {"failing_lane", "interrupted_lane"} + assert summary["lanes"]["failing_lane"]["exit_code"] == 1 + assert summary["lanes"]["interrupted_lane"]["exit_code"] != 0 + + +def test_lane_contract_rejects_duplicate_exclusion() -> None: + discovered = discover_test_modules() + lane = LaneDefinition( + name="duplicate_exclusion", + modules=(EXCLUDED_MODULES[0],), + requires_postgres=False, + ) + + with pytest.raises(LaneError, match="duplicate_lane_modules"): + validate_lane_inventory( + discovered, + lanes=(*LANES, lane), + excluded=EXCLUDED_MODULES, + ) diff --git a/backend/tests/test_ci_test_shards.py b/backend/tests/test_ci_test_shards.py deleted file mode 100644 index d2506a40..00000000 --- a/backend/tests/test_ci_test_shards.py +++ /dev/null @@ -1,448 +0,0 @@ -"""Tests for deterministic backend CI shard evidence.""" - -from __future__ import annotations - -import importlib.util -import json -from pathlib import Path - -import pytest - - -SCRIPT = Path(__file__).resolve().parents[1] / "scripts/ci_test_shards.py" -SPEC = importlib.util.spec_from_file_location("ci_test_shards", SCRIPT) -assert SPEC is not None and SPEC.loader is not None -shards = importlib.util.module_from_spec(SPEC) -SPEC.loader.exec_module(shards) - -TREE_SHA = "a" * 40 - - -def _nodes() -> dict[str, list[str]]: - return { - "backend/tests/test_alpha.py": [ - "tests/test_alpha.py::test_a", - "tests/test_alpha.py::test_b[value]", - "tests/test_alpha.py::test_c", - "tests/test_alpha.py::test_d", - ], - "backend/tests/test_beta.py": [ - "tests/test_beta.py::test_a", - "tests/test_beta.py::test_b", - "tests/test_beta.py::test_c", - ], - "backend/tests/test_delta.py": ["tests/test_delta.py::test_a"], - "backend/tests/test_epsilon.py": ["tests/test_epsilon.py::test_a"], - "backend/tests/test_gamma.py": [ - "tests/test_gamma.py::test_a", - "tests/test_gamma.py::test_b", - ], - } - - -def _write_bundle_set(root: Path, manifest: dict) -> None: - root.mkdir() - modules = { - row["path"]: [ - f"{item['base']}[runtime-{index}]" if item["count"] > 1 else item["base"] - for item in row["node_signature"] - for index in range(item["count"]) - ] - for row in manifest["modules"] - } - for shard in manifest["shards"]: - bundle = root / f"shard-{shard['id']}" - bundle.mkdir() - coverage = bundle / "coverage.data" - coverage.write_bytes(f"coverage-{shard['id']}".encode()) - observed = sorted(node for module in shard["modules"] for node in modules[module]) - result = { - "coverage_file": "coverage.data", - "coverage_sha256": shards._sha256(coverage.read_bytes()), - "duration_seconds": 1.25, - "manifest_sha256": manifest["manifest_sha256"], - "modules": shard["modules"], - "collected_node_ids": observed, - "completed_node_ids": observed, - "schema_version": shards.SCHEMA_VERSION, - "shard_id": shard["id"], - "tree_sha": manifest["tree_sha"], - } - (bundle / "result.json").write_text( - json.dumps(result, indent=2, sort_keys=True) + "\n", encoding="utf-8" - ) - - -def test_manifest_is_deterministic_and_balanced() -> None: - manifest = shards.build_manifest(TREE_SHA, _nodes(), 4) - assert manifest == shards.build_manifest(TREE_SHA, dict(reversed(_nodes().items())), 4) - assert [row["weight"] for row in manifest["shards"]] == [4, 3, 2, 2] - assigned = [module for row in manifest["shards"] for module in row["modules"]] - assert sorted(assigned) == sorted(_nodes()) - assert len(assigned) == len(set(assigned)) - assert shards.validate_manifest(manifest) == manifest - - -@pytest.mark.parametrize( - ("tree_sha", "nodes", "count", "message"), - [ - ("bad", _nodes(), 4, "invalid_tree_sha"), - (TREE_SHA, {}, 4, "invalid_module_inventory"), - (TREE_SHA, _nodes(), 3, "invalid_shard_count"), - ( - TREE_SHA, - {"backend/tests/test_zero.py": []}, - 4, - "invalid_node_inventory", - ), - ( - TREE_SHA, - {"backend/tests/test_alpha.py": ["tests/test_other.py::test_a"]}, - 4, - "node_module_mismatch", - ), - ( - TREE_SHA, - {"backend/tests/../test_escape.py": ["tests/test_escape.py::test_a"]}, - 4, - "invalid_test_module", - ), - ], -) -def test_manifest_rejects_invalid_inventory( - tree_sha: str, nodes: dict[str, list[str]], count: int, message: str -) -> None: - with pytest.raises(shards.ShardError, match=message): - shards.build_manifest(tree_sha, nodes, count) - - -def test_manifest_rejects_digest_or_assignment_tampering() -> None: - manifest = shards.build_manifest(TREE_SHA, _nodes(), 4) - manifest["shards"][0]["modules"].append("backend/tests/test_foreign.py") - with pytest.raises(shards.ShardError, match="manifest_digest_mismatch"): - shards.validate_manifest(manifest) - - -def test_discovery_rejects_symlinked_module(tmp_path: Path) -> None: - tests = tmp_path / "backend/tests" - tests.mkdir(parents=True) - target = tmp_path / "target.py" - target.write_text("def test_a(): pass\n", encoding="utf-8") - (tests / "test_link.py").symlink_to(target) - with pytest.raises(shards.ShardError, match="invalid_test_module"): - shards.discover_modules(tmp_path) - - -def test_collect_nodes_rejects_collection_failure( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path -) -> None: - class Result: - returncode = 2 - stdout = "" - stderr = "failure" - - monkeypatch.setattr(shards.subprocess, "run", lambda *args, **kwargs: Result()) - with pytest.raises(shards.ShardError, match="pytest_collection_failed"): - shards.collect_nodes(tmp_path, ["backend/tests/test_alpha.py"]) - - -def test_collect_nodes_rejects_zero_or_foreign_nodes( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path -) -> None: - class Result: - returncode = 0 - stdout = "tests/test_other.py::test_a\n" - stderr = "" - - monkeypatch.setattr(shards.subprocess, "run", lambda *args, **kwargs: Result()) - with pytest.raises(shards.ShardError, match="foreign_or_duplicate_node"): - shards.collect_nodes(tmp_path, ["backend/tests/test_alpha.py"]) - - -def test_collect_nodes_accepts_parameterized_nodes_and_sets_safe_environment( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path -) -> None: - observed: dict = {} - - class Result: - returncode = 0 - stdout = ( - "tests/test_alpha.py::test_a[value]\ntests/test_alpha.py::test_b\n2 tests collected\n" - ) - stderr = "" - - def fake_run(command, **kwargs): - observed["command"] = command - observed["env"] = kwargs["env"] - return Result() - - monkeypatch.setattr(shards.subprocess, "run", fake_run) - result = shards.collect_nodes(tmp_path, ["backend/tests/test_alpha.py"]) - assert result == { - "backend/tests/test_alpha.py": [ - "tests/test_alpha.py::test_a[value]", - "tests/test_alpha.py::test_b", - ] - } - assert observed["command"][-1] == "tests/test_alpha.py" - assert observed["env"]["PYTEST_DISABLE_PLUGIN_AUTOLOAD"] == "1" - - -def test_discovery_accepts_regular_modules_and_explicit_exclusion(tmp_path: Path) -> None: - tests = tmp_path / "backend/tests" - tests.mkdir(parents=True) - (tests / "test_alpha.py").write_text("def test_a(): pass\n", encoding="utf-8") - (tests / "test_beta.py").write_text("def test_b(): pass\n", encoding="utf-8") - (tests / "test_isolated_database_runner.py").write_text( - "def test_runner(): pass\n", encoding="utf-8" - ) - assert shards.discover_modules(tmp_path) == [ - "backend/tests/test_alpha.py", - "backend/tests/test_beta.py", - ] - - -def test_manifest_file_round_trip_and_invalid_json(tmp_path: Path) -> None: - manifest = shards.build_manifest(TREE_SHA, _nodes(), 4) - path = tmp_path / "manifest.json" - path.write_text(json.dumps(manifest), encoding="utf-8") - assert shards.load_manifest(path) == manifest - path.write_text("not-json", encoding="utf-8") - with pytest.raises(shards.ShardError, match="invalid_manifest_file"): - shards.load_manifest(path) - - -@pytest.mark.parametrize( - ("field", "value"), - [ - ("schema_version", 99), - ("shard_count", True), - ("manifest_sha256", "bad"), - ("excluded_modules", []), - ("shards", "bad"), - ], -) -def test_manifest_rejects_invalid_top_level_schema(field: str, value: object) -> None: - manifest = shards.build_manifest(TREE_SHA, _nodes(), 4) - manifest[field] = value - with pytest.raises(shards.ShardError, match="invalid_manifest"): - shards.validate_manifest(manifest) - - -def test_run_shard_uses_python_argv_and_writes_authenticated_bundle( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path -) -> None: - manifest = shards.build_manifest(TREE_SHA, _nodes(), 4) - manifest_path = tmp_path / "manifest.json" - manifest_path.write_text(json.dumps(manifest), encoding="utf-8") - shard = manifest["shards"][0] - module_nodes = {module: nodes for module, nodes in _nodes().items()} - monkeypatch.setattr(shards, "_assert_checked_out_tree", lambda *args: None) - captured: dict = {} - - class Result: - returncode = 0 - - def fake_run(command, **kwargs): - captured["command"] = command - captured["env"] = kwargs["env"] - Path(kwargs["env"]["COVERAGE_FILE"]).write_bytes(b"real-coverage") - runtime = sorted(node for module in shard["modules"] for node in module_nodes[module]) - for variable in (shards.COLLECTED_NODES_ENV, shards.COMPLETED_NODES_ENV): - Path(kwargs["env"][variable]).write_text( - "".join(json.dumps(node) + "\n" for node in runtime), encoding="utf-8" - ) - return Result() - - monkeypatch.setattr(shards.subprocess, "run", fake_run) - bundle = tmp_path / "bundle" - shards.run_shard( - tmp_path, - manifest_path, - shard["id"], - bundle, - tmp_path / "database.json", - ) - result = json.loads((bundle / "result.json").read_text(encoding="utf-8")) - assert captured["command"][:2] == [shards.sys.executable, "scripts/run_isolated_tests.py"] - invoked_modules = [ - argument for argument in captured["command"] if argument.startswith("tests/") - ] - assert invoked_modules == [ - str(Path(module).relative_to("backend")) for module in shard["modules"] - ] - assert "scripts.ci_test_shards" in captured["command"] - assert captured["env"]["PYTEST_DISABLE_PLUGIN_AUTOLOAD"] == "1" - assert result["modules"] == shard["modules"] - assert result["coverage_sha256"] == shards._sha256(b"real-coverage") - - -def test_run_shard_rejects_failed_test_process( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path -) -> None: - manifest = shards.build_manifest(TREE_SHA, _nodes(), 4) - path = tmp_path / "manifest.json" - path.write_text(json.dumps(manifest), encoding="utf-8") - shard = manifest["shards"][0] - monkeypatch.setattr(shards, "_assert_checked_out_tree", lambda *args: None) - - class Result: - returncode = 1 - - monkeypatch.setattr(shards.subprocess, "run", lambda *args, **kwargs: Result()) - with pytest.raises(shards.ShardError, match="shard_tests_failed"): - shards.run_shard(tmp_path, path, shard["id"], tmp_path / "bundle", tmp_path / "db") - - -def test_run_shard_rejects_collected_but_not_executed_node( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path -) -> None: - manifest = shards.build_manifest(TREE_SHA, _nodes(), 4) - path = tmp_path / "manifest.json" - path.write_text(json.dumps(manifest), encoding="utf-8") - shard = manifest["shards"][0] - module_nodes = _nodes() - monkeypatch.setattr(shards, "_assert_checked_out_tree", lambda *args: None) - - class Result: - returncode = 0 - - def fake_run(command, **kwargs): - Path(kwargs["env"]["COVERAGE_FILE"]).write_bytes(b"coverage") - expected = sorted(node for module in shard["modules"] for node in module_nodes[module]) - Path(kwargs["env"][shards.COLLECTED_NODES_ENV]).write_text( - "".join(json.dumps(node) + "\n" for node in expected), - encoding="utf-8", - ) - Path(kwargs["env"][shards.COMPLETED_NODES_ENV]).write_text( - "".join(json.dumps(node) + "\n" for node in expected[1:]), - encoding="utf-8", - ) - return Result() - - monkeypatch.setattr(shards.subprocess, "run", fake_run) - with pytest.raises(shards.ShardError, match="shard_execution_mismatch"): - shards.run_shard(tmp_path, path, shard["id"], tmp_path / "bundle", tmp_path / "db") - - -def test_pytest_hooks_record_runtime_collection_and_completion( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path -) -> None: - collected = tmp_path / "collected.jsonl" - completed = tmp_path / "completed.jsonl" - collected.write_text("", encoding="utf-8") - completed.write_text("", encoding="utf-8") - monkeypatch.setenv(shards.COLLECTED_NODES_ENV, str(collected)) - monkeypatch.setenv(shards.COMPLETED_NODES_ENV, str(completed)) - session = type( - "Session", (), {"items": [type("Item", (), {"nodeid": "tests/test_alpha.py::test_a"})()]} - )() - shards.pytest_collection_finish(session) - shards.pytest_runtest_logfinish("tests/test_alpha.py::test_a", ("file", 1, "test_a")) - assert collected.read_text(encoding="utf-8") == '"tests/test_alpha.py::test_a"\n' - assert completed.read_text(encoding="utf-8") == '"tests/test_alpha.py::test_a"\n' - - -def test_runtime_signature_tolerates_changed_parameter_display_values() -> None: - before = ["tests/test_alpha.py::test_a[550e8400::nested[value]]"] - after = ["tests/test_alpha.py::test_a[6ba7b810::other[value]]"] - assert shards._node_signature(before) == shards._node_signature(after) - first = shards.build_manifest( - TREE_SHA, - { - "backend/tests/test_alpha.py": before, - **{ - key: value - for key, value in _nodes().items() - if key != "backend/tests/test_alpha.py" - }, - }, - 4, - ) - second = shards.build_manifest( - TREE_SHA, - { - "backend/tests/test_alpha.py": after, - **{ - key: value - for key, value in _nodes().items() - if key != "backend/tests/test_alpha.py" - }, - }, - 4, - ) - assert first == second - - -def test_fan_in_accepts_exact_authenticated_bundles(tmp_path: Path) -> None: - manifest = shards.build_manifest(TREE_SHA, _nodes(), 4) - bundles = tmp_path / "bundles" - _write_bundle_set(bundles, manifest) - summary_path = tmp_path / "fan-in-summary.json" - outputs = shards.validate_fan_in(manifest, bundles, tmp_path / "combined", summary_path) - assert [path.name for path in outputs] == [ - ".coverage.shard-1", - ".coverage.shard-2", - ".coverage.shard-3", - ".coverage.shard-4", - ] - summary = json.loads(summary_path.read_text(encoding="utf-8")) - assert summary["tree_sha"] == TREE_SHA - assert summary["manifest_sha256"] == manifest["manifest_sha256"] - assert summary["node_count"] == sum(len(nodes) for nodes in _nodes().values()) - assert [row["node_count"] for row in summary["shards"]] == [4, 3, 2, 2] - assert summary["timing"] == { - "imbalance_seconds": 0.0, - "maximum_seconds": 1.25, - "total_runner_seconds": 5.0, - } - - -@pytest.mark.parametrize("mutation", ["missing", "extra", "coverage", "nodes", "tree"]) -def test_fan_in_rejects_incomplete_or_tampered_evidence(tmp_path: Path, mutation: str) -> None: - manifest = shards.build_manifest(TREE_SHA, _nodes(), 4) - bundles = tmp_path / "bundles" - _write_bundle_set(bundles, manifest) - if mutation == "missing": - (bundles / "shard-1/result.json").unlink() - elif mutation == "extra": - (bundles / "surplus").mkdir() - elif mutation == "coverage": - (bundles / "shard-1/coverage.data").write_bytes(b"changed") - else: - path = bundles / "shard-1/result.json" - result = json.loads(path.read_text(encoding="utf-8")) - if mutation == "nodes": - result["completed_node_ids"] = result["completed_node_ids"][1:] - else: - result["tree_sha"] = "b" * 40 - path.write_text(json.dumps(result), encoding="utf-8") - with pytest.raises(shards.ShardError): - shards.validate_fan_in(manifest, bundles, tmp_path / "combined") - - -def test_fan_in_rejects_symlink_and_unexpected_file(tmp_path: Path) -> None: - manifest = shards.build_manifest(TREE_SHA, _nodes(), 4) - bundles = tmp_path / "bundles" - _write_bundle_set(bundles, manifest) - (bundles / "shard-1/unexpected").write_text("no", encoding="utf-8") - with pytest.raises(shards.ShardError, match="unexpected_bundle_path"): - shards.validate_fan_in(manifest, bundles, tmp_path / "combined") - (bundles / "shard-1/unexpected").unlink() - coverage = bundles / "shard-1/coverage.data" - coverage.unlink() - coverage.symlink_to(bundles / "shard-2/coverage.data") - with pytest.raises(shards.ShardError, match="unexpected_bundle_path"): - shards.validate_fan_in(manifest, bundles, tmp_path / "combined") - - -def test_fan_in_rejects_runtime_collection_completion_mismatch(tmp_path: Path) -> None: - manifest = shards.build_manifest(TREE_SHA, _nodes(), 4) - bundles = tmp_path / "bundles" - _write_bundle_set(bundles, manifest) - second = bundles / "shard-2/result.json" - two = json.loads(second.read_text(encoding="utf-8")) - two["completed_node_ids"] = two["completed_node_ids"][1:] - second.write_text(json.dumps(two), encoding="utf-8") - with pytest.raises(shards.ShardError, match="shard_node_mismatch"): - shards.validate_fan_in(manifest, bundles, tmp_path / "combined") diff --git a/backend/tests/test_database_reset.py b/backend/tests/test_database_reset.py new file mode 100644 index 00000000..29f98c58 --- /dev/null +++ b/backend/tests/test_database_reset.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable + +import asyncpg # type: ignore[import-not-found] + +TRUNCATE_GUARDED_TABLES = { + "admin_role_grants", + "audit_events", + "authority_control", + "authority_idempotency_records", + "outbox_events", +} + + +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: + head_before = await connection.fetchval("select version_num from alembic_version") + migration_before = await connection.fetchrow( + "select * from actor_profile_migration_state" + ) + 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() + + 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" + ) + head_after = await connection.fetchval("select version_num from alembic_version") + migration_after = await connection.fetchrow( + "select * from actor_profile_migration_state" + ) + counter_count = await connection.fetchval( + "select count(*) from api_rate_control_counters" + ) + trigger_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", + sorted(TRUNCATE_GUARDED_TABLES), + ) + finally: + await connection.close() + + assert dict(control) == { + "bootstrap_completed": False, + "bootstrap_grant_id": None, + "version": 0, + } + assert counter_count == 0 + assert head_after == head_before + assert migration_after == migration_before + assert {row["table_name"] for row in trigger_rows} == TRUNCATE_GUARDED_TABLES + assert all(row["enabled"] for row in trigger_rows) + + asyncio.run(exercise()) diff --git a/backend/tests/test_isolated_database_runner.py b/backend/tests/test_isolated_database_runner.py index 2f3198b2..6d99b5b2 100644 --- a/backend/tests/test_isolated_database_runner.py +++ b/backend/tests/test_isolated_database_runner.py @@ -11,8 +11,8 @@ import time from types import SimpleNamespace -import asyncpg -import pytest +import asyncpg # type: ignore[import-not-found] +import pytest # type: ignore[import-not-found] RUNNER = Path(__file__).resolve().parents[1] / "scripts/run_isolated_tests.py" ADMIN_ENV = "WORKSTREAM_TEST_ADMIN_DATABASE_URL" @@ -25,7 +25,9 @@ async def _names(admin_url: str) -> set[str]: connection = await asyncpg.connect("postgresql" + admin_url.removeprefix("postgresql+asyncpg")) try: - rows = await connection.fetch("SELECT datname FROM pg_database WHERE datname LIKE 'workstream_test_%'") + rows = await connection.fetch( + "SELECT datname FROM pg_database WHERE datname LIKE 'workstream_test_%'" + ) return {row["datname"] for row in rows} finally: await connection.close() @@ -34,7 +36,9 @@ async def _names(admin_url: str) -> set[str]: async def _roles(admin_url: str) -> set[str]: connection = await asyncpg.connect("postgresql" + admin_url.removeprefix("postgresql+asyncpg")) try: - rows = await connection.fetch("SELECT rolname FROM pg_roles WHERE rolname LIKE 'workstream_role_%'") + rows = await connection.fetch( + "SELECT rolname FROM pg_roles WHERE rolname LIKE 'workstream_role_%'" + ) return {row["rolname"] for row in rows} finally: await connection.close() @@ -49,17 +53,66 @@ def _run(tmp_path: Path, code: str, *, timeout: float | None = None) -> subproce def _mock_successful_main(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, drop) -> None: - async def noop(*_): return None - async def observed(*_): return "0015" + async def noop(*_): + return None + + async def observed(*_): + return "0015" + calls = iter([(0, b"", b""), (0, b"", b"")]) monkeypatch.setenv(ADMIN_ENV, "postgresql+asyncpg://local@localhost/postgres") - monkeypatch.setattr(runner, "_urls", lambda _: ("workstream_test_012345abcdef", "workstream_role_012345abcdef", "password", "target")) + monkeypatch.setattr( + runner, + "_urls", + lambda _: ( + "workstream_test_012345abcdef", + "workstream_role_012345abcdef", + "password", + "target", + ), + ) monkeypatch.setattr(runner, "_create", noop) monkeypatch.setattr(runner, "_observed_head", observed) monkeypatch.setattr(runner, "_drop", drop) monkeypatch.setattr(runner, "_run", lambda *_: next(calls)) monkeypatch.setattr(runner.subprocess, "check_output", lambda *_args, **_kwargs: "1" * 40) - monkeypatch.setattr(sys, "argv", [str(RUNNER), "--metadata-json", str(tmp_path / "db.json"), "--", "child"]) + monkeypatch.setattr( + sys, "argv", [str(RUNNER), "--metadata-json", str(tmp_path / "db.json"), "--", "child"] + ) + + +def test_run_emits_secret_free_heartbeat( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture +) -> None: + """Long children expose liveness without exposing buffered child output.""" + + class Process: + returncode = 0 + + def __init__(self) -> None: + self.timeouts: list[float | None] = [] + + def communicate(self, timeout=None): + self.timeouts.append(timeout) + if len(self.timeouts) == 1: + raise runner.TimeoutExpired("child", timeout) + return b"private stdout", b"private stderr" + + process = Process() + clock = iter((0.0, 0.0, 60.0, 60.0)) + monkeypatch.setattr(runner.subprocess, "Popen", lambda *args, **kwargs: process) + monkeypatch.setattr(runner.time, "monotonic", lambda: next(clock)) + + result = runner._run(["child"], {}, 120.0) + + code, stdout, stderr = result + assert code == 0 + assert stdout == b"private stdout" + assert stderr == b"private stderr" + assert process.timeouts == [60.0, 60.0] + heartbeat = capsys.readouterr().out + assert heartbeat == "isolated-test child active: elapsed_seconds=60\n" + assert "private" not in heartbeat def test_runner_rejects_nonlocal_admin_without_exposing_it( @@ -74,7 +127,9 @@ def test_runner_rejects_nonlocal_admin_without_exposing_it( assert "unsafe_admin_database" in result.stderr -@pytest.mark.parametrize(("code", "expected"), [("raise SystemExit(7)", 7), ("import time; time.sleep(2)", 124)]) +@pytest.mark.parametrize( + ("code", "expected"), [("raise SystemExit(7)", 7), ("import time; time.sleep(2)", 124)] +) def test_runner_propagates_failure_and_always_drops_owned_database( tmp_path: Path, code: str, expected: int ) -> None: @@ -94,23 +149,28 @@ def test_runner_strips_parent_secrets_migrates_and_redacts_target( """The child sees only its target while metadata proves migration and cleanup.""" admin = os.environ[ADMIN_ENV] monkeypatch.setenv("WORKSTREAM_ALLOW_NONLOCAL_E2E_DATABASE", "unsafe") - code = "\n".join([ - "import asyncio, asyncpg, os", - "assert 'WORKSTREAM_TEST_ADMIN_DATABASE_URL' not in os.environ", - "assert 'WORKSTREAM_ALLOW_NONLOCAL_E2E_DATABASE' not in os.environ", - "url = os.environ['WORKSTREAM_DATABASE_URL']", - "async def check():", - " c = await asyncpg.connect('postgresql' + url.removeprefix('postgresql+asyncpg'))", - " row = await c.fetchrow('select current_database(), rolsuper, rolcreatedb, rolcreaterole, rolreplication, rolbypassrls from pg_roles where rolname=current_user')", - " await c.close()", - " assert row[0].startswith('workstream_test_') and not any(row[1:])", - "asyncio.run(check())", - "print(url)", - ]) + code = "\n".join( + [ + "import asyncio, asyncpg, os", + "assert 'WORKSTREAM_TEST_ADMIN_DATABASE_URL' not in os.environ", + "assert 'WORKSTREAM_ALLOW_NONLOCAL_E2E_DATABASE' not in os.environ", + "url = os.environ['WORKSTREAM_DATABASE_URL']", + "async def check():", + " c = await asyncpg.connect('postgresql' + url.removeprefix('postgresql+asyncpg'))", + " row = await c.fetchrow('select current_database(), rolsuper, rolcreatedb, rolcreaterole, rolreplication, rolbypassrls from pg_roles where rolname=current_user')", + " await c.close()", + " assert row[0].startswith('workstream_test_') and not any(row[1:])", + "asyncio.run(check())", + "print(url)", + ] + ) before = asyncio.run(_names(admin)) roles = asyncio.run(_roles(admin)) result = _run(tmp_path, code) - metadata = json.loads((tmp_path / "database.json").read_text(encoding="utf-8")) + try: + metadata = json.loads((tmp_path / "database.json").read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc: + raise AssertionError("runner metadata must be valid JSON") from exc assert result.returncode == 0 assert "[REDACTED_DATABASE_URL]" in result.stdout assert admin not in result.stdout + result.stderr @@ -124,29 +184,40 @@ def test_runner_strips_parent_secrets_migrates_and_redacts_target( def test_cleanup_terminates_only_owned_sessions_and_drops_role(child_drops_database: bool) -> None: """Cleanup terminates exact owned sessions while an unrelated session survives.""" admin = os.environ[ADMIN_ENV] + async def exercise() -> None: name, role, password, target = runner._urls(admin) await runner._create(admin, name, role, password) spectator = await asyncpg.connect(runner._asyncpg_url(admin)) owned = await asyncpg.connect(runner._asyncpg_url(target)) + controller = None if child_drops_database: - controller = await asyncpg.connect(runner._asyncpg_url(target).rsplit("/", 1)[0] + "/postgres") + controller = await asyncpg.connect( + runner._asyncpg_url(target).rsplit("/", 1)[0] + "/postgres" + ) await controller.execute(f'DROP DATABASE "{name}" WITH (FORCE)') await runner._drop(admin, name, role) with pytest.raises((asyncpg.PostgresError, asyncpg.InterfaceError, ConnectionResetError)): await owned.fetchval("SELECT 1") if child_drops_database: - with pytest.raises((asyncpg.PostgresError, asyncpg.InterfaceError, ConnectionResetError)): + assert controller is not None + with pytest.raises( + (asyncpg.PostgresError, asyncpg.InterfaceError, ConnectionResetError) + ): await controller.fetchval("SELECT 1") assert await spectator.fetchval("SELECT 1") == 1 await spectator.close() assert name not in await _names(admin) and role not in await _roles(admin) + asyncio.run(exercise()) @pytest.mark.parametrize( ("name", "role"), - [("workstream_test_bad", "workstream_role_012345abcdef"), ("workstream_test_012345abcdef", "admin")], + [ + ("workstream_test_bad", "workstream_role_012345abcdef"), + ("workstream_test_012345abcdef", "admin"), + ], ) def test_destructive_boundaries_revalidate_identifiers(name: str, role: str) -> None: """Direct destructive helper use rejects every noncanonical identifier.""" @@ -166,9 +237,23 @@ def test_runner_concurrency_is_unique_and_interrupt_cleanup_is_exact(tmp_path: P processes = [] for index in range(2): marker = tmp_path / f"child-{index}" - ignored = "import signal; signal.signal(signal.SIGTERM, signal.SIG_IGN); " if index == 0 else "" - code = ignored + f"import os,time,urllib.parse,pathlib; pathlib.Path({str(marker)!r}).write_text(urllib.parse.urlsplit(os.environ['WORKSTREAM_DATABASE_URL']).path[1:]); time.sleep(30)" - command = [sys.executable, str(RUNNER), "--metadata-json", str(tmp_path / f"db-{index}.json"), "--", sys.executable, "-c", code] + ignored = ( + "import signal; signal.signal(signal.SIGTERM, signal.SIG_IGN); " if index == 0 else "" + ) + code = ( + ignored + + f"import os,time,urllib.parse,pathlib; pathlib.Path({str(marker)!r}).write_text(urllib.parse.urlsplit(os.environ['WORKSTREAM_DATABASE_URL']).path[1:]); time.sleep(30)" + ) + command = [ + sys.executable, + str(RUNNER), + "--metadata-json", + str(tmp_path / f"db-{index}.json"), + "--", + sys.executable, + "-c", + code, + ] processes.append((subprocess.Popen(command, env=os.environ.copy()), marker)) try: deadline = time.monotonic() + 120 @@ -187,12 +272,15 @@ def test_runner_concurrency_is_unique_and_interrupt_cleanup_is_exact(tmp_path: P def test_collision_never_claims_or_drops_existing_database() -> None: """A create collision leaves the pre-existing database and sessions untouched.""" admin = os.environ[ADMIN_ENV] + async def exercise() -> None: suffix = os.urandom(6).hex() name, role = f"workstream_test_{suffix}", f"workstream_role_{suffix}" password = os.urandom(16).hex() await runner._create(admin, name, role, password) - target = "postgresql" + admin.removeprefix("postgresql+asyncpg").rsplit("/", 1)[0] + f"/{name}" + target = ( + "postgresql" + admin.removeprefix("postgresql+asyncpg").rsplit("/", 1)[0] + f"/{name}" + ) session = await asyncpg.connect(target) try: with pytest.raises(runner.RunnerError, match="database_collision"): @@ -203,6 +291,7 @@ async def exercise() -> None: finally: await session.close() await runner._drop(admin, name, role) + asyncio.run(exercise()) @@ -214,19 +303,24 @@ def test_interruption_after_partial_create_compensates_real_resources( admin = os.environ[ADMIN_ENV] before, roles = asyncio.run(_names(admin)), asyncio.run(_roles(admin)) real_connect = asyncpg.connect + class InterruptingConnection: def __init__(self, connection): self.connection, self.triggered = connection, False + async def execute(self, query, *args): result = await self.connection.execute(query, *args) if query.startswith(trigger) and not self.triggered: self.triggered = True raise KeyboardInterrupt return result + def __getattr__(self, name): return getattr(self.connection, name) + async def connect(url): return InterruptingConnection(await real_connect(url)) + fake = SimpleNamespace( connect=connect, DuplicateDatabaseError=asyncpg.DuplicateDatabaseError, @@ -240,34 +334,54 @@ async def connect(url): assert asyncio.run(_roles(admin)) == roles -@pytest.mark.parametrize(("migration_code", "interrupted", "expected"), [(9, False, 2), (130, True, 130)]) +@pytest.mark.parametrize( + ("migration_code", "interrupted", "expected"), [(9, False, 2), (130, True, 130)] +) def test_migration_failure_or_interrupt_cleans_without_starting_child( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, migration_code: int, - interrupted: bool, expected: int, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + migration_code: int, + interrupted: bool, + expected: int, ) -> None: """Migration failure or interruption cleans before any child can run.""" events = [] + async def record(event, *_): events.append(event) + monkeypatch.setenv(ADMIN_ENV, "postgresql+asyncpg://local@localhost/postgres") - monkeypatch.setattr(runner, "_urls", lambda _: ("workstream_test_012345abcdef", "workstream_role_012345abcdef", "password", "target")) + monkeypatch.setattr( + runner, + "_urls", + lambda _: ( + "workstream_test_012345abcdef", + "workstream_role_012345abcdef", + "password", + "target", + ), + ) monkeypatch.setattr(runner, "_create", lambda *_: record("create")) monkeypatch.setattr(runner, "_drop", lambda *_: record("drop")) + def migration(*_): - runner.INTERRUPTED = interrupted + setattr(runner, "INTERRUPTED", interrupted) return migration_code, b"", b"" + monkeypatch.setattr(runner, "_run", migration) - monkeypatch.setattr(sys, "argv", [str(RUNNER), "--metadata-json", str(tmp_path / "db.json"), "--", "child"]) + monkeypatch.setattr( + sys, "argv", [str(RUNNER), "--metadata-json", str(tmp_path / "db.json"), "--", "child"] + ) assert runner.main() == expected assert events == ["create", "drop"] -def test_cleanup_failure_overrides_success( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: +def test_cleanup_failure_overrides_success(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """A successful child cannot hide failure to destroy owned database state.""" + async def fail_drop(*_): raise RuntimeError("secret detail") + _mock_successful_main(tmp_path, monkeypatch, fail_drop) assert runner.main() == 2 @@ -276,8 +390,10 @@ def test_cleanup_defers_repeated_signals_and_preserves_child_result( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """Repeated SIGINT/SIGTERM cannot interrupt owned cleanup or rewrite success.""" + async def signalled_drop(*_): os.kill(os.getpid(), signal.SIGINT) os.kill(os.getpid(), signal.SIGTERM) + _mock_successful_main(tmp_path, monkeypatch, signalled_drop) assert runner.main() == 0 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..1d661a79 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 == [] @@ -1996,9 +1957,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 +2102,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 +2176,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 +2535,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 +2629,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 +2952,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 +3010,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 +3263,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 +3311,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 +3330,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 +3589,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 +3781,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 +3946,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 +3975,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 +4032,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 +4076,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 +4122,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 +4224,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 +4322,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 +4363,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 +4440,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 +4478,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 +4521,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 +4561,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 +5390,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 +5480,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 +5524,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 +5551,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 +5619,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 +5664,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 +5727,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 +5781,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 +5858,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 +5939,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 +5967,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 +6058,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 +6119,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 +6160,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 +6173,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 +6189,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 +6234,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 +6251,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 +6326,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 +6345,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 +6415,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 +6428,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 +6491,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 +6509,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" diff --git a/docs/operations_backend_testing.md b/docs/operations_backend_testing.md index 3fb88f93..fee6dc11 100644 --- a/docs/operations_backend_testing.md +++ b/docs/operations_backend_testing.md @@ -1,115 +1,125 @@ # Backend Testing Operations -Workstream's application tests run against a new local Postgres database per -invocation. Provisioning and cleanup use the admin database; the application -phase receives only a strict `workstream_test_<12 lowercase hex>` database and an ephemeral login without elevated authority. + +Workstream groups backend tests into four dependency-based processes. The +no-PostgreSQL lane runs without database credentials. Three PostgreSQL lanes +share one server but each owns a temporary database and role. Every database is +migrated once; fixtures then restore an empty baseline with `TRUNCATE`. ## Local full suite -Keep the admin URL in the environment with `postgresql+asyncpg` and a loopback host. -Never put real or shared credentials in arguments, logs, evidence, or configuration. + +Keep the administrator URL in the environment with `postgresql+asyncpg` and a +loopback host. Never place real or shared credentials in arguments, logs, +evidence, or configuration. ```bash cd backend tmp_dir=$(mktemp -d) trap 'rm -rf "$tmp_dir"' EXIT export WORKSTREAM_TEST_ADMIN_DATABASE_URL='postgresql+asyncpg://USER:PASSWORD@localhost:5433/postgres' +export PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 .venv/bin/python -m pytest -q tests/test_isolated_database_runner.py -.venv/bin/python scripts/run_isolated_tests.py --metadata-json "$tmp_dir/database.json" -- .venv/bin/python -m pytest -q --ignore=tests/test_isolated_database_runner.py +rm -f .coverage .coverage.* +.venv/bin/python scripts/run_test_lanes.py \ + --metadata-dir "$tmp_dir/lanes" \ + --summary-json "$tmp_dir/summary.json" \ + --timeout-seconds 1200 +.venv/bin/coverage combine unset WORKSTREAM_TEST_ADMIN_DATABASE_URL ``` -Run both phases. The second can exceed three hours locally; CI gives the child 210 minutes and the job 240 minutes so cleanup retains a bounded window. +`run_test_lanes.py` validates that every `test_*.py` module is assigned exactly +once or is the dedicated runner test. It starts the no-PostgreSQL, schema, +control-plane, and execution-plane processes concurrently. Each process writes a +private coverage file; coverage is combined locally after every lane passes. + +Database lanes invoke `run_isolated_tests.py`, which removes the administrator +URL before pytest, overwrites child database URLs, and redacts complete URLs. +Both runners emit secret-free 60-second heartbeats. The isolated runner drops +each owned database and role after success, failure, timeout, or interruption. + +## Database reset model -The runner removes the admin URL before child launch, overwrites both child database URLs, -removes the nonlocal override, redacts complete URLs, and writes only credential-free metadata. -It attempts to drop the owned database and ephemeral login after success, -failure, timeout, or interruption. Host termination or a database error can -prevent cleanup; recover manually with the database provisioning credential, targeting only the exact strict database and role names reported by local catalog inspection. +`tests/conftest.py` owns the reset seam. Before each database-backed test it: -## Candidate coverage floor +1. disposes pooled application connections; +2. truncates every mutable public table in one transaction; +3. preserves `alembic_version` and immutable actor migration evidence; +4. restores the single `authority_control` baseline row; and +5. re-enables the five explicit truncate guards. -`coverage_policy.py --compute-floor` is a read-only preparation command. Point -`--coverage-json` at temporary complete-app coverage JSON; the command validates -the application-file inventory and prints the exact statement percentage -truncated to six places. It does not configure or enforce a floor, write -evidence, connect to Postgres, or act as the CI coverage policy. Keep coverage -JSON temporary and non-secret; 01B2 owns baseline publication and enforcement. +Do not add module-local migration fixtures. Migration behavior belongs in +`tests/test_alembic.py`; ordinary route and service tests must consume the +central clean-database fixture. + +PGlite is not the default Python test adapter. The full schema migrates on +PGlite, but the current PGlite Socket multiplexer does not complete +`asyncpg.Connection.close()` reliably and does not guarantee normal PostgreSQL +concurrency behavior. Lock, trigger, migration, and concurrent transaction +proofs therefore remain on real PostgreSQL. ## Focused checks -The API-guard tests are statically DB-free: + +The API-guard tests are statically database-free: ```bash .venv/bin/python -m pytest -q tests/test_api_contract_e2e.py ``` -Runner lifecycle tests require the same admin environment variable: +Runner lifecycle tests require the administrator environment variable: ```bash .venv/bin/python -m pytest -q tests/test_isolated_database_runner.py ``` -Run the destructive API drill only against `workstream_test`, `test_workstream`, or a runner-derived local name: +Run the destructive API drill only through the isolated runner or against a +strict local test database name: ```bash -WORKSTREAM_DATABASE_URL='postgresql+asyncpg://USER:PASSWORD@localhost:5433/workstream_test' .venv/bin/python scripts/api_contract_e2e.py +WORKSTREAM_DATABASE_URL='postgresql+asyncpg://USER:PASSWORD@localhost:5433/workstream_test' \ + .venv/bin/python scripts/api_contract_e2e.py ``` Do not use `WORKSTREAM_ALLOW_NONLOCAL_E2E_DATABASE` for ordinary proof. -If provisioning fails, confirm the local PostgreSQL provisioning credential can create/drop databases and roles, terminate owned sessions, and reach the named admin database. Diagnostics omit credentials. - -## Hosted parallel full-suite proof - -The required GitHub check remains `Backend / test`. It is the final fan-in for: - -1. `preflight`: evidence gate, lint, docstrings, isolated-runner test, exact test - collection, and deterministic four-shard plan; -2. `shards`: four independent jobs, each with its own digest-pinned PostgreSQL - service, runner-owned migrated database, real digest-pinned MinIO, and - coverage file; -3. `api_e2e`: the real API contract proof in a separate isolated database; and -4. `test`: exact artifact validation, coverage combination, the 78 percent - repository floor, and all protected 90 percent subsystem floors. - -Matrix job state is the live progress view. The isolated runner continues to -buffer and redact pytest output, so a running shard does not stream individual -test names. The final check reports shard duration and balance metadata after -all evidence is authenticated. - -### Evidence bundles - -The preflight plan and four fixed shard bundles are retained for seven days. -Their names include the actual checked-out tree SHA. Each shard bundle contains -only `coverage.data` and allowlisted `result.json`; the result binds the tree, -manifest, shard, modules, collected and completed pytest node IDs, duration, and -SHA-256 of the exact coverage bytes. Repository-owned pytest hooks record the -final selected inventory and lifecycle completions in the same process; fan-in -requires exact equality and matches stable test-base cardinalities to preflight. -Bundles never contain database URLs or passwords, MinIO -credentials, environment dumps, or runner database metadata. - -The fan-in accepts exactly four expected regular-file bundles. It rejects stale -tree or manifest bindings, missing/extra/duplicate nodes or modules, altered -coverage, symlinks, path traversal, unexpected files, failed/cancelled/skipped -upstream jobs, and missing artifacts before `coverage combine` runs. - -### Failure diagnosis and reruns - -- `preflight` failure: inspect evidence/lint/runner/collection output. No shard - evidence is valid until preflight succeeds. -- one `shards` matrix failure: inspect that shard's database, MinIO, collection, - or test failure. The final required check must fail even if other shards pass. -- `api_e2e` failure: inspect the independent API contract job; coverage cannot - compensate for it. -- `test` failure: inspect dependency-result validation, exact bundle fan-in, then - the named global or subsystem coverage report. - -A complete workflow rerun creates evidence for the same checked-out tree and is -the clearest recovery. GitHub may rerun failed jobs, but the fan-in still rejects -missing or stale artifacts; never upload or edit bundles manually. A new commit -always requires a complete new run because its tree SHA differs. - -Four shards reduce wall-clock latency by using more concurrent runner minutes. -Review shard durations and total Actions consumption after deployment before -changing the shard count. If parallel execution is unstable or does not justify -its cost, revert the single implementation PR to restore the prior sequential -workflow; do not lower coverage, skip a shard, or add a silent fallback. +## Latest local proof + +The semantic lanes executed all 1,826 tests exactly once in 314.40 seconds; +including process startup and local coverage combine, wall time was 330.70 +seconds. The previous single process required 919.48 test seconds and 975.62 wall +seconds. Global coverage is 87.58 percent; protected reports are +90.90–100.00 percent. The real API contract drill also passed. + +An AST audit found no exact duplicate test bodies. Six literal-only similarity +clusters are candidates for parametrization, but parametrization would preserve +the same executions and would not materially reduce runtime. No behavior test +was removed; only Outbox's repeated Alembic-head setup was deleted. + +These values prove the local test topology, not hosted runner timing. Use the +exact checked-out commit's `Backend / test` result for release evidence. + +## Hosted proof + +The required GitHub check remains `Backend / test`. It uses one job, one +PostgreSQL service, and one MinIO service. Its order is: + +1. internal evidence, lint, docstrings, and isolated-runner checks; +2. four dependency-based test processes with isolated mutable state; +3. local coverage combine followed by the destructive API contract drill; and +4. the 78 percent repository floor plus every protected 90 percent floor. + +There is no GitHub matrix, arbitrary weighted shard, cross-job artifact fan-in, +or shared mutable database. Every lane has a 20-minute bound, the API drill has +a 10-minute bound, and the GitHub job has a 45-minute hard bound. + +### Failure diagnosis + +- **Runner test:** inspect provisioning, redaction, signal, or cleanup behavior. +- **Semantic lanes:** inspect the named lane result and 60-second active-lane + heartbeat; every process is force-bounded at 20 minutes. +- **API drill:** inspect the named contract assertion after the full suite. +- **Coverage:** inspect the named global or protected subsystem report. Coverage + cannot compensate for a failed test or API drill. + +Rerun the complete `Backend / test` job for the same checked-out commit. Never +lower coverage, skip tests, or restore parallel databases to hide fixture +regressions. diff --git a/scripts/test_agent_gates.py b/scripts/test_agent_gates.py index c1568382..b3d453f7 100644 --- a/scripts/test_agent_gates.py +++ b/scripts/test_agent_gates.py @@ -1,3 +1,4 @@ +# pyright: reportArgumentType=false, reportAttributeAccessIssue=false, reportCallIssue=false """Regression tests for Workstream agent gate helpers. Run with plain Python after installing the hash-pinned agent-gate dependencies; @@ -2341,6 +2342,7 @@ def stage_tree(repository: Path, index: Path) -> str: repository = root / "state" parent = initialize_repository(repository, with_parent=True) + assert parent is not None index = root / "generated.index" tree = stage_tree(repository, index) updater.validate_generated_git_tree(repository, tree, output) @@ -5318,7 +5320,7 @@ def test_local_minio_compose_is_regression_protected() -> None: def test_backend_coverage_thresholds_are_regression_protected() -> None: - """Keep parallel full-suite fan-in and every coverage floor fail closed.""" + """Keep semantic lanes, local coverage combine, and every floor fail closed.""" workflow_path = ROOT / ".github/workflows/backend.yml" workflow = workflow_path.read_text(encoding="utf-8") parsed_workflow = yaml.safe_load(workflow) @@ -5327,108 +5329,95 @@ def test_backend_coverage_thresholds_are_regression_protected() -> None: assert "pull_request_target" not in workflow assert "paths-ignore" not in workflow and "continue-on-error" not in workflow jobs = parsed_workflow["jobs"] - assert set(jobs) == {"preflight", "shards", "api_e2e", "test"} - + assert set(jobs) == {"test"} + + test_job = jobs["test"] + assert set(test_job) == { + "runs-on", + "timeout-minutes", + "services", + "steps", + } + assert test_job["timeout-minutes"] == 45 + assert "strategy" not in test_job and "needs" not in test_job postgres_image = ( "public.ecr.aws/docker/library/postgres:16@sha256:" "33f923b05f64ca54ac4401c01126a6b92afe839a0aa0a52bc5aeb5cc958e5f20" ) - for job_name in ("preflight", "shards", "api_e2e"): - assert jobs[job_name]["services"]["postgres"]["image"] == postgres_image - - preflight = jobs["preflight"] - assert set(preflight["outputs"]) == {"tree_sha"} - assert any( - step.get("name") == "Isolated database runner test" - and step.get("run") == "python -m pytest -q tests/test_isolated_database_runner.py" - for step in preflight["steps"] + assert test_job["services"]["postgres"]["image"] == postgres_image + steps = test_job["steps"] + assert not any( + "actions/upload-artifact@" in step.get("uses", "") + or "actions/download-artifact@" in step.get("uses", "") + for step in steps ) - plan_steps = [ - step for step in preflight["steps"] if step.get("name") == "Collect and plan exact test inventory" + assert "ci_test_shards.py" not in workflow + + runner_tests = [ + step for step in steps if step.get("name") == "Isolated database runner test" ] - assert len(plan_steps) == 1 - assert "ci_test_shards.py plan" in str(plan_steps[0]["run"]) - assert "--shards 4" in str(plan_steps[0]["run"]) - - shard_job = jobs["shards"] - assert shard_job["needs"] == "preflight" - assert shard_job["strategy"] == { - "fail-fast": False, - "matrix": {"shard": [1, 2, 3, 4]}, - } - shard_steps = shard_job["steps"] + assert len(runner_tests) == 1 + assert runner_tests[0]["run"] == "python -m pytest -q tests/test_isolated_database_runner.py" minio_steps = [ - step for step in shard_steps if step.get("name") == "Start real MinIO artifact provider" + step for step in steps if step.get("name") == "Start real MinIO artifact provider" ] assert len(minio_steps) == 1 assert "${MINIO_IMAGE}" in str(minio_steps[0]["run"]) - run_shard_steps = [ - step for step in shard_steps if str(step.get("name", "")).startswith("Run isolated shard") - ] - assert len(run_shard_steps) == 1 - assert "ci_test_shards.py run-shard" in str(run_shard_steps[0]["run"]) - assert "--cov-fail-under" not in str(run_shard_steps[0]["run"]) - api_steps = jobs["api_e2e"]["steps"] - api_e2e_steps = [ - step for step in api_steps if step.get("name") == "API contract real API e2e" + suite_steps = [ + step for step in steps if step.get("name") == "Backend semantic-lane coverage" ] - assert len(api_e2e_steps) == 1 - assert "scripts/run_isolated_tests.py" in str(api_e2e_steps[0]["run"]) - assert "scripts/api_contract_e2e.py" in str(api_e2e_steps[0]["run"]) - shard_tool = (ROOT / "backend/scripts/ci_test_shards.py").read_text(encoding="utf-8") - assert 4800 <= jobs["shards"]["timeout-minutes"] * 60 - 600 - assert '"4800"' in shard_tool - assert 1500 <= jobs["api_e2e"]["timeout-minutes"] * 60 - 300 - assert "--timeout-seconds 1500" in str(api_e2e_steps[0]["run"]) - - test_job = parsed_workflow["jobs"]["test"] - assert set(test_job) == {"if", "needs", "runs-on", "timeout-minutes", "steps"} - assert test_job["if"] == "${{ always() }}" - assert test_job["needs"] == ["preflight", "shards", "api_e2e"] - steps = test_job["steps"] - upstream = [step for step in steps if step.get("name") == "Require every upstream proof"] - assert len(upstream) == 1 - assert upstream[0]["env"] == { - "PREFLIGHT_RESULT": "${{ needs.preflight.result }}", - "SHARDS_RESULT": "${{ needs.shards.result }}", - "API_E2E_RESULT": "${{ needs.api_e2e.result }}", + assert len(suite_steps) == 1 + suite_step = suite_steps[0] + suite_command = str(suite_step["run"]) + assert suite_step["working-directory"] == "backend" + assert suite_step["env"] == { + "COVERAGE_FILE": ".coverage", + "PYTEST_DISABLE_PLUGIN_AUTOLOAD": "1", + "WORKSTREAM_TEST_ADMIN_DATABASE_URL": ( + "postgresql+asyncpg://workstream:workstream@localhost:5433/postgres" + ), + "WORKSTREAM_TEST_MINIO_ENDPOINT": "http://127.0.0.1:9000", } - assert str(upstream[0]["run"]).count('= success') == 3 - downloads = [step for step in steps if "actions/download-artifact@" in step.get("uses", "")] - assert len(downloads) == 5 - assert all( - step["uses"] - == "actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093" - and "pattern" not in step.get("with", {}) - for step in downloads + assert "scripts/run_test_lanes.py" in suite_command + assert "--metadata-dir" in suite_command and "--summary-json" in suite_command + assert "--timeout-seconds 1200" in suite_command + assert "rm -f .coverage .coverage.*" in suite_command + assert "coverage combine" in suite_command + assert "scripts/run_isolated_tests.py" not in suite_command + assert "--cov-fail-under" not in suite_command + assert 1200 <= test_job["timeout-minutes"] * 60 - 600 + + api_steps = [step for step in steps if step.get("name") == "API contract real API e2e"] + assert len(api_steps) == 1 + api_command = str(api_steps[0]["run"]) + assert "scripts/run_isolated_tests.py" in api_command + assert "scripts/api_contract_e2e.py" in api_command + assert "--timeout-seconds 600" in api_command + + runner_tool = (ROOT / "backend/scripts/run_isolated_tests.py").read_text( + encoding="utf-8" ) - uploads = [ - step - for job in jobs.values() - for step in job["steps"] - if "actions/upload-artifact@" in step.get("uses", "") + assert "HEARTBEAT_SECONDS = 60.0" in runner_tool + assert "isolated-test child active" in runner_tool + lane_tool = (ROOT / "backend/scripts/run_test_lanes.py").read_text(encoding="utf-8") + assert "validate_lane_inventory(discover_test_modules(), lanes=LANES)" in lane_tool + assert 'name="no_postgres"' in lane_tool + assert 'name="schema_contracts"' in lane_tool + assert 'name="control_plane"' in lane_tool + assert 'name="execution_plane"' in lane_tool + assert 'env["COVERAGE_FILE"] = f".coverage.{lane.name}"' in lane_tool + assert '"--durations=25"' in lane_tool + assert "test lanes active" in lane_tool + assert "start_new_session=True" in lane_tool + suite_index = steps.index(suite_step) + floor_steps = [ + step for step in steps if step.get("name") == "Repository coverage floor" ] - assert len(uploads) == 3 - assert all( - step["uses"] - == "actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02" - for step in uploads - ) - fan_in = [step for step in steps if step.get("name") == "Validate exact fan-in and combine coverage"] - assert len(fan_in) == 1 - assert "ci_test_shards.py fan-in" in str(fan_in[0]["run"]) - assert "coverage combine ../.ci/combined-coverage" in str(fan_in[0]["run"]) + assert len(floor_steps) == 1 + assert floor_steps[0]["run"] == "coverage report --precision=2 --fail-under=78" + assert steps.index(floor_steps[0]) > suite_index - full_suite_steps = [ - step for step in steps if step.get("name") == "Backend full-suite coverage" - ] - assert len(full_suite_steps) == 1 - assert full_suite_steps[0].get("working-directory") == "backend" - assert full_suite_steps[0]["run"] == "coverage report --precision=2 --fail-under=78" - for forbidden_key in ("if", "continue-on-error", "shell", "env"): - assert forbidden_key not in full_suite_steps[0] - full_suite_index = steps.index(full_suite_steps[0]) auth_coverage_steps = [ step for step in steps @@ -5437,11 +5426,6 @@ def test_backend_coverage_thresholds_are_regression_protected() -> None: assert tuple(str(step["run"]).strip() for step in auth_coverage_steps) == ( AUTH_09B_COVERAGE_COMMANDS ) - for coverage_step in auth_coverage_steps: - assert full_suite_index < steps.index(coverage_step) - assert coverage_step.get("working-directory") == "backend" - for forbidden_key in ("if", "continue-on-error", "shell", "env"): - assert forbidden_key not in coverage_step active_phase = active_artifact_coverage_phase() expected_coverage = artifact_expected_coverage_commands_for(active_phase) actual_coverage = tuple( @@ -5461,11 +5445,8 @@ def test_backend_coverage_thresholds_are_regression_protected() -> None: step for step in steps if str(step.get("run", "")).strip() == command ] assert len(matches) == 1, (command, matches) - coverage_step = matches[0] - assert steps.index(coverage_step) > full_suite_index - assert coverage_step.get("working-directory") == "backend" - for forbidden_key in ("if", "continue-on-error", "shell", "env"): - assert forbidden_key not in coverage_step + assert steps.index(matches[0]) > suite_index + assert matches[0].get("working-directory") == "backend" later_commands = artifact_expected_coverage_commands_for("06B") assert later_commands[0] == FOUNDATION_ARTIFACT_COVERAGE_COMMAND assert any("app/modules/checkers/*" in command for command in later_commands) @@ -5476,7 +5457,6 @@ def test_backend_coverage_thresholds_are_regression_protected() -> None: + len(AUTH_09B_COVERAGE_COMMANDS) + len(AUTHORIZATION_READ_COVERAGE_COMMANDS) ) - assert "continue-on-error" not in workflow def test_artifact_coverage_phase_is_derived_from_work_queue() -> None: From c73b28931b3b2a68a9ee2389181220132efaa41e Mon Sep 17 00:00:00 2001 From: Konan Date: Wed, 22 Jul 2026 09:37:41 +0100 Subject: [PATCH 2/2] test(backend): integrate shared reset with current schema --- backend/tests/conftest.py | 8 ++- backend/tests/test_artifact_recovery.py | 80 +++++++++---------------- 2 files changed, 35 insertions(+), 53 deletions(-) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 070550fd..e8aaa69a 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -70,8 +70,12 @@ async def _reset_test_database_state( "order by tablename" ) tables = {row["tablename"]: row["identifier"] for row in rows} - if not tables or not set(TRUNCATE_GUARDED_TABLES).issubset(tables): - raise RuntimeError("test database is not migrated to the expected schema") + missing_tables = set(TRUNCATE_GUARDED_TABLES) - tables.keys() + if not tables or missing_tables: + missing = ",".join(sorted(missing_tables)) or "all" + raise RuntimeError( + f"test database is not migrated to the expected schema: {missing}" + ) for name in TRUNCATE_GUARDED_TABLES: await connection.execute(f"alter table {tables[name]} disable trigger user") await connection.execute( diff --git a/backend/tests/test_artifact_recovery.py b/backend/tests/test_artifact_recovery.py index 3912d2a7..81fe3744 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 @@ -392,7 +376,7 @@ async def test_exact_replay_creates_one_recovery_job_and_audit( first = await service.create(request) replay = await service.create(request) assert first.retry_verification_job_id == replay.retry_verification_job_id - assert replay.replayed is True + assert replay.replayed assert await session.scalar(select(func.count(ArtifactRecoveryAttempt.id))) == 1 assert await session.scalar(select(func.count(ArtifactVerificationJob.id))) == 2 assert await session.scalar( @@ -444,7 +428,7 @@ async def test_taskless_recovery_and_deny_only_authority_boundary( replay = await ArtifactRecoveryService( session, settings, _AllowRecoveryAuthority() ).create(request) - assert replay.replayed is True + assert replay.replayed assert created.retry_verification_job_id == replay.retry_verification_job_id bootstrap.close() finally: @@ -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,