This commit captures everything produced during autodev existing-code Steps 1 (Document), 2 (Architecture Baseline Scan), and 3 (Test Spec), together with the targeted auth + CORS re-sync triggered on 2026-05-14 when codebase drift was detected at Step 4 entry. None of this work was previously committed. Step 1 (Document) — 50+ _docs/02_document/ files: problem, solution, architecture, system flows, glossary, module-layout, per-component specs (01..06), modules, deployment, diagrams, data model, FINAL report, verification log, discovery. Step 2 (Architecture Baseline) — architecture_compliance_baseline.md. Verdict PASS_WITH_WARNINGS (0 Critical, 0 High, 1 Medium, 2 Low). No High/Critical findings; auto-chained to Step 3 per existing-code flow. Step 3 (Test Spec) — _docs/02_document/tests/* (67 scenarios across blackbox, security, resilience, resource-limit, performance), plus e2e/docker-compose.test.yml, e2e/seed/run.sh, scripts/run-tests.sh, scripts/run-performance-tests.sh. Coverage 88% over the active scope (40 of 45 items covered, 6 RB-deferred, 5 documented-as-uncovered). Targeted auth + CORS re-sync — replaces the deleted in-house token issuer with a JWKS-verifier model. AuthController and TokenService removed; JwtExtensions switched from HS256 symmetric to ES256 over admin's JWKS. ConfigurationResolver and CorsConfigurationValidator added under src/Infrastructure/. ADR-002 and ADR-006 retired; SEC-01, SEC-02, SEC-03 marked Closed. One new testability risk recorded in architecture.md Open Risks Section 6 (JWKS HTTPS gating). Source changes: - src/Auth/JwtExtensions.cs (modified) — ES256, JWKS, alg pinning - src/Program.cs (modified) — DI wiring for ConfigurationResolver and CorsConfigurationValidator - src/Controllers/AuthController.cs (deleted) — no in-service issuance - src/Services/TokenService.cs (deleted) — same - src/Infrastructure/ConfigurationResolver.cs (new) - src/Infrastructure/CorsConfigurationValidator.cs (new) - .env.example (new) — required env var documentation - .gitignore (updated) Cross-repo coordination: _docs/cross-repo/flights_h1_h2_h3_change_spec captures the change-spec for downstream services that consumed the now deleted /auth endpoints. Co-authored-by: Cursor <cursoragent@cursor.com>
20 KiB
Blackbox Tests
Positive Scenarios
FT-P-01: Annotation create — single detection, small image
Summary: A POST /annotations with a small frame and one synthetic detection persists the row, writes the YOLO label file, and returns the persisted DTO.
Traces to: AC-F-01, AC-F-03, AC-F-04
Category: Annotation lifecycle — Create
Preconditions:
- SUT healthy (
/healthreturns 200) - DB clean (no rows in
annotations,detection,media,annotations_queue_records) - Runner has minted an ES256 token with the
ANNclaim (seetest-data.md→ "Bearer token harness")
Input data: image_small.jpg + F1_001_request.json (1 detection, class_num=10 Plane, normalized bbox)
Steps:
| Step | Consumer Action | Expected System Response |
|---|---|---|
| 1 | POST /annotations with the request body |
HTTP 200; body matches AnnotationDto schema; body.id =~ /^[0-9a-f]{32}$/; body.detections.length == 1 |
| 2 | Out-of-band: assert <images_dir>/<id>.jpg exists with same bytes as image_small.jpg |
file present, byte-for-byte match |
| 3 | Out-of-band: assert <images_dir>/<id>.txt exists with one line 10 0.45 0.32 0.08 0.12 (or whatever the request supplied, formatted) |
file present, line matches regex ^10 \d+\.\d+ \d+\.\d+ \d+\.\d+ \d+\.\d+$ |
| 4 | GET /annotations/{id} |
HTTP 200; same body as step 1 |
Expected outcome: the persisted entity round-trips through GET /annotations/{id} byte-for-byte, the image file is on disk, and the label file format is YOLO.
Max execution time: 5s
FT-P-02: Annotation create — idempotency on identical re-POST
Summary: Re-POSTing the same image bytes + same detections does not create a new row; the second response carries the same id.
Traces to: AC-F-01, AC-F-02
Preconditions:
- FT-P-01 has just succeeded, so an annotation for
image_small.jpgalready exists.
Input data: same as FT-P-01
Steps:
| Step | Consumer Action | Expected System Response |
|---|---|---|
| 1 | POST /annotations with the same body |
HTTP 200; body.id == <id from FT-P-01> |
| 2 | Out-of-band: count rows in annotations WHERE id = <id> |
count == 1 |
Expected outcome: idempotent write — same hash → same id → same row. Max execution time: 5s
FT-P-03: Annotation create — empty scene, 0 detections
Summary: An empty-scene image with 0 detections creates an annotation row with no detection rows; the YOLO label file is empty. Traces to: AC-F-03 (label-file format with 0 detections)
Preconditions: clean state.
Input data: image_empty_scene.jpg + F1_003_request.json (0 detections)
Steps:
| Step | Consumer Action | Expected System Response |
|---|---|---|
| 1 | POST /annotations |
HTTP 200; body.detections.length == 0 |
| 2 | Out-of-band: read <images_dir>/<id>.txt |
file exists; content is empty (0 bytes) or whitespace-only |
| 3 | Out-of-band: count rows in detection WHERE annotation_id = <id> |
count == 0 |
Expected outcome: persisted annotation with empty detections; label file present and empty. Max execution time: 5s
FT-P-04: Annotation create — dense scene, 5 mixed-class detections
Summary: A dense frame with 5 detections across multiple seeded classes persists 5 detection rows, writes a 5-line YOLO label, and returns a DTO with all 5 detections. Traces to: AC-F-03, AC-F-04
Preconditions: clean state.
Input data: image_dense01.jpg + F1_004_request.json (5 detections, class_num ∈ {0=ArmorVehicle, 1=Truck, 2=Vehicle, 9=Smoke, 10=Plane})
Steps:
| Step | Consumer Action | Expected System Response |
|---|---|---|
| 1 | POST /annotations |
HTTP 200; body.detections.length == 5 |
| 2 | Out-of-band: read <images_dir>/<id>.txt |
exactly 5 lines; each matches ^\d+ \d+\.\d+ \d+\.\d+ \d+\.\d+ \d+\.\d+$ |
| 3 | Compare line set against expected | line set equals the 5 detections in F1_004_request.json (order may differ — test uses set equality) |
Expected outcome: 5 detections round-trip through both DB and YOLO label. Max execution time: 5s
FT-P-05: Annotation listing — paginated read
Summary: After several creates, GET /annotations returns a paginated list with the correct shape and count.
Traces to: AC-F-04 (read path)
Preconditions: FT-P-01..FT-P-04 have run; 4 annotations exist.
Steps:
| Step | Consumer Action | Expected System Response |
|---|---|---|
| 1 | GET /annotations?limit=10 |
HTTP 200; body.length == 4; each item conforms to AnnotationListItem schema |
| 2 | GET /annotations?limit=2&offset=0 |
HTTP 200; body.length == 2 |
| 3 | GET /annotations?limit=2&offset=2 |
HTTP 200; body.length == 2; ids disjoint from step 2's response |
Expected outcome: paginated read works; results are stable across paging windows. Max execution time: 5s
FT-P-06: Annotation detail by id
Summary: GET /annotations/{id} returns the full DTO including detections.
Traces to: AC-F-04
Preconditions: FT-P-04 has run.
Steps:
| Step | Consumer Action | Expected System Response |
|---|---|---|
| 1 | GET /annotations/<id from FT-P-04> |
HTTP 200; body matches AnnotationDto; body.detections.length == 5 |
Max execution time: 3s
FT-P-07: SSE delivery — event for new annotation
Summary: A subscriber connected to /annotations/events?missionId=<m> receives the lifecycle event for a POST /annotations against that mission within 1 second.
Traces to: AC-F-10
Preconditions: clean state.
Steps:
| Step | Consumer Action | Expected System Response |
|---|---|---|
| 1 | Open SSE connection to /annotations/events?missionId=<m> |
HTTP 200; Content-Type: text/event-stream |
| 2 | POST /annotations against mission <m> |
HTTP 200 |
| 3 | Read next event from the SSE stream | event arrives within 1000ms; event.data parses as AnnotationEventDto; event.operation == "Created"; event.annotationId == <id from step 2> |
Expected outcome: real-time delivery of the lifecycle event. Max execution time: 10s
FT-P-08: Outbox row on create
Summary: A successful POST /annotations inserts exactly one row into annotations_queue_records with operation == 10 (Created).
Traces to: AC-F-12 (outbox drain), AC-F-05 ([after RB-01] for non-Created paths)
Preconditions: clean state; RabbitMQ broker reachable but the test does not consume from the stream yet.
Steps:
| Step | Consumer Action | Expected System Response |
|---|---|---|
| 1 | POST /annotations (any valid payload) |
HTTP 200 |
| 2 | Out-of-band: SELECT COUNT(*) FROM annotations_queue_records WHERE annotation_id = <id> AND operation = 10 immediately after step 1 |
count == 1 (within 500ms — outbox insert happens before the response returns) |
Max execution time: 5s
FT-P-09: Stream message round-trip
Summary: After the outbox drain interval, a message arrives on the azaion-annotations stream that decodes to the documented schema.
Traces to: AC-F-12
Preconditions: FT-P-08 just succeeded; outbox row present.
Steps:
| Step | Consumer Action | Expected System Response |
|---|---|---|
| 1 | Connect a stream consumer to azaion-annotations at offset next |
consumer alive |
| 2 | Wait up to drain_interval + 2s for one message |
one message arrives |
| 3 | gzip-decompress + MessagePack-deserialize the body | object matches the documented stream schema |
| 4 | Out-of-band: re-query annotations_queue_records WHERE annotation_id = <id> |
count == 0 (drainer deleted the row) |
Max execution time: 30s (depends on configured drain interval)
FT-P-10: Media single upload
Summary: POST /media (multipart) persists the file and a media row.
Traces to: AC-F-20
Steps:
| Step | Consumer Action | Expected System Response |
|---|---|---|
| 1 | POST /media with image_small.jpg, mediaType=Image, waypointId=<m> (multipart) |
HTTP 200; body matches MediaListItem schema |
| 2 | Out-of-band: <media_dir>/<media_id>.jpg exists |
file present |
Max execution time: 5s
FT-P-11: Media batch upload
Summary: POST /media/batch with N files persists N rows + N files in one request.
Traces to: AC-F-21
Steps:
| Step | Consumer Action | Expected System Response |
|---|---|---|
| 1 | POST /media/batch with 3 distinct files (image_small, image_dense01, image_dense02) |
HTTP 200; body.length == 3; 3 distinct media ids |
| 2 | Out-of-band: 3 distinct files exist on disk | 3 files present |
Max execution time: 10s
FT-P-12: Bearer token verification — happy path
Summary: A request bearing an ES256 access token whose iss, aud, signature, and exp are all valid is accepted by every authenticated endpoint reached.
Traces to: AC-F-50
Preconditions: A test-only ES256 key pair is published at the JWT_JWKS_URL fetched by the service at boot (see test-data.md → "Bearer token harness"). The runner mints an access token signed with the matching private key.
Steps:
| Step | Consumer Action | Expected System Response |
|---|---|---|
| 1 | GET /annotations with Authorization: Bearer <ES256 token, iss=$JWT_ISSUER, aud=$JWT_AUDIENCE, exp=now+5m, ANN claim present> |
HTTP 200; valid PaginatedResponse<AnnotationListItem> body |
Max execution time: 3s
FT-P-13: Bearer token verification — alg pinning
Summary: A token signed with alg=HS256 (using the public ES256 key as the HMAC secret) is rejected — JwtExtensions.AddJwtAuth pins ValidAlgorithms = [EcdsaSha256].
Traces to: AC-F-50
Preconditions: Same harness as FT-P-12. Runner additionally produces a forged HS256 token using the public ES256 key bytes as the HMAC key.
Steps:
| Step | Consumer Action | Expected System Response |
|---|---|---|
| 1 | GET /annotations with Authorization: Bearer <forged HS256 token> |
HTTP 401; error envelope |
Max execution time: 3s
FT-P-14: Detection class catalog read
Summary: GET /classes returns the 19 seeded classes with stable ids.
Traces to: AC-F-41
Steps:
| Step | Consumer Action | Expected System Response |
|---|---|---|
| 1 | GET /classes |
HTTP 200; body.length == 19; ids [0..18] present (set equality); entry where id==9 has name=="Smoke"; entry where id==10 has name=="Plane" |
Max execution time: 3s
FT-P-15: Directory settings → PathResolver invariant
Summary: PUT /settings/directories updates the values; the next annotation create writes to the new path.
Traces to: AC-F-40
Preconditions: ADM JWT in hand. Volume mounts include both old and new paths so the SUT can write to either.
Steps:
| Step | Consumer Action | Expected System Response |
|---|---|---|
| 1 | GET /settings/directories |
HTTP 200; record current imagesDir |
| 2 | PUT /settings/directories with imagesDir = /data/images-alt |
HTTP 200 |
| 3 | GET /settings/directories |
HTTP 200; imagesDir == "/data/images-alt" |
| 4 | POST /annotations for a fresh image |
HTTP 200; out-of-band: image lands at /data/images-alt/<id>.jpg, NOT at the original imagesDir |
Expected outcome: pathResolver.Reset() has fired and the next write uses the new directory.
Max execution time: 10s
FT-P-16: Dataset filter by status
Summary: GET /dataset?status=10 (Pending) returns only Pending rows.
Traces to: AC-F-30
Preconditions: FT-P-04 just ran; one Pending annotation exists.
Steps:
| Step | Consumer Action | Expected System Response |
|---|---|---|
| 1 | GET /dataset?status=10 |
HTTP 200; every item in body has status == 10 |
Max execution time: 3s
FT-P-17: Dataset class distribution
Summary: GET /dataset/class-distribution returns counts grouped by class with the expected shape.
Traces to: AC-F-30 (read path), AC-F-41 (class metadata)
Steps:
| Step | Consumer Action | Expected System Response |
|---|---|---|
| 1 | GET /dataset/class-distribution after FT-P-04 (5 detections of mixed classes) |
HTTP 200; body is an array; entry for classNum=10 has count >= 1; sum of all count values equals total detection rows |
Max execution time: 3s
FT-P-18: Dataset bulk status
Summary: POST /dataset/status/bulk flips status atomically on N rows.
Traces to: AC-F-31
Preconditions: 2+ Pending annotations from FT-P-01 and FT-P-04 (now FT-P-04 has 1; need at least 2).
Steps:
| Step | Consumer Action | Expected System Response |
|---|---|---|
| 1 | POST /dataset/status/bulk with {annotationIds: [<id1>, <id2>], status: 20} |
HTTP 200 |
| 2 | GET /annotations/<id1> |
HTTP 200; body.status == 20 |
| 3 | GET /annotations/<id2> |
HTTP 200; body.status == 20 |
Max execution time: 5s
FT-P-19: Health check
Summary: GET /health returns 200 with low latency at any time post-boot.
Traces to: AC-F-54
Steps:
| Step | Consumer Action | Expected System Response |
|---|---|---|
| 1 | GET /health |
HTTP 200 within 200ms |
Max execution time: 2s
FT-P-20: Migrator idempotence
Summary: Restarting the SUT against the same DB makes 0 schema changes. Traces to: AC-N-02
Preconditions: SUT booted once; DB schema captured (e.g., pg_dump --schema-only).
Steps:
| Step | Consumer Action | Expected System Response |
|---|---|---|
| 1 | Capture schema-only dump → dump_a.sql |
non-empty dump |
| 2 | docker compose restart annotations |
SUT comes back healthy |
| 3 | Capture schema-only dump → dump_b.sql |
non-empty dump |
| 4 | Diff dump_a.sql and dump_b.sql |
zero meaningful differences (whitespace / SERIAL counters tolerated) |
Max execution time: 30s
FT-P-21 [after RB-01]: Lifecycle event on update
Summary: PUT /annotations/{id} emits an Updated SSE event AND inserts an outbox row.
Traces to: AC-F-05 (post RB-01)
Note: this test stays disabled (skipped with reason "awaiting RB-01") until the refactor lands.
FT-P-22 [after RB-01]: Lifecycle event on delete + soft-delete file relocation
Summary: DELETE /annotations/{id} flips status to 40, relocates files to deleted_dir, and emits a Deleted SSE event + outbox row.
Traces to: AC-F-06, AC-F-07
Note: skipped until RB-01 + RB-08 land.
Negative Scenarios
FT-N-01: Create without image bytes
Summary: POST /annotations with no image field is rejected.
Traces to: AC-F-04 (negative)
Steps:
| Step | Consumer Action | Expected System Response |
|---|---|---|
| 1 | POST /annotations with body missing image |
HTTP 400 or 422; error envelope |
Max execution time: 3s
FT-N-02: Create without mediaType
Summary: Missing required enum field is rejected.
| Step | Consumer Action | Expected System Response |
|---|---|---|
| 1 | POST /annotations with no mediaType |
HTTP 400 or 422; error envelope |
Max execution time: 3s
FT-N-03: Create without ANN policy
Summary: A token with policy DATASET cannot create annotations.
Traces to: AC-F-52
| Step | Consumer Action | Expected System Response |
|---|---|---|
| 1 | POST /annotations with an ES256 token carrying only the DATASET claim |
HTTP 403; error envelope |
Max execution time: 3s
FT-N-04: Create unauthenticated
Summary: Missing Authorization header → 401.
| Step | Consumer Action | Expected System Response |
|---|---|---|
| 1 | POST /annotations with no Authorization header |
HTTP 401; error envelope |
Max execution time: 3s
FT-N-05: Out-of-range bbox value (current lenient behavior)
Summary: centerX = 1.5 is accepted today; the test asserts the current behavior. Will flip to expecting 400/422 after SEC-05 lands.
Traces to: documented gap in security_approach.md SEC-05
| Step | Consumer Action | Expected System Response |
|---|---|---|
| 1 | POST /annotations with detections[0].centerX = 1.5 |
HTTP 200 today (lenient); test will be inverted post-SEC-05 |
Max execution time: 3s
FT-N-06: GET nonexistent annotation
| Step | Consumer Action | Expected System Response |
|---|---|---|
| 1 | GET /annotations/00000000000000000000000000000000 |
HTTP 404; error envelope; error.code matches /not.?found/i |
Max execution time: 3s
FT-N-07: Filter by unknown mission
| Step | Consumer Action | Expected System Response |
|---|---|---|
| 1 | GET /annotations?missionId=<unknown-guid> |
HTTP 200; body.length == 0 |
Max execution time: 3s
FT-N-08: SSE without auth
| Step | Consumer Action | Expected System Response |
|---|---|---|
| 1 | Open SSE to /annotations/events?missionId=<m> with no Authorization |
HTTP 401 on connection establishment |
Max execution time: 3s
FT-N-09: Bearer token — expired
| Step | Consumer Action | Expected System Response |
|---|---|---|
| 1 | GET /annotations with Authorization: Bearer <token with exp=now-1m, otherwise valid> |
HTTP 401; error envelope |
Max execution time: 3s
FT-N-10: Bearer token — wrong issuer
| Step | Consumer Action | Expected System Response |
|---|---|---|
| 1 | GET /annotations with Authorization: Bearer <token with iss="https://other.example.com" but otherwise valid> |
HTTP 401; error envelope |
Max execution time: 3s
FT-N-11: Bearer token — wrong audience
| Step | Consumer Action | Expected System Response |
|---|---|---|
| 1 | GET /annotations with Authorization: Bearer <token with aud="some-other-service" but otherwise valid> |
HTTP 401; error envelope |
Max execution time: 3s
FT-N-12: Mutating settings without ADM
| Step | Consumer Action | Expected System Response |
|---|---|---|
| 1 | PUT /settings/system with an ES256 token carrying only the ANN claim |
HTTP 403; error envelope |
Max execution time: 3s
FT-N-13: PUT directories without ADM
| Step | Consumer Action | Expected System Response |
|---|---|---|
| 1 | PUT /settings/directories with non-ADM JWT |
HTTP 403; error envelope |
Max execution time: 3s
FT-N-14: Media upload missing waypoint
| Step | Consumer Action | Expected System Response |
|---|---|---|
| 1 | POST /media multipart without waypointId |
HTTP 400 or 422; error envelope |
Max execution time: 3s
FT-N-15: Media upload without ANN
| Step | Consumer Action | Expected System Response |
|---|---|---|
| 1 | POST /media with non-ANN JWT |
HTTP 403; error envelope |
Max execution time: 3s
FT-N-16: Bulk status with empty list
| Step | Consumer Action | Expected System Response |
|---|---|---|
| 1 | POST /dataset/status/bulk with annotationIds: [] |
HTTP 400; error envelope (verified: DatasetService.BulkUpdateStatus throws ArgumentException) |
Max execution time: 3s