mirror of
https://github.com/azaion/annotations.git
synced 2026-06-21 11:11:06 +00:00
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:
@@ -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
|
||||
@@ -0,0 +1,218 @@
|
||||
# Test Environment
|
||||
|
||||
## Overview
|
||||
|
||||
**System under test**: `Azaion.Annotations` HTTP API on port 8080 (REST + SSE) plus its RabbitMQ Stream producer (`azaion-annotations` stream).
|
||||
**Consumer app purpose**: A standalone test runner that exercises the system through its public HTTP / SSE / Stream interfaces only — no in-process imports, no direct DB queries against the system's main DB, no shared filesystem.
|
||||
|
||||
## Docker Environment
|
||||
|
||||
### Services
|
||||
|
||||
| Service | Image / Build | Purpose | Ports |
|
||||
|---------|--------------|---------|-------|
|
||||
| `annotations` | Built from `src/Dockerfile` (ARM64) with `AZAION_REVISION=test-<sha>` | System under test | `8080:8080` |
|
||||
| `postgres` | `postgres:13` | DB for the system under test | `5432:5432` (private to test net) |
|
||||
| `rabbitmq` | `rabbitmq:3.13-management` with the **streams plugin** enabled | Stream broker the SUT publishes to | `5552:5552` (stream listener), `15672:15672` (mgmt UI, optional) |
|
||||
| `e2e-runner` | Built from `tests/Azaion.Annotations.E2E/Dockerfile` | Black-box test runner (xUnit + HttpClient + RabbitMQ.Stream.Client consumer); also holds the ES256 private key used to mint per-test bearer tokens | — |
|
||||
| `e2e-issuer` | `python:3.12-alpine` running `tests/harness/mock_issuer.py` (≈40 lines, serves a static JWKS over HTTP) | Mock JWKS endpoint stand-in for admin's real issuer; publishes the public ES256 key the SUT validates against | `8080` (on `e2e-net`; not exposed to host) |
|
||||
| `dataseed` | One-shot job: `psql` only | Boot-time seed of any required reference data (no users — annotations has no `users` table) | — |
|
||||
|
||||
The fixture binaries (frame images, videos) are mounted from `../detections/_docs/00_problem/input_data/` (suite-relative path, see `_docs/00_problem/input_data/fixtures.md`) into both the `annotations` service (read-only, for direct file ingestion paths) and the `e2e-runner` (read-only, for upload-as-multipart paths).
|
||||
|
||||
### Networks
|
||||
|
||||
| Network | Services | Purpose |
|
||||
|---------|----------|---------|
|
||||
| `e2e-net` | `annotations`, `postgres`, `rabbitmq`, `e2e-issuer`, `e2e-runner`, `dataseed` | Isolated bridge network — services reach each other by container hostname |
|
||||
|
||||
### Volumes
|
||||
|
||||
| Volume | Mounted to | Purpose |
|
||||
|--------|-----------|---------|
|
||||
| `annotations-images` | `annotations:/data/images` | `images_dir` — content-addressed image bytes + YOLO label files |
|
||||
| `annotations-videos` | `annotations:/data/videos` | `videos_dir` |
|
||||
| `annotations-deleted` | `annotations:/data/deleted` | `deleted_dir` (post RB-01 soft-delete relocation) |
|
||||
| `pg-data` | `postgres:/var/lib/postgresql/data` | DB durability across container restart (resilience scenarios) |
|
||||
| `fixtures-ro` (bind) | `annotations:/fixtures:ro`, `e2e-runner:/fixtures:ro` | Reuse of detections corpus binaries |
|
||||
|
||||
### docker-compose structure
|
||||
|
||||
```yaml
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:13
|
||||
environment:
|
||||
POSTGRES_DB: annotations
|
||||
POSTGRES_USER: annotations
|
||||
POSTGRES_PASSWORD: annotations
|
||||
volumes:
|
||||
- pg-data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U annotations"]
|
||||
|
||||
rabbitmq:
|
||||
image: rabbitmq:3.13-management
|
||||
environment:
|
||||
RABBITMQ_DEFAULT_USER: annotations
|
||||
RABBITMQ_DEFAULT_PASS: annotations
|
||||
RABBITMQ_PLUGINS: rabbitmq_stream rabbitmq_management
|
||||
healthcheck:
|
||||
test: ["CMD", "rabbitmq-diagnostics", "ping"]
|
||||
|
||||
e2e-issuer:
|
||||
image: python:3.12-alpine
|
||||
command: ["python", "/harness/mock_issuer.py"]
|
||||
volumes:
|
||||
- ../tests/harness:/harness:ro
|
||||
- jwt-keys:/keys
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:8080/.well-known/jwks.json"]
|
||||
|
||||
annotations:
|
||||
build:
|
||||
context: ../src
|
||||
environment:
|
||||
ASPNETCORE_ENVIRONMENT: E2ETest
|
||||
DATABASE_URL: postgresql://annotations:annotations@postgres:5432/annotations
|
||||
JWT_ISSUER: https://e2e-issuer.test
|
||||
JWT_AUDIENCE: annotations-e2e
|
||||
JWT_JWKS_URL: http://e2e-issuer:8080/.well-known/jwks.json
|
||||
CorsConfig__AllowedOrigins__0: http://e2e-runner.test
|
||||
RABBITMQ_HOST: rabbitmq
|
||||
RABBITMQ_STREAM_PORT: 5552
|
||||
RABBITMQ_PRODUCER_USER: annotations
|
||||
RABBITMQ_PRODUCER_PASS: annotations
|
||||
AZAION_REVISION: test-${GIT_SHA:-local}
|
||||
volumes:
|
||||
- annotations-images:/data/images
|
||||
- annotations-videos:/data/videos
|
||||
- annotations-deleted:/data/deleted
|
||||
- ../../detections/_docs/00_problem/input_data:/fixtures:ro
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
rabbitmq:
|
||||
condition: service_healthy
|
||||
e2e-issuer:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"]
|
||||
|
||||
dataseed:
|
||||
image: postgres:13
|
||||
depends_on:
|
||||
annotations:
|
||||
condition: service_healthy
|
||||
entrypoint: ["/bin/sh", "/seed/run.sh"]
|
||||
volumes:
|
||||
- ./seed:/seed:ro
|
||||
|
||||
e2e-runner:
|
||||
build:
|
||||
context: ../tests/Azaion.Annotations.E2E
|
||||
depends_on:
|
||||
dataseed:
|
||||
condition: service_completed_successfully
|
||||
environment:
|
||||
ANNOTATIONS_BASE_URL: http://annotations:8080
|
||||
JWT_ISSUER: https://e2e-issuer.test
|
||||
JWT_AUDIENCE: annotations-e2e
|
||||
RABBITMQ_HOST: rabbitmq
|
||||
RABBITMQ_STREAM_PORT: 5552
|
||||
RABBITMQ_USER: annotations
|
||||
RABBITMQ_PASS: annotations
|
||||
FIXTURES_DIR: /fixtures
|
||||
volumes:
|
||||
- ../../detections/_docs/00_problem/input_data:/fixtures:ro
|
||||
- jwt-keys:/keys:ro
|
||||
|
||||
volumes:
|
||||
pg-data: {}
|
||||
annotations-images: {}
|
||||
annotations-videos: {}
|
||||
annotations-deleted: {}
|
||||
jwt-keys: {}
|
||||
|
||||
networks:
|
||||
default:
|
||||
name: e2e-net
|
||||
```
|
||||
|
||||
## Consumer Application
|
||||
|
||||
**Tech stack**: .NET 10 + xUnit (matches the SUT runtime to avoid a second toolchain in CI). Uses `HttpClient` for REST, raw `HttpClient` with `text/event-stream` for SSE, and `RabbitMQ.Stream.Client` for stream-consumer scenarios.
|
||||
**Entry point**: `dotnet test --logger "console;verbosity=normal" --logger "trx" --results-directory /results`
|
||||
|
||||
### Communication with system under test
|
||||
|
||||
| Interface | Protocol | Endpoint / Topic | Authentication |
|
||||
|-----------|----------|-----------------|----------------|
|
||||
| Annotations REST | HTTP/1.1 JSON | `http://annotations:8080/annotations/*`, `/media/*`, `/dataset/*`, `/settings/*`, `/classes`, `/health` | `Authorization: Bearer <jwt>` (ES256 JWT minted on demand by the runner using the in-stack mock-issuer key) |
|
||||
| Annotations SSE | HTTP/1.1 `text/event-stream` | `http://annotations:8080/annotations/events?missionId=<guid>` | Same ES256 bearer token |
|
||||
| Mock JWKS | HTTP/1.1 JSON | `http://e2e-issuer:8080/.well-known/jwks.json` | None (test-net only) |
|
||||
| RabbitMQ Stream | AMQP 1.0 / streams (port 5552) | Stream `azaion-annotations` | Username + password env vars; consumer offset starts at `next` for fresh test runs |
|
||||
| Postgres (test-only, read-only assertions on DB state) | direct (out-of-band) | `postgresql://postgres:5432/annotations` | DB user; **only the test runner uses this and only for blackbox-allowed assertions** (e.g., F4-001 verifying the outbox row was inserted). Tests that need DB introspection are clearly marked. |
|
||||
|
||||
### What the consumer does NOT have access to
|
||||
|
||||
- No in-process import of the `Azaion.Annotations` assembly.
|
||||
- No direct write to the SUT's `annotations`, `media`, `detection`, `annotations_queue_records` tables (DB read access only, for outbox-state assertions documented in `test-data.md`). Annotations has no `users` table.
|
||||
- No shared memory or filesystem with the SUT (volumes are mounted read-only).
|
||||
- No mocking of internal services (`AnnotationService`, `FailsafeProducer`, etc.) — all interactions go through the public surface.
|
||||
|
||||
## CI/CD Integration
|
||||
|
||||
**When to run**: on every push to `dev` and on every PR; nightly full run including the long-running performance + resilience scenarios.
|
||||
**Pipeline stage**: after Woodpecker `build` step; new step `test-e2e` invoking `docker compose -f e2e/docker-compose.test.yml up --abort-on-container-exit --exit-code-from e2e-runner` (or, equivalently, `scripts/run-tests.sh`).
|
||||
**Gate behavior**: any failed scenario blocks the merge; nightly perf failures emit a warning but do not block a green PR.
|
||||
**Timeout**: 30 min for the standard suite (functional + smoke perf); 2 hours for the nightly full perf + resilience suite.
|
||||
|
||||
## Reporting
|
||||
|
||||
**Format**: CSV (xUnit's `trx` output is converted by the runner into a flat CSV).
|
||||
**Columns**: `test_id`, `test_name`, `category`, `traces_to`, `execution_time_ms`, `result`, `error_message`.
|
||||
**Output path**: `e2e-results/report.csv` inside the `e2e-runner` container, mounted out to `./e2e-results/report.csv` on the host.
|
||||
|
||||
In addition, raw xUnit `.trx` is preserved at `e2e-results/results.trx` for human inspection / IDE integration.
|
||||
|
||||
## Dependencies on the existing stack
|
||||
|
||||
This environment intentionally **does not** re-use the suite's running development DB or RabbitMQ — it stands up its own. The only suite-level dependency is the read-only mount of `detections/_docs/00_problem/input_data/` for fixtures.
|
||||
|
||||
## Test Execution
|
||||
|
||||
**Decision**: Docker only.
|
||||
|
||||
**Rationale** (from Hardware-Dependency Assessment, run between test-spec Phase 3 and Phase 4):
|
||||
|
||||
- **Documentation scan** — `restrictions.md` lists HW-01 (ARM64-only image), HW-02 (writable filesystem dirs), HW-03 (memory pressure on `FailsafeProducer`). None of these are accelerator / sensor / OS-feature dependencies; they are generic infrastructure constraints satisfiable in any Linux container.
|
||||
- **Code scan** — zero hits across `src/` for CUDA, TensorRT, CoreML, OpenCL, Vulkan, TPU, V4L2, GPIO, `cv2.VideoCapture`, `sys.platform`-style branches, or `platform.machine()` checks. The Dockerfile's `TARGETARCH` branch (line 5) is a buildplatform-aware Node toolchain selector, not a runtime hardware gate — the running binary uses managed .NET 10 with no native acceleration paths.
|
||||
- **Dependency files** — `Azaion.Annotations.csproj` references only managed NuGet packages (Linq2DB, Npgsql, JwtBearer, RabbitMQ.Stream.Client, MessagePack, Swashbuckle, System.IO.Hashing). No native-binding libraries, no hardware-specific packages.
|
||||
|
||||
**Classification**: not hardware-dependent. Docker is the preferred default and the only chosen mode.
|
||||
|
||||
### Docker mode — execution instructions
|
||||
|
||||
Run from the suite root (parent of `annotations/` and `detections/`) so the fixture bind-mount path resolves:
|
||||
|
||||
```bash
|
||||
# From the annotations repo root:
|
||||
./scripts/run-tests.sh # functional + smoke perf
|
||||
./scripts/run-performance-tests.sh # full perf scenarios
|
||||
|
||||
# Equivalent without the wrapper:
|
||||
docker compose -f e2e/docker-compose.test.yml up \
|
||||
--abort-on-container-exit \
|
||||
--exit-code-from e2e-runner
|
||||
```
|
||||
|
||||
Results land at `e2e/e2e-results/report.csv` (host path), and at `test-results/` for any JUnit/CTRX outputs. The exit code of `e2e-runner` becomes the suite's exit code; CI uses it as the gate.
|
||||
|
||||
### Why not local mode
|
||||
|
||||
The xUnit test runner CAN execute against a SUT bound to `localhost:8080` if a developer wants to iterate inside the IDE. That path is not the supported test environment for CI; it is a developer convenience. Phase 4 produces only the Docker runner script.
|
||||
|
||||
### CI image arch
|
||||
|
||||
The Docker test stack runs on the same ARM64 hosts the Woodpecker pipeline already targets (HW-01). If a future CI runner family is x86_64-only, the same docker-compose works because every service in `e2e-net` is multi-arch (`postgres:13`, `rabbitmq:3.13-management`, the SUT itself if rebuilt with `--platform linux/amd64`).
|
||||
@@ -0,0 +1,147 @@
|
||||
# Performance Tests
|
||||
|
||||
> **Calibration note**: no contracted SLAs exist anywhere in the codebase or `acceptance_criteria.md`. The thresholds below are **inferred starting points** anchored to the documented system properties. Step 15 (Performance Test) of the autodev existing-code flow will tune them against real targets. A test that fails the threshold is a *signal*, not a release-blocker, until the targets are contracted.
|
||||
|
||||
### NFT-PERF-LATENCY-01: Annotation create — p95 latency, small image
|
||||
|
||||
**Summary**: Sequential `POST /annotations` with a small frame stays under a per-call threshold at p95.
|
||||
**Traces to**: implicit NFR; documented gap on AC-N-* (no contracted target)
|
||||
**Metric**: end-to-end response latency in ms (consumer wall-clock from request start to body close).
|
||||
|
||||
**Preconditions**:
|
||||
- SUT freshly started; warmup loop of 10 sequential calls discarded.
|
||||
- Clean state; clean outbox; RabbitMQ stream consumer not connected (writes fan out via channel + outbox only).
|
||||
- Single in-process consumer (no concurrent load).
|
||||
|
||||
**Steps**:
|
||||
|
||||
| Step | Consumer Action | Measurement |
|
||||
|------|----------------|-------------|
|
||||
| 1 | Warmup: 10× `POST /annotations` with `image_small.jpg` | discarded |
|
||||
| 2 | Measure: 50× `POST /annotations` with `image_small.jpg`, sequential, single consumer | record latency per call |
|
||||
| 3 | Compute p50, p95, p99 | summary stats |
|
||||
|
||||
**Pass criteria**: p95 ≤ 1500ms, p99 ≤ 3000ms (single-instance dev DB, no concurrent load).
|
||||
**Duration**: ~2 minutes.
|
||||
|
||||
---
|
||||
|
||||
### NFT-PERF-LATENCY-02: Annotation create — large image
|
||||
|
||||
**Summary**: Same shape as -01 with a 7 MB image.
|
||||
**Traces to**: same as -01.
|
||||
**Metric**: end-to-end latency.
|
||||
|
||||
**Preconditions**: same as -01.
|
||||
|
||||
**Steps**:
|
||||
|
||||
| Step | Consumer Action | Measurement |
|
||||
|------|----------------|-------------|
|
||||
| 1 | Warmup: 5× `POST /annotations` with `image_large.JPG` | discarded |
|
||||
| 2 | Measure: 20× `POST /annotations` with `image_large.JPG`, sequential | record latency per call |
|
||||
| 3 | p50, p95, p99 | summary stats |
|
||||
|
||||
**Pass criteria**: p95 ≤ 5000ms, p99 ≤ 8000ms.
|
||||
**Duration**: ~2 minutes.
|
||||
|
||||
---
|
||||
|
||||
### NFT-PERF-THROUGHPUT-01: Annotation create — sustained writes
|
||||
|
||||
**Summary**: 5-minute sustained `POST /annotations` traffic at 5 RPS does not degrade response latency.
|
||||
**Metric**: response latency over time + total successful responses.
|
||||
|
||||
**Preconditions**: SUT warm; clean state; clean outbox; RabbitMQ broker reachable.
|
||||
|
||||
**Steps**:
|
||||
|
||||
| Step | Consumer Action | Measurement |
|
||||
|------|----------------|-------------|
|
||||
| 1 | Warmup: 30s at 5 RPS with `image_small.jpg` | discarded |
|
||||
| 2 | Measure: 5 minutes at 5 RPS, 1 consumer | record per-second latency p50/p95 |
|
||||
| 3 | Compare windows | p95 in last minute ≤ 1.5× p95 in first minute |
|
||||
|
||||
**Pass criteria**: 0 HTTP 5xx; p95 latency in last minute ≤ 1.5× p95 in first minute.
|
||||
**Duration**: ~6 minutes.
|
||||
|
||||
---
|
||||
|
||||
### NFT-PERF-OUTBOX-DRAIN-01: FailsafeProducer drain rate
|
||||
|
||||
**Summary**: Under sustained writes, the outbox queue depth stays bounded.
|
||||
**Traces to**: AC-N-03
|
||||
**Metric**: `SELECT COUNT(*) FROM annotations_queue_records` sampled every 5s during the run.
|
||||
|
||||
**Preconditions**: NFT-PERF-THROUGHPUT-01 running; RabbitMQ broker reachable; no stream consumer back-pressure.
|
||||
|
||||
**Steps**:
|
||||
|
||||
| Step | Consumer Action | Measurement |
|
||||
|------|----------------|-------------|
|
||||
| 1 | While -THROUGHPUT-01 is running, sample queue depth every 5s for the full duration | record samples |
|
||||
| 2 | Compute max queue depth + average drain interval | summary stats |
|
||||
|
||||
**Pass criteria**: max queue depth ≤ 100 rows; depth at end-of-run ≤ depth at start-of-run + 10.
|
||||
**Duration**: 5 minutes (overlaid on -THROUGHPUT-01).
|
||||
|
||||
---
|
||||
|
||||
### NFT-PERF-SSE-FANOUT-01: SSE delivery latency under modest fan-out
|
||||
|
||||
**Summary**: 10 simultaneous SSE subscribers receive every event for their mission within the latency budget.
|
||||
**Traces to**: AC-F-10
|
||||
**Metric**: per-subscriber event-arrival latency (consumer wall-clock from `POST /annotations` returning to SSE event arrival).
|
||||
|
||||
**Preconditions**: SUT warm; clean state.
|
||||
|
||||
**Steps**:
|
||||
|
||||
| Step | Consumer Action | Measurement |
|
||||
|------|----------------|-------------|
|
||||
| 1 | Open 10 SSE connections to `/annotations/events?missionId=<m>` | all 10 alive |
|
||||
| 2 | `POST /annotations` once for mission `<m>` | record post-return timestamp |
|
||||
| 3 | Each subscriber records its event-arrival timestamp | per-subscriber latency |
|
||||
| 4 | Compute max latency across the 10 subscribers | summary |
|
||||
|
||||
**Pass criteria**: every subscriber receives the event; max latency ≤ 1000ms.
|
||||
**Duration**: 30s.
|
||||
|
||||
---
|
||||
|
||||
### NFT-PERF-LIST-01: Annotation listing on populated DB
|
||||
|
||||
**Summary**: `GET /annotations?limit=100` against a DB with 10,000 rows responds within budget.
|
||||
**Metric**: end-to-end response latency.
|
||||
|
||||
**Preconditions**: DB pre-seeded with 10,000 annotations + 50,000 detections (use `dataseed` to insert via direct SQL, bypassing the public API for population speed — the test still queries via the public API).
|
||||
|
||||
**Steps**:
|
||||
|
||||
| Step | Consumer Action | Measurement |
|
||||
|------|----------------|-------------|
|
||||
| 1 | Warmup: 5× `GET /annotations?limit=100&offset=0` | discarded |
|
||||
| 2 | Measure: 20× `GET /annotations?limit=100&offset=<random 0..9000>` | record per-call latency |
|
||||
| 3 | p95 | summary |
|
||||
|
||||
**Pass criteria**: p95 ≤ 1000ms (read-only path; index `ix_annotations_created_date` should keep it fast).
|
||||
**Duration**: ~1 minute.
|
||||
|
||||
---
|
||||
|
||||
### NFT-PERF-DATASET-01: Dataset class distribution at scale
|
||||
|
||||
**Summary**: `GET /dataset/class-distribution` against the populated DB.
|
||||
**Metric**: end-to-end latency.
|
||||
|
||||
**Preconditions**: same populated DB as NFT-PERF-LIST-01.
|
||||
|
||||
**Steps**:
|
||||
|
||||
| Step | Consumer Action | Measurement |
|
||||
|------|----------------|-------------|
|
||||
| 1 | Warmup: 3 calls | discarded |
|
||||
| 2 | Measure: 10 calls | record latency |
|
||||
|
||||
**Pass criteria**: p95 ≤ 2000ms.
|
||||
**Duration**: ~30s.
|
||||
@@ -0,0 +1,134 @@
|
||||
# Resilience Tests
|
||||
|
||||
### NFT-RES-01: RabbitMQ broker outage during create
|
||||
|
||||
**Summary**: `POST /annotations` succeeds (HTTP 200) when the RabbitMQ broker is unreachable; the outbox row is preserved; `FailsafeProducer` does not crash; on broker recovery the message is delivered.
|
||||
**Traces to**: AC-F-12, OP-02 (single-instance baseline)
|
||||
|
||||
**Preconditions**: SUT healthy; broker initially reachable; clean outbox.
|
||||
|
||||
**Fault injection**:
|
||||
- `docker exec rabbitmq rabbitmqctl stop_app` mid-test (stops AMQP/streams listeners; container stays up).
|
||||
|
||||
**Steps**:
|
||||
|
||||
| Step | Action | Expected Behavior |
|
||||
|------|--------|------------------|
|
||||
| 1 | Stop RabbitMQ app | broker unreachable on 5552 |
|
||||
| 2 | `POST /annotations` once | HTTP 200; outbox row inserted |
|
||||
| 3 | Out-of-band: `SELECT COUNT(*) FROM annotations_queue_records WHERE annotation_id = <id>` | `count == 1` (row not deleted because drain failed) |
|
||||
| 4 | `GET /health` | HTTP 200 (SUT not crashed) |
|
||||
| 5 | `docker exec rabbitmq rabbitmqctl start_app` | broker recovers |
|
||||
| 6 | Wait `drain_interval × 3` | drainer publishes the queued message |
|
||||
| 7 | Out-of-band: `SELECT COUNT(*) FROM annotations_queue_records WHERE annotation_id = <id>` | `count == 0` (drained) |
|
||||
| 8 | Stream consumer (started before step 5 at offset `next`) reads one message | message body matches the documented schema |
|
||||
|
||||
**Pass criteria**: zero 5xx during outage; outbox preserves the row; recovery delivers the deferred message; total recovery time ≤ 60s after broker comes back.
|
||||
**Duration**: ~2 minutes.
|
||||
|
||||
---
|
||||
|
||||
### NFT-RES-02: Postgres restart between writes
|
||||
|
||||
**Summary**: Killing and restarting Postgres during a quiet period does not corrupt state; subsequent writes succeed.
|
||||
**Traces to**: AC-N-02 (idempotent migrator), implicit data-integrity NFR
|
||||
|
||||
**Fault injection**: `docker compose restart postgres` while no in-flight requests.
|
||||
|
||||
**Steps**:
|
||||
|
||||
| Step | Action | Expected Behavior |
|
||||
|------|--------|------------------|
|
||||
| 1 | `POST /annotations` once (FT-P-01-shape) | HTTP 200; row in DB |
|
||||
| 2 | `docker compose restart postgres` | DB up after ~5s |
|
||||
| 3 | Wait for SUT `/health` to return 200 | SUT recovers connection pool (or restarts itself) |
|
||||
| 4 | `POST /annotations` again | HTTP 200; row in DB |
|
||||
| 5 | `GET /annotations/<id from step 1>` | HTTP 200; original row intact |
|
||||
|
||||
**Pass criteria**: original row intact after restart; new write succeeds within 30s of DB recovery; zero data loss.
|
||||
**Duration**: ~2 minutes.
|
||||
|
||||
---
|
||||
|
||||
### NFT-RES-03: Postgres unreachable during create
|
||||
|
||||
**Summary**: When DB is unreachable mid-request, the SUT returns a structured error envelope (no 500 with stack trace); the SUT recovers when DB returns.
|
||||
**Traces to**: AC-N-04 (zero unhandled exceptions to clients)
|
||||
|
||||
**Fault injection**: `docker pause postgres` between request start and request end (race-y; use a delay-injecting test proxy if needed).
|
||||
|
||||
**Steps**:
|
||||
|
||||
| Step | Action | Expected Behavior |
|
||||
|------|--------|------------------|
|
||||
| 1 | `docker pause postgres` | DB connections hang |
|
||||
| 2 | `POST /annotations` once with timeout 30s | HTTP 5xx OR HTTP 503; **error envelope present**; **no raw exception text in body** |
|
||||
| 3 | `docker unpause postgres` | DB responsive |
|
||||
| 4 | `POST /annotations` again | HTTP 200; SUT recovered |
|
||||
|
||||
**Pass criteria**: under-DB-outage response uses the error envelope; SUT recovers within 30s of DB recovery.
|
||||
**Duration**: ~2 minutes.
|
||||
|
||||
---
|
||||
|
||||
### NFT-RES-04: SSE subscriber disconnect mid-stream
|
||||
|
||||
**Summary**: A subscriber that disconnects mid-stream does not crash the SUT or block other subscribers.
|
||||
**Traces to**: AC-F-10, OP-01 (per-instance SSE state)
|
||||
|
||||
**Steps**:
|
||||
|
||||
| Step | Action | Expected Behavior |
|
||||
|------|--------|------------------|
|
||||
| 1 | Open 3 SSE connections to `/annotations/events?missionId=<m>` | all 3 alive |
|
||||
| 2 | Abruptly close subscriber #2 (TCP RST) | SUT cleans up its channel slot |
|
||||
| 3 | `POST /annotations` for mission `<m>` | HTTP 200 |
|
||||
| 4 | Subscribers #1 and #3 each receive the event | both receive within 1000ms |
|
||||
| 5 | `GET /health` | HTTP 200 |
|
||||
|
||||
**Pass criteria**: surviving subscribers still receive events; no SUT memory growth visible (channel slots reclaimed); `/health` stays green.
|
||||
**Duration**: ~1 minute.
|
||||
|
||||
---
|
||||
|
||||
### NFT-RES-05: Repeated FailsafeProducer empty-catch path
|
||||
|
||||
**Summary**: When the image referenced by an outbox row no longer exists on disk, the drainer logs and proceeds (post RB-05). Tests today's behavior (empty catch) AND, after RB-05 lands, asserts the logged failure path.
|
||||
**Traces to**: RB-05
|
||||
|
||||
**Fault injection**: insert an outbox row whose `annotation_id` references a missing image (manually delete the file after `POST /annotations` returned 200, before the drain interval fires).
|
||||
|
||||
**Steps**:
|
||||
|
||||
| Step | Action | Expected Behavior |
|
||||
|------|--------|------------------|
|
||||
| 1 | `POST /annotations` (FT-P-01) | HTTP 200; outbox row + image file present |
|
||||
| 2 | Delete `<images_dir>/<id>.jpg` | image gone |
|
||||
| 3 | Wait `drain_interval × 2` | drainer runs |
|
||||
| 4 | Out-of-band: `SELECT COUNT(*) FROM annotations_queue_records WHERE annotation_id = <id>` | today's behavior: row may be deleted or stuck (empty catch swallows IOException) — **document actual behavior here** |
|
||||
| 5 `[after RB-05]` | Inspect SUT logs for an `ERROR` entry mentioning the missing image | one log entry present; metric counter `failsafe_drain_errors` incremented |
|
||||
|
||||
**Pass criteria today**: SUT does not crash; `/health` stays 200.
|
||||
**Pass criteria after RB-05**: as above + the logged failure path is exercised.
|
||||
**Duration**: ~1 minute.
|
||||
|
||||
---
|
||||
|
||||
### NFT-RES-06: Stream consumer reconnect
|
||||
|
||||
**Summary**: A stream consumer that drops and reconnects with offset `last_committed` reads only post-disconnect messages.
|
||||
**Traces to**: implicit (consumer-side concern, but documents the contract Annotations producer expects)
|
||||
|
||||
**Steps**:
|
||||
|
||||
| Step | Action | Expected Behavior |
|
||||
|------|--------|------------------|
|
||||
| 1 | Start consumer at offset `next`; record current end-of-stream offset `O0` | consumer up |
|
||||
| 2 | `POST /annotations` 5 times | 5 outbox rows; 5 stream messages produced shortly after |
|
||||
| 3 | Consumer reads all 5; commits offset after each | consumer offset = `O0 + 5` |
|
||||
| 4 | Disconnect consumer | done |
|
||||
| 5 | `POST /annotations` 3 more times | 3 more stream messages |
|
||||
| 6 | Reconnect consumer at `last_committed = O0 + 5` | consumer reads only messages 6..8 |
|
||||
|
||||
**Pass criteria**: re-attached consumer sees no duplicates and no gaps.
|
||||
**Duration**: ~1 minute.
|
||||
@@ -0,0 +1,123 @@
|
||||
# Resource Limit Tests
|
||||
|
||||
### NFT-RES-LIM-01: Sustained-load process memory
|
||||
|
||||
**Summary**: Process memory stays bounded under sustained `POST /annotations` traffic.
|
||||
**Traces to**: AC-N-03 (outbox depth bounded → memory bounded), HW-03 (memory pressure on `FailsafeProducer`'s image re-read)
|
||||
**Preconditions**: SUT freshly started; clean state; a stream consumer connected so the outbox actually drains.
|
||||
|
||||
**Monitoring**:
|
||||
- `docker stats annotations` polled every 10s for `MemUsage` (RSS) and `MemPerc`.
|
||||
- Sample at the 0s / 60s / 600s marks.
|
||||
|
||||
**Duration**: 10 minutes at 5 RPS.
|
||||
**Pass criteria**: RSS at the 600s mark ≤ 1.5× RSS at the 60s mark; no OOMKilled events; container stays healthy.
|
||||
|
||||
---
|
||||
|
||||
### NFT-RES-LIM-02: Single-file upload boundary
|
||||
|
||||
**Summary**: Determine the maximum single-file upload size accepted by `POST /media`.
|
||||
**Traces to**: documented gap (no explicit limit in code; ASP.NET form-options apply)
|
||||
|
||||
**Monitoring**: HTTP status code per uploaded size.
|
||||
|
||||
**Steps**:
|
||||
|
||||
| Size | Expected Result |
|
||||
|------|-----------------|
|
||||
| 1 MB | HTTP 200 |
|
||||
| 10 MB | HTTP 200 |
|
||||
| 50 MB | HTTP 200 |
|
||||
| 100 MB | HTTP 200 (probable, depends on ASP.NET defaults) |
|
||||
| 256 MB | HTTP 200 OR 400 (test the boundary) |
|
||||
| 512 MB | likely HTTP 400 / form-options reject |
|
||||
|
||||
**Duration**: ~5 minutes (one upload per size).
|
||||
**Pass criteria**: a clear cutoff size is documented; below it the SUT accepts; at or above it the SUT returns the error envelope (NOT a 500 with no body, NOT a hang).
|
||||
|
||||
---
|
||||
|
||||
### NFT-RES-LIM-03: Outbox depth under broker outage
|
||||
|
||||
**Summary**: With RabbitMQ stopped for an extended period, the outbox `annotations_queue_records` table grows linearly with traffic AND does not exceed disk capacity / DB connection pool limits within the test window.
|
||||
**Traces to**: NFT-RES-01 (extended), AC-N-03
|
||||
|
||||
**Monitoring**:
|
||||
- `SELECT COUNT(*) FROM annotations_queue_records` every 30s.
|
||||
- Disk usage of the Postgres data volume every minute.
|
||||
- `docker stats postgres` for memory.
|
||||
|
||||
**Steps**:
|
||||
|
||||
| Step | Action | Expected Behavior |
|
||||
|------|--------|------------------|
|
||||
| 1 | `docker exec rabbitmq rabbitmqctl stop_app` | broker down |
|
||||
| 2 | Run 10 RPS of `POST /annotations` for 5 minutes | 3000 outbox rows written |
|
||||
| 3 | Sample queue depth and disk usage | depth grows linearly; disk grows linearly with image bytes (since `images_dir` is also written) |
|
||||
| 4 | `docker exec rabbitmq rabbitmqctl start_app` | broker recovers |
|
||||
| 5 | Wait for queue to drain | depth goes to 0 within 5 minutes of recovery |
|
||||
|
||||
**Duration**: 15 minutes total.
|
||||
**Pass criteria**:
|
||||
- During outage: SUT does not return 5xx; queue depth is exactly equal to total successful POSTs since the outage started.
|
||||
- During recovery: queue drains to 0 within 5 minutes.
|
||||
- No DB connection pool exhaustion (no `connection refused` from Postgres).
|
||||
- No SUT crashes.
|
||||
|
||||
---
|
||||
|
||||
### NFT-RES-LIM-04: Disk usage by `images_dir` over many distinct uploads
|
||||
|
||||
**Summary**: Each distinct `image_bytes` POST consumes O(image-size) disk; identical re-uploads consume zero additional disk (idempotent).
|
||||
**Traces to**: AC-F-01, AC-F-02
|
||||
|
||||
**Steps**:
|
||||
|
||||
| Step | Action | Expected Behavior |
|
||||
|------|--------|------------------|
|
||||
| 1 | Capture `du -sb $images_dir` baseline | non-empty path |
|
||||
| 2 | `POST /annotations` 100× with `image_small.jpg` (same bytes) | 1 file added, ~1.5 MB delta from step 1 |
|
||||
| 3 | `POST /annotations` 100× with random distinct image bytes (synthetic) | 100 new files; delta ≈ 100 × avg-size |
|
||||
|
||||
**Pass criteria**: identical uploads do not duplicate disk; distinct uploads scale linearly.
|
||||
**Duration**: ~5 minutes.
|
||||
|
||||
---
|
||||
|
||||
### NFT-RES-LIM-05: Concurrent SSE subscribers — process-memory boundary
|
||||
|
||||
**Summary**: 100 simultaneous SSE subscribers do not exhaust the SUT's memory or thread pool.
|
||||
**Traces to**: AC-N-05 (idle-channel memory bounded), OP-01 (per-instance SSE state)
|
||||
|
||||
**Preconditions**: SUT freshly started.
|
||||
|
||||
**Steps**:
|
||||
|
||||
| Step | Action | Expected Behavior |
|
||||
|------|--------|------------------|
|
||||
| 1 | Open 100 SSE connections to `/annotations/events?missionId=<m>` | all 100 alive |
|
||||
| 2 | Sample `docker stats annotations` immediately after connection | RSS recorded |
|
||||
| 3 | Idle for 10 minutes; sample every 60s | RSS stays within ± 10% of step 2 |
|
||||
| 4 | `POST /annotations` once for mission `<m>` | all 100 subscribers receive the event within 1500ms |
|
||||
|
||||
**Pass criteria**: RSS bounded; all subscribers receive the event; no `connection refused` or thread-pool starvation.
|
||||
**Duration**: ~12 minutes.
|
||||
|
||||
---
|
||||
|
||||
### NFT-RES-LIM-06: Migration on cold-start cost
|
||||
|
||||
**Summary**: Boot-time `DatabaseMigrator.MigrateAsync()` adds bounded latency to cold start (`/health` returns 200 within `<budget>` after container start).
|
||||
**Traces to**: AC-N-01
|
||||
|
||||
**Steps**:
|
||||
|
||||
| Step | Action | Expected Behavior |
|
||||
|------|--------|------------------|
|
||||
| 1 | `docker compose down annotations && docker compose up -d annotations` | container starting |
|
||||
| 2 | Poll `/health` every 200ms; record time-to-first-200 | record time |
|
||||
| 3 | Repeat with a fresh DB (cold migrator) and a populated DB (warm migrator) | both runs measured |
|
||||
|
||||
**Pass criteria** (until contracted): time-to-first-200 ≤ 30s on cold migrator; ≤ 10s on warm migrator. **Step 15 will tune.**
|
||||
**Duration**: ~2 minutes.
|
||||
@@ -0,0 +1,179 @@
|
||||
# Security Tests
|
||||
|
||||
> Blackbox-level only. Code-level vulnerabilities (e.g., SQL injection at the source level) are out of scope here — they belong to Step 14 (Security Audit). The SEC-XX gap list in `security_approach.md` is the broader inventory; the tests below are the ones that can be exercised through the public surface.
|
||||
>
|
||||
> **Auth model assumed by these tests**: annotations is a verifier-only service. Tokens are minted by the e2e harness's mock issuer (see `test-data.md` → "Bearer token harness") using an ES256 key pair whose public half is published at the JWKS URL the service fetches at boot. There is no `/auth/login`, `/auth/refresh`, or `/auth/register` endpoint on this service.
|
||||
|
||||
### NFT-SEC-01: JWT signature mismatch
|
||||
|
||||
**Summary**: A token signed with a key not published in the SUT's JWKS is rejected.
|
||||
**Traces to**: AC-F-50, AC-F-52
|
||||
|
||||
**Steps**:
|
||||
|
||||
| Step | Consumer Action | Expected Response |
|
||||
|------|----------------|------------------|
|
||||
| 1 | Mint an ES256 token with valid `iss` / `aud` / `exp` and an `ANN` claim, but signed with a private key whose public half is **not** in the JWKS | token is well-formed |
|
||||
| 2 | `POST /annotations` with that bearer token | HTTP 401; error envelope; `error.code` matches `/auth|unauthor/i` |
|
||||
|
||||
**Pass criteria**: 401, no 500, no leaking which key the SUT expected.
|
||||
**Duration**: 3s.
|
||||
|
||||
---
|
||||
|
||||
### NFT-SEC-02: JWT expired
|
||||
|
||||
**Summary**: An expired ES256 JWT is rejected.
|
||||
**Traces to**: AC-F-50
|
||||
|
||||
**Steps**:
|
||||
|
||||
| Step | Consumer Action | Expected Response |
|
||||
|------|----------------|------------------|
|
||||
| 1 | Mint a JWT with `exp` 1 hour in the past, signed with a key in the JWKS, otherwise valid (`iss`, `aud`, `ANN` claim) | token is well-formed |
|
||||
| 2 | `POST /annotations` with that bearer token | HTTP 401; error envelope |
|
||||
|
||||
**Pass criteria**: 401; SUT does not honor the expired token.
|
||||
**Duration**: 3s.
|
||||
|
||||
---
|
||||
|
||||
### NFT-SEC-03: Cross-policy attempt — DATASET token cannot create annotations
|
||||
|
||||
**Summary**: Policy `DATASET` cannot reach `/annotations` POST.
|
||||
**Traces to**: AC-F-52
|
||||
|
||||
**Steps**:
|
||||
|
||||
| Step | Consumer Action | Expected Response |
|
||||
|------|----------------|------------------|
|
||||
| 1 | Mint an ES256 token with the `DATASET` claim and `ANN` claim absent | token is well-formed |
|
||||
| 2 | `POST /annotations` with that bearer | HTTP 403; error envelope |
|
||||
|
||||
**Pass criteria**: 403, request rejected before any DB / FS write.
|
||||
**Duration**: 3s.
|
||||
|
||||
---
|
||||
|
||||
### NFT-SEC-04: Cross-policy attempt — ANN token cannot mutate settings
|
||||
|
||||
**Summary**: Policy `ANN` cannot reach an `[Authorize(Policy = "ADM")]` route.
|
||||
**Traces to**: AC-F-52
|
||||
|
||||
**Steps**:
|
||||
|
||||
| Step | Consumer Action | Expected Response |
|
||||
|------|----------------|------------------|
|
||||
| 1 | `PUT /settings/system` with an ES256 token carrying only the `ANN` claim | HTTP 403; error envelope |
|
||||
|
||||
**Pass criteria**: 403.
|
||||
**Duration**: 3s.
|
||||
|
||||
---
|
||||
|
||||
### NFT-SEC-05: Anonymous access to non-public endpoints
|
||||
|
||||
**Summary**: Every endpoint other than `/health` requires authentication.
|
||||
**Traces to**: AC-F-50, AC-F-52, security_approach.md surface inventory
|
||||
|
||||
**Steps**:
|
||||
|
||||
| Step | Consumer Action | Expected Response |
|
||||
|------|----------------|------------------|
|
||||
| 1 | `GET /annotations` with no `Authorization` | HTTP 401 |
|
||||
| 2 | `GET /dataset` with no `Authorization` | HTTP 401 |
|
||||
| 3 | `GET /classes` with no `Authorization` | HTTP 401 |
|
||||
| 4 | `GET /settings/system` with no `Authorization` | HTTP 401 |
|
||||
| 5 | `GET /health` with no `Authorization` | HTTP 200 |
|
||||
|
||||
**Pass criteria**: every authenticated endpoint returns 401; only `/health` is anonymous.
|
||||
**Duration**: 5s.
|
||||
|
||||
---
|
||||
|
||||
### NFT-SEC-06: Error envelope leaks no stack trace in production-mode env
|
||||
|
||||
**Summary**: Triggering a 500 path returns an error envelope with no `stackTrace` / `innerException` fields.
|
||||
**Traces to**: AC-N-04
|
||||
|
||||
**Preconditions**: SUT started with `ASPNETCORE_ENVIRONMENT=Production`.
|
||||
|
||||
**Steps**:
|
||||
|
||||
| Step | Consumer Action | Expected Response |
|
||||
|------|----------------|------------------|
|
||||
| 1 | Trigger a path that produces a 500 (e.g., NFT-RES-03 step 2 in DB-paused state) OR a malformed multipart body | HTTP 5xx; body is the error envelope |
|
||||
| 2 | Inspect body | no key matches `/stack/i`, `/inner/i`, `/trace/i` (case-insensitive) |
|
||||
|
||||
**Pass criteria**: error envelope present; no stack-trace leakage.
|
||||
**Duration**: 30s (depends on fault induction).
|
||||
|
||||
---
|
||||
|
||||
### NFT-SEC-07: Path traversal in image / thumbnail GET routes
|
||||
|
||||
**Summary**: Path traversal sequences in the `id` segment do not escape `images_dir`.
|
||||
**Traces to**: implicit; SEC-05 broader scope
|
||||
|
||||
**Steps**:
|
||||
|
||||
| Step | Consumer Action | Expected Response |
|
||||
|------|----------------|------------------|
|
||||
| 1 | `GET /annotations/%2E%2E%2Fetc%2Fpasswd/image` (encoded `../etc/passwd`) | HTTP 400 OR HTTP 404 (NOT 200, NOT containing `/etc/passwd` content) |
|
||||
| 2 | `GET /annotations/..%2F..%2Fetc%2Fpasswd/thumbnail` | same |
|
||||
|
||||
**Pass criteria**: SUT rejects or returns 404; no host file content in the response body.
|
||||
**Duration**: 5s.
|
||||
|
||||
---
|
||||
|
||||
### NFT-SEC-08: Token claim modification (signature breaks)
|
||||
|
||||
**Summary**: An attacker who edits a JWT payload to elevate to ADM but cannot resign sees 401, not 200.
|
||||
**Traces to**: AC-F-52
|
||||
|
||||
**Steps**:
|
||||
|
||||
| Step | Consumer Action | Expected Response |
|
||||
|------|----------------|------------------|
|
||||
| 1 | Mint an ES256 token with the `ANN` claim | original token |
|
||||
| 2 | Decode payload; replace policy claim with `ADM`; re-encode payload but **keep the original signature** | tampered token |
|
||||
| 3 | `PUT /settings/system` with the tampered token | HTTP 401; error envelope |
|
||||
|
||||
**Pass criteria**: 401 — signature validation catches the tamper.
|
||||
**Duration**: 5s.
|
||||
|
||||
---
|
||||
|
||||
### NFT-SEC-09: CORS preflight respects configured allow-list
|
||||
|
||||
**Summary**: With the SUT booted under `ASPNETCORE_ENVIRONMENT=Production` and `CorsConfig:AllowedOrigins=["https://app.azaion.local"]`, a preflight from an arbitrary origin is not given a wildcard ACAO header. `CorsConfigurationValidator` already prevents the wide-open default in Production.
|
||||
**Traces to**: AC-N-CORS (see `restrictions.md` ENV-06)
|
||||
|
||||
**Steps**:
|
||||
|
||||
| Step | Consumer Action | Expected Response |
|
||||
|------|----------------|------------------|
|
||||
| 1 | `OPTIONS /annotations` with `Origin: https://attacker.example`, `Access-Control-Request-Method: POST` | HTTP 204 with **no** `Access-Control-Allow-Origin: *`; either no ACAO at all, or an ACAO matching the configured allow-list (which the attacker origin is not in) |
|
||||
| 2 | `OPTIONS /annotations` with `Origin: https://app.azaion.local` | HTTP 204; `Access-Control-Allow-Origin: https://app.azaion.local` |
|
||||
|
||||
**Pass criteria**: only configured origins receive a permissive ACAO; arbitrary origins do not.
|
||||
**Duration**: 3s.
|
||||
|
||||
---
|
||||
|
||||
### NFT-SEC-10: Algorithm confusion — `alg=HS256` over the public ES256 key
|
||||
|
||||
**Summary**: Annotations pins `ValidAlgorithms = [EcdsaSha256]` to block the classic JWKS-confusion attack where an attacker forges an HS256 token using the published ES256 public key as the HMAC secret.
|
||||
**Traces to**: AC-F-50
|
||||
|
||||
**Steps**:
|
||||
|
||||
| Step | Consumer Action | Expected Response |
|
||||
|------|----------------|------------------|
|
||||
| 1 | Fetch the SUT's published JWKS; export the ES256 public key bytes | bytes obtained |
|
||||
| 2 | Mint a JWT with `alg=HS256` and the public key bytes as the HMAC key, with otherwise-valid `iss` / `aud` / `exp` / `ANN` claim | forged token |
|
||||
| 3 | `GET /annotations` with that bearer token | HTTP 401; error envelope |
|
||||
|
||||
**Pass criteria**: 401 — algorithm pinning rejects the forged token.
|
||||
**Duration**: 5s.
|
||||
@@ -0,0 +1,102 @@
|
||||
# Test Data Management
|
||||
|
||||
## Seed Data Sets
|
||||
|
||||
| Data Set | Description | Used by Tests | How Loaded | Cleanup |
|
||||
|----------|-------------|---------------|-----------|---------|
|
||||
| `tokens-test` | 3 ES256 access tokens minted on demand by the runner: `ann-token` (claim `ANN`), `dataset-token` (`DATASET`), `adm-token` (`ADM`). All carry `iss=$JWT_ISSUER`, `aud=$JWT_AUDIENCE`, `exp=now+5m`, and a deterministic `sub` GUID per role. | F1-N-003, F1-N-004, F5-004, F6-001..006, F7-004, F8-*, NFT-SEC-01..10, FT-N-10..12 | The harness runs a **mock JWKS issuer** (Python script `tests/harness/mock_issuer.py` or the equivalent .NET fixture) that publishes the public ES256 key at `JWT_JWKS_URL`. The runner imports the matching private key as a fixture and mints tokens per test. | Tokens are short-lived (5m) and never persisted; key pair regenerates on `docker compose down -v` |
|
||||
| `mission-test` | One canonical waypoint id `00000000-0000-0000-0000-000000000aaa` used as `WaypointId` / `MissionId` in every annotation create. | All F1, F2, F3, F4, F5, F8 | Implicit — no FK enforcement; the GUID is just a column value. | N/A |
|
||||
| `classes-baseline` | The 19 detection classes seeded by `DatabaseMigrator` (ids 0–18, names per `data_parameters.md`). | F7-001 (catalog read), F1-* (class_num references) | Auto, by the SUT's boot-time migrator. | N/A — schema-managed |
|
||||
| `clean-state` | Empty `annotations`, `media`, `detection`, `annotations_queue_records` tables at the start of each test class. | every test class that asserts on count / depth | xUnit class fixture: `TRUNCATE annotations, media, detection, annotations_queue_records RESTART IDENTITY CASCADE;` via direct DB connection (out-of-band, runner-only). | Fixture's `Dispose()` truncates again |
|
||||
|
||||
## Data Isolation Strategy
|
||||
|
||||
- **Per-class truncation** — each xUnit test class declares an `IClassFixture<CleanStateFixture>` that truncates the four mutable tables before the first test in the class and again after the last.
|
||||
- **Per-test token** — every test mints its own ES256 token via the mock issuer fixture (see "Bearer token harness" below); tokens never cross test boundaries.
|
||||
- **Per-test mission id** — tests that need fan-out isolation (e.g., F3 SSE subscribers) generate a fresh `WaypointId` GUID per test so concurrent test runs don't leak events into each other.
|
||||
- **Per-test stream consumer** — F4 stream-consumer scenarios use a fresh consumer name per test and start at offset `next` (current end of stream). They consume only messages produced after the test starts.
|
||||
- **Filesystem isolation** — `annotations-images`, `annotations-videos`, `annotations-deleted` volumes are recreated by `docker compose down -v` between full runs. Per-test cleanup removes only files the test wrote (matching `<id>` patterns).
|
||||
|
||||
## Input Data Mapping
|
||||
|
||||
| Input Data File | Source Location | Description | Covers Scenarios |
|
||||
|-----------------|----------------|-------------|-----------------|
|
||||
| `image_small.jpg` | `<fixtures>/image_small.jpg` | 1280×720 frame, ~1.5 MB | F1-001, F1-002, F1-N-003..005, F2-001/002, F3-001/002, F4-001/002, F5-001/002, F8-* |
|
||||
| `image_dense01.jpg` | `<fixtures>/image_dense01.jpg` | small dense frame (~230 KB) | F1-004, F5-002, F8-002 |
|
||||
| `image_dense02.jpg` | `<fixtures>/image_dense02.jpg` | larger dense frame (~2.8 MB) | F5-002 |
|
||||
| `image_different_types.jpg` | `<fixtures>/image_different_types.jpg` | multi-class scene (900×1600) | F8-002 (class filter) |
|
||||
| `image_empty_scene.jpg` | `<fixtures>/image_empty_scene.jpg` | 1920×1080 empty scene | F1-003 (zero detections), NFT-PERF-* warmup |
|
||||
| `image_large.JPG` | `<fixtures>/image_large.JPG` | 6252×4168, ~7 MB | F1-005 (large payload), NFT-PERF-LATENCY |
|
||||
| `video_short01.mp4` | `<fixtures>/video_short01.mp4` | ~150 MB video | F1-006 (video annotation), F1-007 |
|
||||
| `video_short02.mp4` | `<fixtures>/video_short02.mp4` | distinct-bytes second video | F1-007 (distinct bytes → distinct ids) |
|
||||
|
||||
`<fixtures>` resolves to `/fixtures` inside the test runner / SUT container, bound to `../detections/_docs/00_problem/input_data/` per `_docs/00_problem/input_data/fixtures.md`.
|
||||
|
||||
## Synthetic request payloads
|
||||
|
||||
JSON request bodies for `POST /annotations`, `PUT /annotations/{id}`, `POST /dataset/status/bulk`, and the auth flows live under `_docs/00_problem/input_data/requests/`. Each test references a request file by id (`F1_001_request.json`). Class numbers in detections come from the seeded `detection_classes` (ids 0–18); coordinates are normalized 0..1 floats.
|
||||
|
||||
## Expected Results Mapping
|
||||
|
||||
(Full table is `_docs/00_problem/input_data/expected_results/results_report.md` — 44 rows. Selected entries here for cross-reference.)
|
||||
|
||||
| Test Scenario ID | Input Data | Expected Result | Comparison Method | Tolerance | Source |
|
||||
|-----------------|------------|-----------------|-------------------|-----------|--------|
|
||||
| FT-P-01 (=F1-001) | `image_small.jpg` + `F1_001_request.json` | HTTP 200 + `AnnotationDto`; `id =~ /^[0-9a-f]{32}$/`; `detections.length == 1` | exact, schema_match, regex | N/A | `expected_results/F1_001_response.json` |
|
||||
| FT-P-02 (=F1-002) | Same input, second POST | Same `id` as FT-P-01; no duplicate row | exact | N/A | inline |
|
||||
| FT-P-04 (=F1-004) | `image_dense01.jpg` + `F1_004_request.json` | HTTP 200; `detections.length == 5`; YOLO label file with 5 lines | exact, file_content | N/A | `expected_results/F1_004_response.json` |
|
||||
| FT-P-10 (=F3-001) | F1-001 fires, SSE subscriber connected | event with `operation == "Created"`, `latency ≤ 1000ms` | exact, threshold_max | ± 200ms | inline |
|
||||
| FT-N-04 (=F1-N-004) | F1-001 with no `Authorization` header | HTTP 401 + error envelope | exact, schema_match | N/A | inline |
|
||||
| NFT-PERF-LATENCY-01 | `image_small.jpg` × 50 sequential calls | p95 latency ≤ 1500ms | threshold_max | N/A | inline |
|
||||
| NFT-RES-01 | RabbitMQ stopped, F1-001 fires | HTTP 200 returned to caller; outbox row stays; SUT stays alive | exact | N/A | inline |
|
||||
| NFT-SEC-01 | F1-001 with JWT signed by **wrong** key | HTTP 401 | exact | N/A | inline |
|
||||
| NFT-RES-LIM-01 | F4 outbox under sustained load | queue depth ≤ 10× steady-state for ≥ 30 min | threshold_max | N/A | inline |
|
||||
|
||||
## External Dependency Mocks
|
||||
|
||||
| External Service | Mock/Stub | How Provided | Behavior |
|
||||
|-----------------|-----------|-------------|----------|
|
||||
| RabbitMQ Stream broker | Real `rabbitmq:3.13-management` with the streams plugin | Docker service in `e2e-net` | Real broker; resilience tests (NFT-RES-01..03) restart it mid-test using `docker exec rabbitmq rabbitmqctl stop_app && start_app` |
|
||||
| Postgres | Real `postgres:13` | Docker service | Real DB; resilience tests (NFT-RES-04) crash and restart it |
|
||||
| Detections service | Not run | N/A | The annotations service does not call the detections service; tests bypass it by hand-authoring synthetic `detections[]` payloads in `requests/`. |
|
||||
| Suite-level reverse proxy / TLS terminator | Not run | N/A | Tests speak directly to `http://annotations:8080`. SEC-tests for HTTPS / HSTS therefore explicitly skip with reason "out-of-process for SUT". |
|
||||
|
||||
## Data Validation Rules
|
||||
|
||||
| Data Type | Validation | Invalid Examples | Expected System Behavior |
|
||||
|-----------|-----------|-----------------|------------------------|
|
||||
| `image_bytes` (POST /annotations) | non-null, non-empty byte array | empty array `[]`, missing field | HTTP 400/422; error envelope |
|
||||
| `mediaType` (POST /annotations) | enum `Image=10` or `Video=20` | `5`, `100`, missing | HTTP 400/422; error envelope |
|
||||
| `detections[].class_num` | int, no range validator today | `-1`, `999` | HTTP 200 today (lenient); flagged as gap (SEC-05) |
|
||||
| `detections[].centerX/Y/width/height` | float, no range validator today | `1.5`, `-0.1`, `NaN` | HTTP 200 today (lenient); flagged as gap (SEC-05) |
|
||||
| `Authorization` header | bearer ES256 JWT issued by the mock issuer; validated for issuer / audience / signature / expiry, with `alg` pinned to ES256 | missing, wrong issuer, wrong audience, wrong signature, expired, `alg=HS256` forgery | HTTP 401; error envelope |
|
||||
| Caller policy | `ANN`, `DATASET`, or `ADM` per endpoint | mismatched policy | HTTP 403; error envelope |
|
||||
| `WaypointId` (POST /annotations, /media) | GUID format | not a GUID | HTTP 400/422 from model binder |
|
||||
| File-upload size (POST /media) | no explicit limit visible at controller; underlying ASP.NET form-options apply | >256 MB single file | likely HTTP 400 from form-options; verify in NFT-RES-LIM-02 |
|
||||
|
||||
## Runtime-generated test data
|
||||
|
||||
Two scenario groups consume **synthetic test data generated by the runner at execution time** rather than static files on disk. This is intentional and explicitly allowed by `templates/expected-results.md` ("Test data may be generated programmatically — note this in test-data.md"):
|
||||
|
||||
| Scenario | Generated data | How |
|
||||
|----------|----------------|-----|
|
||||
| NFT-RES-LIM-02 (single-file upload boundary) | Synthetic JPEG-prefixed binary blobs at sizes 1, 10, 50, 100, 256, 512 MB | Runner xUnit fixture writes a temp file: 4-byte JPEG magic header + pseudo-random bytes filling to the target size; uploaded once, deleted after. Files NOT committed to the repo. |
|
||||
| NFT-PERF-LIST-01, NFT-PERF-DATASET-01 | 10,000 `annotations` rows + 50,000 `detection` rows in the test DB | `dataseed` job runs a parameterised SQL script that bulk-inserts rows with `media_id` referencing 100 distinct seeded media rows; uses `CROSS JOIN generate_series` for speed. Cleared by `clean-state` truncation between test classes. |
|
||||
|
||||
The generated data still satisfies Phase 3 quantifiability: every generated input has a deterministic shape (size, count) AND a quantifiable expected result (HTTP code, latency threshold, returned row count).
|
||||
|
||||
## Bearer token harness
|
||||
|
||||
Annotations is verifier-only — there is no `/auth/login` to call from a test. The harness reproduces the production model in miniature:
|
||||
|
||||
1. **Key pair** — a fresh ES256 key pair is generated when the test stack starts (`docker compose up`). The private key is mounted into the runner container; the public key is mounted into a tiny **mock issuer** sidecar that serves `/.well-known/jwks.json` over HTTP **inside the docker-compose network**.
|
||||
2. **JWKS URL configuration** — the SUT is started with `JWT_ISSUER=https://e2e-issuer.test`, `JWT_AUDIENCE=annotations-e2e`, and `JWT_JWKS_URL=http://e2e-issuer:8080/.well-known/jwks.json`. The HTTPS-only constraint of `HttpDocumentRetriever { RequireHttps = true }` is relaxed for tests by either (a) overriding `RequireHttps=false` via test-only configuration, or (b) running a TLS-terminating proxy in front of the issuer. Option (a) is preferred for simplicity; the relaxation is gated on `ASPNETCORE_ENVIRONMENT=E2ETest` and never applied in production builds. (This is the testability item flagged in `architecture.md` Open Risks §6.)
|
||||
3. **Token minting** — the runner exposes a per-test helper `mintToken(claim: "ANN" | "DATASET" | "ADM", overrides?)` that builds an ES256 JWT from the in-process private key with the configured `iss`/`aud`, `exp = now + 5m`, a per-role deterministic `sub` GUID, and the requested policy claim. `overrides` lets a test produce expired / wrong-iss / wrong-aud / forged-`alg=HS256` variants for the security suite.
|
||||
4. **No persisted users** — there is no `users` table in this service. Each test mints exactly the token it needs.
|
||||
|
||||
## Notes for the runner
|
||||
|
||||
- **Boot order**: `postgres` → `rabbitmq` → `e2e-issuer` (mock JWKS) → `annotations` (waits for postgres, rabbitmq, and a successful JWKS fetch) → `dataseed` → `e2e-runner`.
|
||||
- **Fresh-state vs. carry-over**: the suite truncates per class, so test ordering inside a class matters; ordering across classes does not.
|
||||
- **Stream consumption**: every test that reads from `azaion-annotations` records the offset before the test acts, then consumes from `start_offset = recorded_offset + 1` to ignore historical messages.
|
||||
- **Conditional probes**: tests that depend on SUT behavior decisions (e.g., specific 4xx code on a corner case) include a fixture step that probes the SUT once at class-init, records the actual behavior, then asserts that branch consistently within the test class. Mismatch on a subsequent run flags as a behavior-drift test failure.
|
||||
@@ -0,0 +1,98 @@
|
||||
# Traceability Matrix
|
||||
|
||||
## Acceptance Criteria Coverage
|
||||
|
||||
### Functional ACs
|
||||
|
||||
| AC ID | Acceptance Criterion (short) | Test IDs | Coverage |
|
||||
|-------|-----------------------------|----------|----------|
|
||||
| AC-F-01 | Same image bytes → same id | FT-P-01, FT-P-02 | Covered |
|
||||
| AC-F-02 | Re-POST is no-op | FT-P-02 | Covered |
|
||||
| AC-F-03 | YOLO label file format | FT-P-03, FT-P-04 | Covered |
|
||||
| AC-F-04 | POST /annotations returns persisted DTO | FT-P-01, FT-N-01, FT-N-02, FT-N-06, FT-N-07 | Covered |
|
||||
| AC-F-05 | `[after RB-01]` Every mutation emits SSE + outbox | FT-P-21, FT-P-22, NFT-RES-01 | Deferred — gated on RB-01 |
|
||||
| AC-F-06 | `[after RB-01]` DELETE is soft + relocates files | FT-P-22 | Deferred — gated on RB-01 |
|
||||
| AC-F-07 | `[after RB-01+RB-08]` soft-deleted hidden from reads | (test added in cycle-update once RB-01+RB-08 land) | Deferred |
|
||||
| AC-F-08 | `[after RB-02]` no `silent_detection` artifacts | (covered by RB-02 implementation tests) | Deferred — gated on RB-02 |
|
||||
| AC-F-10 | SSE delivery < 1s | FT-P-07, NFT-PERF-SSE-FANOUT-01 | Covered |
|
||||
| AC-F-11 | No SSE backfill | FT-P-07 (step 2), inline assertion | Partial — add explicit test in cycle-update |
|
||||
| AC-F-12 | Outbox drain → stream | FT-P-08, FT-P-09, NFT-RES-01 | Covered |
|
||||
| AC-F-13 | `[after RB-09]` `(annotation_id, operation, date_time)` on the wire | (added in cycle-update once RB-09 lands) | Deferred — gated on RB-09 |
|
||||
| AC-F-20 | POST /media single | FT-P-10, FT-N-14, FT-N-15 | Covered |
|
||||
| AC-F-21 | POST /media/batch | FT-P-11 | Covered |
|
||||
| AC-F-30 | GET /dataset filter | FT-P-16, FT-P-17 | Covered |
|
||||
| AC-F-31 | POST /dataset/status/bulk | FT-P-18, FT-N-16 | Covered |
|
||||
| AC-F-40 | PUT /settings/directories triggers Reset() | FT-P-15, FT-N-13 | Covered |
|
||||
| AC-F-41 | GET /classes returns 19 rows | FT-P-14 | Covered |
|
||||
| AC-F-42 | `[after RB-06]` admin CRUD on /classes | (added once RB-06 lands) | Deferred — gated on RB-06 |
|
||||
| AC-F-50 | Bearer token verification (iss/aud/exp/sig/alg) | FT-P-12, FT-P-13, FT-N-10, FT-N-11, NFT-SEC-01, NFT-SEC-02, NFT-SEC-10 | Covered |
|
||||
| AC-F-51 | Annotations does not host token-issuance/refresh | (asserted by NFT-SEC-05 — only `/health` is anonymous) | Covered (negative) |
|
||||
| AC-F-52 | Policy boundaries | FT-N-03, FT-N-04, FT-N-08, FT-N-12, FT-N-13, FT-N-15, NFT-SEC-03, NFT-SEC-04, NFT-SEC-05, NFT-SEC-08 | Covered |
|
||||
| AC-F-53 | Error envelope shape | covered as global invariant; FT-N-* assert envelope | Covered |
|
||||
| AC-F-54 | GET /health returns 200 | FT-P-19, NFT-PERF-* warmup | Covered |
|
||||
|
||||
### Non-Functional ACs
|
||||
|
||||
| AC ID | Acceptance Criterion (short) | Test IDs | Coverage |
|
||||
|-------|-----------------------------|----------|----------|
|
||||
| AC-N-01 | Container boot to /health 200 within healthcheck budget | FT-P-19, NFT-RES-LIM-06 | Covered (threshold inferred — Step 15 contracts it) |
|
||||
| AC-N-02 | Migrator is idempotent | FT-P-20 | Covered |
|
||||
| AC-N-03 | Outbox queue depth bounded | NFT-PERF-OUTBOX-DRAIN-01, NFT-RES-LIM-01, NFT-RES-LIM-03 | Covered |
|
||||
| AC-N-04 | Zero unhandled exceptions to clients | NFT-RES-03, NFT-SEC-06 | Covered |
|
||||
| AC-N-05 | SSE longevity ≥ 30 min | NFT-RES-LIM-05 | Covered (10-min run is a smoke proxy; 30-min is the nightly variant) |
|
||||
|
||||
## Restrictions Coverage
|
||||
|
||||
| Restriction ID | Restriction (short) | Test IDs | Coverage |
|
||||
|----------------|---------------------|----------|----------|
|
||||
| HW-01 | ARM64 only | covered by build pipeline (the test image IS ARM64) | Covered (environment-level) |
|
||||
| HW-02 | Writable `images_dir` / `videos_dir` / `deleted_dir` | FT-P-01, FT-P-15, FT-P-22 | Covered |
|
||||
| HW-03 | Memory pressure on `FailsafeProducer` image re-read | NFT-RES-LIM-01, NFT-RES-LIM-04 | Covered |
|
||||
| SW-01 | .NET 10 | environment-level (Dockerfile) | Covered (deployment) |
|
||||
| SW-02 | Postgres 13+ semantics | FT-P-20 (idempotent migrator exercises `CREATE TYPE` etc.) | Covered |
|
||||
| SW-03 | RabbitMQ streams plugin | FT-P-09, NFT-RES-01, NFT-RES-06 | Covered |
|
||||
| SW-04 | Linq2DB + MessagePack + gzip wire | FT-P-09 (decodes the wire format) | Covered |
|
||||
| SW-05 | JWT verifier-only (ES256 over admin's JWKS, alg pinned) | NFT-SEC-01, NFT-SEC-02, NFT-SEC-08, NFT-SEC-10, FT-N-10, FT-N-11 | Covered |
|
||||
| ENV-01 | Env vars required | environment.md docker-compose | Covered (environment-level) |
|
||||
| ENV-02 | Service on port 8080 HTTP, no in-image TLS | environment.md | Covered (environment-level) |
|
||||
| ENV-03 | `AZAION_REVISION` boot stamp | not exposed via API today; covered by inspecting `docker logs` (test runner asserts log line `AZAION_REVISION=test-...` appears within 5s of boot) | Partial — add log-assertion test in cycle-update |
|
||||
| ENV-04 | Branch-driven `${BRANCH}-arm` tags | CI-pipeline concern; not a runtime test | Not covered (CI-level) |
|
||||
| ENV-05 | Swagger UI mounted always | NFT-SEC (verifier in Step 14 catches this); not a hard test today | Not covered — Step 14 |
|
||||
| ENV-06 | Config-driven CORS gated by `CorsConfigurationValidator` | NFT-SEC-09 | Covered (asserts allow-list-only ACAO in `Production`) |
|
||||
| ENV-07 | DDL applied at boot | FT-P-20 | Covered |
|
||||
| OP-01 | Per-instance SSE state | NFT-RES-LIM-05, NFT-RES-04 | Covered |
|
||||
| OP-02 | No outbox row leasing | NFT-RES-01 (single-instance baseline); multi-instance double-publish is **not tested today** because the test stack runs a single SUT — flagged | Not covered (multi-instance) |
|
||||
| OP-03 | No automated test suite | this matrix IS the contract; the implementation lands in Step 6 | N/A (meta) |
|
||||
| OP-04 | No lint / formatter step in CI | CI concern | Not covered (CI-level) |
|
||||
| OP-05 | `HEALTHCHECK` calls `/health` | FT-P-19, environment.md (Dockerfile has `HEALTHCHECK`) | Covered |
|
||||
| OP-06 | `annotations_queue_records` is a private outbox | enforced by code ownership; test asserts no public endpoint allows writing to it (negative coverage via NFT-SEC-05) | Covered (negative) |
|
||||
| OP-07 | DB connection string in `jdbc:postgresql://…` form | Boot succeeds with this format → FT-P-19 implicitly checks it | Covered (implicit) |
|
||||
|
||||
## Coverage Summary
|
||||
|
||||
| Category | Total Items | Covered | Deferred (RB) | Not Covered | Coverage % (excl. deferred) |
|
||||
|----------|-----------|---------|--------------|-------------|----------------------------|
|
||||
| Functional ACs | 24 | 18 | 6 | 0 | 18 / 18 = 100% (active scope) |
|
||||
| Non-Functional ACs | 5 | 5 | 0 | 0 | 100% |
|
||||
| HW restrictions | 3 | 3 | 0 | 0 | 100% |
|
||||
| SW restrictions | 5 | 5 | 0 | 0 | 100% |
|
||||
| ENV restrictions | 7 | 4 | 0 | 3 (ENV-04, ENV-05; OP-04 noted) | 57% — gaps are CI-level / Step-14 |
|
||||
| OP restrictions | 7 | 5 | 0 | 2 (OP-02 multi-instance, OP-04 CI lint) | 71% |
|
||||
| **Total (active scope)** | **51** | **40** | **6** | **5** | 88% covered, 12% NOT_COVERED with reasons |
|
||||
|
||||
## Uncovered Items Analysis
|
||||
|
||||
| Item | Reason Not Covered | Risk | Mitigation |
|
||||
|------|-------------------|------|-----------|
|
||||
| AC-F-05, AC-F-06, AC-F-07, AC-F-08, AC-F-13, AC-F-42 | Gated on Refactor Backlog items (RB-01, RB-02, RB-06, RB-08, RB-09) | Until those refactors land, the lifecycle observability + soft-delete + dedupe contract are not in code | The corresponding tests are authored in advance (FT-P-21, FT-P-22, NFT-RES-01) and remain `skipped` until the RB items move; the cycle-update mode of the test-spec skill (per `.cursor/skills/test-spec/modes/cycle-update.md`) flips them to `enabled` when Phase B implements the RB items |
|
||||
| ENV-06 (post-refactor) | CORS test now exercises the validator-enforced allow-list rather than the legacy wide-open default | None — the test asserts current behavior | NFT-SEC-09 covers it; no follow-up needed |
|
||||
| ENV-04 | Branch-driven CI tag scheme is a CI concern, not a runtime contract | Wrong tag could deploy the wrong revision | Covered by Woodpecker pipeline tests (separate harness) — not a Step 6 deliverable |
|
||||
| ENV-05 | Swagger UI exposure is a Step 14 (Security Audit) item | Information disclosure | Step 14 produces a SEC-XX item; test added once the gating decision is made |
|
||||
| OP-02 | Multi-instance double-publish requires the test harness to spin up ≥ 2 SUT instances; current harness is single-instance | Two-pod deploy could double-publish | Documented as a pre-deployment constraint; full multi-instance testing waits for either RB-09 dedupe contract OR a horizontal-scale design decision |
|
||||
| OP-04 | "No lint / formatter in CI" is a meta-restriction (about CI), not a runtime contract | Style drift, dead code accumulating | Step 14 / Step 17 retrospective will set this up; not a runtime test |
|
||||
|
||||
## Notes
|
||||
|
||||
- The `[after RB-XX]` rows in `results_report.md` correspond directly to the **Deferred** column above. The implementation skill (Step 6) is instructed to author these tests with `[Skip(Reason = "awaiting RB-01")]` etc., so they show in the test discovery surface and flip to active automatically when the gating refactor lands.
|
||||
- The `Not covered` rows under ENV / OP are intentional — they are CI-pipeline or environment-level concerns that do NOT belong in the Step 6 blackbox suite. They are listed here so reviewers see the full restriction inventory.
|
||||
- Per the test-spec Phase 3 hard gate threshold (≥ 75% coverage), the active-scope coverage of **88%** clears the bar with a wide margin.
|
||||
Reference in New Issue
Block a user