Files
missions/_docs/tasks/done/AZ-576_test_infrastructure.md
T
Oleksandr Bezdieniezhnykh ccd85a09df
ci/woodpecker/push/build-arm Pipeline failed
[AZ-576] Add e2e test infrastructure (xUnit + jwks-mock + reporting)
Scaffold the blackbox test project the rest of epic AZ-575 (AZ-577..AZ-586)
will build on. Two new csprojs under tests/, plus the TLS materials and
TRX->CSV reporting hand-off the existing docker-compose.test.yml already
calls for.

JWKS mock (tests/Azaion.Missions.JwksMock/):
- ASP.NET Core minimal API on .NET 10, no NuGet deps; JWS is hand-rolled
  to keep the surface tight and avoid version drift with the SUT
- KeyStore with one in-memory ECDSA P-256 keypair + retired-key grace
  window for NFT-RES-07 / NFT-SEC-11 rotation observability
- Endpoints: GET /.well-known/jwks.json, POST /sign, POST /rotate-key
- Mock-only alg_override / kid_override switches drive NFT-SEC-09/10/11
- TLS keypair committed under tls/; tests/jwks-mock-ca.crt is a copy
  mounted into both missions and e2e-consumer per docker-compose.test.yml

E2E consumer (tests/Azaion.Missions.E2E.Tests/):
- xUnit 2.9.2 + Bogus 35.6.1 + Npgsql 10.0.2 + Xunit.SkippableFact 1.4.13
- TestBase / TokenMinter scaffolding for downstream tasks
- Fixtures/ for DbReset, DbSeed, ComposeRestart, JwksRotate, JwksMockReverse
- Helpers/ for DbAssertions (side-channel), HttpAssertions, FixtureSql
- 8 Tests/<category>/Sanity.cs discovery smoke tests (AC-3)
- Tests/InfrastructureSanity.cs SkippableFacts for AC-1/2/5/6
- Tests/AaaPatternEnforcement.cs greps source files for AC-7
- Tests/Reporting/TrxToCsvPostProcessorTests.cs covers AC-4
- Reporting/TrxToCsvPostProcessor.cs handles VSTest TRX -> environment.md
  CSV; xUnit traits are not propagated by the TRX logger so the converter
  reflects them out of the test DLL via GetCustomAttributesData
- Reporting.Cli/ is a separate console csproj that links the converter
  source files (test project excludes Reporting.Cli/** from compile)
- Dockerfile + entrypoint.sh wire dotnet test -> trx -> csv inside the
  e2e-consumer container the compose file already references

Local verification: 13 pass, 3 skip (with explicit reasons), 0 fail.
End-to-end TRX->CSV manually verified against environment.md header spec.
Docker stack build is handed off to autodev Step 7 (test-run skill).

Reports under _docs/03_implementation/.
AZ-576 task spec moved to _docs/tasks/done/.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-15 06:57:40 +03:00

229 lines
17 KiB
Markdown

# Test Infrastructure
**Status**: Done (2026-05-15)
**Task**: AZ-576_test_infrastructure
**Name**: Test Infrastructure (Missions e2e)
**Description**: Scaffold the Blackbox test project — xUnit runner, JWKS mock service, Docker test environment wiring, test data fixtures, reporting. Compose file already exists at repo root and references not-yet-built build contexts; this task fills in those contexts.
**Complexity**: 5 points
**Dependencies**: None (C01 + C02 testability refactor already landed; see `_docs/04_refactoring/01-testability-refactoring/testability_changes_summary.md`)
**Component**: Blackbox Tests
**Tracker**: AZ-576
**Epic**: AZ-575
## Scope
Two artifacts that the existing `docker-compose.test.yml` references but does not yet build, plus the run script the suite expects:
1. `tests/Azaion.Missions.JwksMock/` — minimal HTTPS service holding an ECDSA P-256 keypair in memory, serving JWKS + `POST /sign` + `POST /rotate-key`. Image tag `azaion/jwks-mock:test`.
2. `tests/Azaion.Missions.E2E.Tests/` — xUnit 2.x test project that drives the running `missions` service over HTTP, mints tokens via `https://jwks-mock:8443/sign`, asserts DB side-effects through a side-channel Npgsql connection, and produces `report.csv`.
3. `tests/jwks-mock-ca.crt` — the self-signed CA cert that both `missions` and `e2e-consumer` mount and `update-ca-certificates --fresh` adds to the OS trust bundle (per `docker-entrypoint.sh` from C02).
4. `scripts/run-tests.sh` — wraps `docker compose -f docker-compose.test.yml up --build --abort-on-container-exit e2e-consumer`, collects `report.csv`, then `down -v`.
5. `scripts/run-performance-tests.sh` — same compose stack with the `[Trait("Category","Perf")]` filter and the perf seed.
The `missions` and `postgres-test` services already exist in `docker-compose.test.yml`; the `jwks-mock` and `e2e-consumer` services exist but point at build contexts that this task creates.
## Test Project Folder Layout
```
tests/
├── jwks-mock-ca.crt # self-signed CA (mounted into missions + e2e-consumer)
├── Azaion.Missions.JwksMock/
│ ├── Azaion.Missions.JwksMock.csproj
│ ├── Dockerfile # builds azaion/jwks-mock:test, exposes 8443/tcp
│ ├── Program.cs # ASP.NET Core minimal API
│ ├── Endpoints/
│ │ ├── JwksEndpoint.cs # GET /.well-known/jwks.json
│ │ ├── SignEndpoint.cs # POST /sign
│ │ └── RotateKeyEndpoint.cs # POST /rotate-key
│ ├── Services/
│ │ ├── KeyStore.cs # in-memory ECDSA P-256 keypair + old-key grace window
│ │ └── TokenSigner.cs # ECDSA signing with alg_override/kid_override support
│ └── appsettings.json # JWT_ISSUER, JWT_AUDIENCE, OLD_KEY_GRACE_SECONDS
└── Azaion.Missions.E2E.Tests/
├── Azaion.Missions.E2E.Tests.csproj # xUnit 2.x + Bogus 35.x + Npgsql 10.x
├── Dockerfile # runs `dotnet test --logger trx` + trx→csv post-step
├── TestBase.cs # HttpClient factory, default JWT, shared MissionsBaseUrl
├── TokenMinter.cs # POST jwks-mock:8443/sign with overrides
├── Fixtures/
│ ├── DbResetFixture.cs # IClassFixture<>: TRUNCATE between classes
│ ├── DbSeedFixture.cs # base for the named seed sets in test-data.md
│ ├── ComposeRestartFixture.cs # docker compose down -v && up -d for bootstrap-sensitive tests
│ └── JwksRotateFixture.cs # POST /rotate-key + wait for missions to refresh JWKS cache
├── Helpers/
│ ├── DbAssertions.cs # Npgsql side-channel asserts (row counts, default-vehicle invariants)
│ ├── HttpAssertions.cs # PascalCase shape, error-envelope shape, ordering, pagination
│ └── FixtureSql.cs # loads fixture_cascade_F3.sql / fixture_cascade_F4.sql
├── Tests/
│ ├── Vehicles/ # FT-P-01..06, FT-N-01..03
│ ├── Missions/ # FT-P-07..12, FT-N-04..06
│ ├── Waypoints/ # FT-P-13..15, FT-P-18, FT-N-07
│ ├── Health/ # FT-P-16..17, FT-N-08
│ ├── Security/ # NFT-SEC-01..13, 04b
│ ├── Resilience/ # NFT-RES-01..08
│ ├── ResourceLimits/ # NFT-RES-LIM-01..04
│ └── Performance/ # NFT-PERF-01..04
└── Reporting/
├── TrxToCsvPostProcessor.cs # produces /app/results/report.csv per environment.md § Reporting
└── ResultRow.cs # TestId, TestName, Category, Traces, ExecutionTimeMs, Result, ErrorMessage
```
### Layout Rationale
- **Per-feature test folders** (`Vehicles/`, `Missions/`, etc.) match the natural decomposition surface in `blackbox-tests.md` and let `dotnet test --filter` slice the suite per Jira child ticket.
- **`Fixtures/` separate from `Tests/`** so xUnit `IClassFixture<>` lifetime is explicit (class-scoped DB reset) and not entangled with test cases.
- **`Helpers/` named for the assertion family** (DB, HTTP, FixtureSql) so each test reads as a single `// Arrange` + `// Act` + `// Assert` block per `coderule.mdc`.
- **JwksMock is a SEPARATE csproj**, not nested inside the e2e tests, because the build context is mounted as a service in `docker-compose.test.yml` (`tests/Azaion.Missions.JwksMock/`). Sharing a project would force the e2e runner to ship JWKS code into its container.
- **CA cert lives at `tests/jwks-mock-ca.crt`** rather than inside a project so both consumers (missions + e2e-consumer) mount the same file. The cert is regenerated only when the keypair changes — committed to the repo for deterministic test runs.
## Mock Services
| Mock Service | Replaces | Endpoints | Behavior |
|-------------|----------|-----------|----------|
| `jwks-mock` | `admin` JWT issuer + JWKS endpoint | `GET https://jwks-mock:8443/.well-known/jwks.json`; `POST https://jwks-mock:8443/sign`; `POST https://jwks-mock:8443/rotate-key` | Holds one ECDSA P-256 keypair in memory; serves the public half as JWKS with `Cache-Control: public, max-age=60`; signs ECDSA-SHA256 JWTs on `/sign` honoring optional `iss`/`aud`/`exp_offset_seconds`/`permissions`/`alg_override`/`kid_override`; rotates keypair on `/rotate-key` while retaining the old public key for `OLD_KEY_GRACE_SECONDS` (5s in tests). Private key never leaves the container. |
DB-only stubs (no service running, side-channel SQL inserts only): `annotations`, `detection`, `media`, `map_objects` — see `_docs/02_document/tests/test-data.md` § External Dependency Mocks.
### Mock Control API
`jwks-mock` exposes `POST /sign` and `POST /rotate-key` as its full control surface. The `/sign` body shape is documented in `test-data.md` § "JWKS mock token-minting contract":
```http
POST https://jwks-mock:8443/sign
{
"iss": "https://admin-test.azaion.local", # optional
"aud": "azaion-edge", # optional
"exp_offset_seconds": 3600, # optional; negative for expired
"permissions": "FL", # optional; "" / "ADMIN" / "fl" / "FLight" for claim-mismatch
"alg_override": null, # "HS256" to test alg-confusion (NFT-SEC-10)
"kid_override": null # non-existent kid for unknown-key tests (NFT-SEC-11)
}
```
Response: `{ "token": "<encoded JWT>", "kid": "<key id>" }`.
## Docker Test Environment
### docker-compose.test.yml Structure
| Service | Image / Build | Purpose | Depends On |
|---------|--------------|---------|------------|
| `postgres-test` | `postgres:16-alpine` | Owned test PostgreSQL; `tmpfs:/var/lib/postgresql/data` for `down -v` isolation | — |
| `jwks-mock` | build `tests/Azaion.Missions.JwksMock/``azaion/jwks-mock:test` | Mock JWKS issuer | — |
| `missions` | build `.` (repo root `Dockerfile`) → `azaion/missions:test` | System under test | `postgres-test` (healthy), `jwks-mock` (healthy) |
| `e2e-consumer` | build `tests/Azaion.Missions.E2E.Tests/` | xUnit runner; emits `report.csv` to host-mounted `./test-results/` | `missions` (healthy), `jwks-mock` (healthy) |
The compose file is already authored at the repo root. This task does NOT modify it — the file IS the contract; the task fills in the two missing build contexts so the references resolve.
### Networks and Volumes
| Resource | Purpose |
|----------|---------|
| `e2e-net` (bridge) | Isolated test network; no host network access. All four services attach. |
| `tmpfs:/var/lib/postgresql/data` | Ephemeral PG data; recreated per `docker compose down -v`. |
| `./test-results:/app/results` | `e2e-consumer` mounts this for `report.csv` output to the host. |
| `./tests/jwks-mock-ca.crt:/usr/local/share/ca-certificates/jwks-mock-ca.crt:ro` | Mounted into `missions` AND `e2e-consumer` so both trust the mock's HTTPS cert after `update-ca-certificates --fresh` runs in `docker-entrypoint.sh`. |
## Test Runner Configuration
**Framework**: xUnit 2.x
**Plugins**: `Microsoft.NET.Test.Sdk`, `xunit.runner.visualstudio`, `Bogus 35.x` (synthetic data), `Npgsql 10.x` (side-channel only — NO `Azaion.Missions.*` project reference)
**Entry point**: `dotnet test tests/Azaion.Missions.E2E.Tests/Azaion.Missions.E2E.Tests.csproj --logger "trx;LogFileName=results.trx"` followed by `TrxToCsvPostProcessor` converting `results.trx``report.csv`
**AAA convention**: every test method has `// Arrange` / `// Act` / `// Assert` comments per `.cursor/rules/coderule.mdc`.
### Fixture Strategy
| Fixture | Scope | Purpose |
|---------|-------|---------|
| `DbResetFixture` | Class (`IClassFixture<>`) | `TRUNCATE TABLE` for all schema tables between classes; cheap reset for read-path tests (AC-1, AC-2, AC-4) |
| `DbSeedFixture<TSeed>` | Class | Applies the named seed sets from `test-data.md` (`seed_empty`, `seed_one_default_vehicle`, `seed_3_vehicles_2_default`, `seed_25_missions`, `fixture_cascade_F3`, `fixture_cascade_F4`, `seed_5_waypoints_unordered`, `seed_legacy_gps_tables`) via Npgsql side-channel |
| `ComposeRestartFixture` | Collection | `docker compose -f docker-compose.test.yml down -v && up -d` between scenarios that assert startup-time behavior (AC-6.3..6.7, AC-5.7) |
| `JwksRotateFixture` | Scenario | `POST jwks-mock:8443/rotate-key` then waits for missions to refresh its JWKS cache (≤ 30s in tests, capped by `JWT_JWKS_AUTO_REFRESH_INTERVAL_SECONDS`) |
| `JwksMockReverseFixture` | Scenario | Boots `missions` outside compose via `docker run` with `ASPNETCORE_ENVIRONMENT=Production` + empty `CorsConfig:AllowedOrigins` to test E9 lock (NFT-SEC-13) |
### xUnit traits
Every test method MUST set `[Trait("Category", "Blackbox" | "Sec" | "Res" | "ResLim" | "Perf")]`. The CSV `Category` column reads from this trait. Traceability IDs go into a second `[Trait("Traces", "AC-1.2,AC-1.4")]` trait, comma-separated.
## Test Data Fixtures
Loaded entirely from `_docs/02_document/tests/test-data.md` § Seed Data Sets. The fixtures bind the named seeds to the AC IDs that consume them:
| Data Set | Source | Format | Used By |
|----------|--------|--------|---------|
| `seed_empty` | `down -v` + `missions` startup migrator | Schema only, no rows | bootstrap, unauth, 404 scenarios |
| `seed_one_default_vehicle` | Side-channel `INSERT INTO vehicles ...` | Inline SQL string | AC-1.2 default-clear, AC-1.3 TOCTOU, AC-1.4 setDefault, AC-2.1 mission-create |
| `seed_3_vehicles_2_default` | Side-channel SQL | Inline | AC-1.5 list, AC-1.6 filter |
| `seed_25_missions` | Side-channel SQL with deterministic UUIDs | Inline | AC-2.3..2.5 pagination + date filter |
| `fixture_cascade_F3` | `_docs/00_problem/input_data/expected_results/fixture_cascade_F3.sql` | SQL file | AC-3.1, 3.3, 3.4, 10.2 |
| `fixture_cascade_F4` | `_docs/00_problem/input_data/expected_results/fixture_cascade_F4.sql` | SQL file | AC-4.5, 4.6 |
| `seed_5_waypoints_unordered` | Side-channel SQL with `order_num [3,1,2,5,4]` | Inline | AC-4.3 unpaginated ordering |
| `seed_legacy_gps_tables` | `CREATE TABLE orthophotos / gps_corrections` + `INSERT` | Inline | AC-3.5 absence, AC-6.5 one-shot drop, AC-10.5 legacy migration |
### Data Isolation
Three tiers, by scenario type (per `test-data.md` § Data Isolation Strategy):
- **Class-scoped DB reset** (`IClassFixture<DbResetFixture>`): for scenarios that share a seed within a class but must not leak across classes. Used for AC-1, AC-2, AC-4 read paths.
- **Scenario-scoped container restart** (`docker compose down -v && up -d`): for scenarios that assert startup-time behavior or migrator side-effects (AC-6.3..6.7, AC-6.11, AC-5.7).
- **No per-test transaction rollback** — the system under test is a separate process; its `DataConnection` is not in the test transaction.
## Test Reporting
**Format**: CSV
**Columns**: `TestId, TestName, Category, Traces, ExecutionTimeMs, Result, ErrorMessage`
**Output path**: `/app/results/report.csv` inside `e2e-consumer`, mounted to `./test-results/report.csv` on the host
**Source**: post-processor reads `results.trx` (xUnit logger output), joins each test's `[Trait("Category",...)]` and `[Trait("Traces",...)]` into the CSV columns. `Result` is `pass` / `fail` / `skip`. `ErrorMessage` is the first line of the failure message (CRs stripped).
## Acceptance Criteria
**AC-1: Test environment starts**
Given the `docker-compose.test.yml` at repo root
When `docker compose -f docker-compose.test.yml up --build` runs
Then `postgres-test`, `jwks-mock`, and `missions` all reach `healthy`, and `e2e-consumer` starts after them
**AC-2: Mock JWKS service responds**
Given the test environment is running
When `GET https://jwks-mock:8443/.well-known/jwks.json` is issued from inside `e2e-net`
Then the response is `200 OK` with a JWKS body containing exactly one ECDSA P-256 public key
And `POST https://jwks-mock:8443/sign` with body `{}` returns a valid ECDSA-SHA256 JWT whose `iss` / `aud` match the mock's env vars
**AC-3: Test runner executes**
Given the test environment is running
When `e2e-consumer` starts and `dotnet test` runs
Then the runner discovers ≥ 1 test in each of the eight test folders (`Vehicles/`, `Missions/`, `Waypoints/`, `Health/`, `Security/`, `Resilience/`, `ResourceLimits/`, `Performance/`)
**AC-4: Test report generated**
Given tests have been executed
When `e2e-consumer` exits
Then `./test-results/report.csv` exists on the host
And the first line is the documented column header `TestId,TestName,Category,Traces,ExecutionTimeMs,Result,ErrorMessage`
And every executed test has exactly one CSV row
**AC-5: CA trust works end-to-end**
Given `tests/jwks-mock-ca.crt` is mounted into both `missions` and `e2e-consumer`
When `docker-entrypoint.sh` runs `update-ca-certificates --fresh` and `missions` issues `GET https://jwks-mock:8443/.well-known/jwks.json` to populate its JWKS cache
Then the TLS handshake succeeds (no `RemoteCertificateNotAvailable` / `RemoteCertificateNameMismatch`)
And the cached JWKS contains the public key the consumer-issued tokens are signed with
**AC-6: JWKS rotation observable inside the 15-minute CI gate**
Given the test compose sets `JWT_JWKS_AUTO_REFRESH_INTERVAL_SECONDS=30` and `JWT_JWKS_REFRESH_INTERVAL_SECONDS=10` (per C01)
When `POST https://jwks-mock:8443/rotate-key` is called
Then within 30s `missions` refreshes its JWKS cache and accepts tokens signed with the new `kid`
And during the 5s `OLD_KEY_GRACE_SECONDS` window tokens signed with the old `kid` are still accepted
**AC-7: AAA pattern enforced**
Given the xUnit test project compiles
When `dotnet build` runs
Then every `[Fact]` / `[Theory]` method in `tests/Azaion.Missions.E2E.Tests/Tests/` contains the literal comment lines `// Arrange` (when setup exists), `// Act`, and `// Assert` in that order — verified by a Roslyn analyzer test or a single integration assertion that greps the source files
## Constraints
- `restrictions.md` SW-01: target framework .NET 10 (matches `Azaion.Missions.csproj`)
- `restrictions.md` HW-01: ARM64 + AMD64 (multi-arch base images on both projects)
- `restrictions.md` ENV-01: HTTPS-only for the JWKS endpoint (HTTP would short-circuit AC-6.12)
- `coderule.mdc`: AAA pattern with `// Arrange` / `// Act` / `// Assert` comments, no narrative comments otherwise
- No project reference from `Azaion.Missions.E2E.Tests``Azaion.Missions.csproj` (consumer must remain blackbox; assertions only via HTTP and Npgsql side-channel)
- Side-channel DB access limited to fixture seeding + post-call assertions; marked with `[Trait("db_access","seed-or-assert-only")]` where used
- Token signing happens ONLY inside `jwks-mock`; the consumer never imports a JWT signing library
- `report.csv` lives in `./test-results/` (host-mounted); this directory MUST be in `.gitignore`