docs+src: complete Steps 1-3 outcomes + auth re-sync baseline

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>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-14 20:19:05 +03:00
parent 08eadc1158
commit 03f879206e
66 changed files with 6006 additions and 133 deletions
+572
View File
@@ -0,0 +1,572 @@
# 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 (`/health` returns 200)
- DB clean (no rows in `annotations`, `detection`, `media`, `annotations_queue_records`)
- Runner has minted an ES256 token with the `ANN` claim (see `test-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.jpg` already 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