chore: update configuration and Docker setup for JWT and test results
ci/woodpecker/push/build-arm Pipeline was successful

Enhanced the .gitignore to exclude test results and updated the Dockerfile to include a new entrypoint script for improved container initialization. Refactored JWT configuration to support additional parameters for automatic refresh intervals, ensuring better control over token management. Updated the ConfigurationResolver to enforce required environment variables without hardcoded fallbacks, enhancing security and flexibility.
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-15 03:23:23 +03:00
parent 7025f4d075
commit 78dea8ebab
40 changed files with 1990 additions and 510 deletions
@@ -49,9 +49,9 @@
| 1.2 | Same as 1.1 but `IsDefault:true` against a DB containing one prior `vehicles` row with `is_default=true` | Create default — must demote prior default first (AC-1.2) | `status_code: 201`; new row has `IsDefault:true`; the prior default row now has `is_default=false`; `SELECT COUNT(*) FROM vehicles WHERE is_default=true == 1` | exact (status), db_query (count, prior row state) | N/A | N/A |
| 1.3 | Same as 1.2 but inject a concurrent `INSERT vehicles (..., is_default=true)` between the service's `UPDATE … SET is_default=FALSE` and its `INSERT` | TOCTOU race window (AC-1.4) | `status_code: 201`; `SELECT COUNT(*) FROM vehicles WHERE is_default=true >= 2` is observable in at least one race interleaving | db_query (count) | N/A | N/A |
| 1.4 | `POST /vehicles/{id}/setDefault` body `{ IsDefault:true }` against `id` of a non-default row | Promote existing vehicle to default (AC-1.2) | `status_code: 200`; body `Vehicle` with `IsDefault:true`; previous default has `is_default=false`; default count == 1 | exact (status, body), db_query (count) | N/A | N/A |
| 1.5 | `GET /vehicles` no query, JWT `permissions=FL`, DB has 3 rows | List all vehicles (AC-1.5) | `status_code: 200`; body is JSON `array` (NOT `PaginatedResponse`); `body.length == 3`; PascalCase property names | exact (status, length), schema (array, not paginated), exact (case) | N/A | N/A |
| 1.6 | `GET /vehicles?name=BR&isDefault=true` against DB with `["BR-01" default, "BR-02" non-default, "MQ-9" default]` | Filter by name substring + is_default (AC-1.6) | `status_code: 200`; `body.length == 1`; `body[0].Name == "BR-01"` | exact (status, length, value) | N/A | N/A |
| 1.7 | `GET /vehicles?name=br` against DB with `"BR-01"` only | Case-sensitive name filter (AC-1.6) | `status_code: 200`; `body.length == 0` | exact (status, length) | N/A | N/A |
| 1.5 | `GET /vehicles` no query, JWT `permissions=FL`, DB has 3 rows (`BR-01`, `BR-02`, `MQ-9` inserted in any order) | List all vehicles ordered by Name ASC (AC-1.5) | `status_code: 200`; body is JSON `array` (NOT `PaginatedResponse`); `body.length == 3`; PascalCase property names; `[v.Name for v in body] == ["BR-01", "BR-02", "MQ-9"]` (alphabetical ASC) | exact (status, length, ordering), schema (array, not paginated), exact (case) | N/A | N/A |
| 1.6 | `GET /vehicles?name=BR&isDefault=true` against DB with `["BR-01" default, "BR-02" non-default, "MQ-9" default]`; also `?name=br` (lowercase) | Case-INSENSITIVE substring filter + exact `is_default` (AC-1.6) | both queries: `status_code: 200`; `body.length == 1`; `body[0].Name == "BR-01"` | exact (status, length, value) | N/A | N/A |
| 1.7 | `GET /vehicles?name=ZZ` (substring absent from all names); also `?name=zz` (lowercase) | No-match path of case-INSENSITIVE filter (AC-1.6) | both queries: `status_code: 200`; `body.length == 0` | exact (status, length) | N/A | N/A |
| 1.8 | `GET /vehicles/{id}` with `id` not in DB | Vehicle not found (AC-1.7) | `status_code: 404`; body matches `{ statusCode:404, message: <non-empty string> }` (camelCase by accidental match per AC-8.2) | exact (status), schema (envelope shape), exact (case) | N/A | N/A |
| 1.9 | `DELETE /vehicles/{id}` against vehicle referenced by ≥1 mission | Vehicle in use → 409 (AC-1.8) | `status_code: 409`; body envelope `{ statusCode:409, message:<non-empty> }`; `db.vehicles WHERE id={id}` still exists (count==1) | exact (status, envelope shape), db_query | N/A | N/A |
| 1.10 | `DELETE /vehicles/{id}` against vehicle referenced by 0 missions | Vehicle deletable | `status_code: 204`; `db.vehicles WHERE id={id}` count == 0 | exact (status), db_query | N/A | N/A |
@@ -64,7 +64,7 @@
|---|-------|-------------------|-----------------|------------|-----------|---------------|
| 2.1 | `POST /missions` body `{ Name:"Recon-01", VehicleId:<existing>, CreatedDate:null }`, JWT `FL` | Create mission with default created date (AC-2.1) | `status_code: 201`; body `Mission` with server-assigned `Id`, `CreatedDate` set to a UTC timestamp within `now ± 5s`; `Name == "Recon-01"`; `VehicleId` echoes input | exact (status, fields), numeric_tolerance (CreatedDate ± 5s) | ±5s | N/A |
| 2.2 | `POST /missions` body `{ Name:"Recon-02", VehicleId:<random uuid>, CreatedDate:null }` | Vehicle not found (AC-2.2) | `status_code: 400` (today via `ArgumentException`; spec wants 404 — divergence carry-forward) | exact (status) | N/A | N/A |
| 2.3 | `GET /missions` no query, DB has 25 missions | Default pagination (AC-2.3) | `status_code: 200`; body matches `PaginatedResponse<Mission>` schema; `body.Page == 1`; `body.PageSize == 20`; `body.TotalCount == 25`; `body.Items.length == 20` | schema, exact (counts) | N/A | N/A |
| 2.3 | `GET /missions` no query, DB has 25 missions with deterministic `CreatedDate` values | Default pagination ordered by `CreatedDate` DESC (AC-2.3) | `status_code: 200`; body matches `PaginatedResponse<Mission>` schema; `body.Page == 1`; `body.PageSize == 20`; `body.TotalCount == 25`; `body.Items.length == 20`; for every `i` in `[0..18]`: `Items[i].CreatedDate >= Items[i+1].CreatedDate` (DESC); `?name=re` (lowercase) against missions named `"Recon-*"` returns `TotalCount > 0` (case-INSENSITIVE) | schema, exact (counts, ordering, case-insensitive match) | N/A | N/A |
| 2.4 | `GET /missions?page=2&pageSize=20` against same 25-row DB | Second page | `body.Page == 2`; `body.PageSize == 20`; `body.Items.length == 5` | exact (counts) | N/A | N/A |
| 2.5 | `GET /missions?fromDate=2026-01-01T00:00:00Z&toDate=2026-01-31T23:59:59Z` against DB with 3 January missions and 2 February missions | Date range filter | `body.TotalCount == 3`; `body.Items.length == 3` | exact (counts) | N/A | N/A |
| 2.6 | `GET /missions/{id}` with `id` not in DB | Not found (AC-2.4) | `status_code: 404`; envelope `{ statusCode:404, message:<non-empty> }` | exact (status, envelope) | N/A | N/A |
@@ -100,18 +100,23 @@ Test data fixtures live in `expected_results/fixture_cascade_F3.sql` (seed scrip
### AC-5 — JWT bearer validation
JWT fixtures use `JWT_SECRET=test-secret-32-chars-min!!!!!!!!!`, HS256, claims include `permissions=FL` unless noted.
JWT fixtures are obtained via `POST https://jwks-mock:8443/sign { ... }` — the mock signs with its current ECDSA-P-256 private key, publishes the matching public key in its JWKS at `https://jwks-mock:8443/.well-known/jwks.json`. `missions` is configured with `JWT_ISSUER=https://admin-test.azaion.local`, `JWT_AUDIENCE=azaion-edge`, `JWT_JWKS_URL=https://jwks-mock:8443/.well-known/jwks.json`. Default claims include `permissions=FL` unless noted.
| # | Input | Input Description | Expected Result | Comparison | Tolerance | Reference File |
|---|-------|-------------------|-----------------|------------|-----------|---------------|
| 5.1 | `GET /vehicles` without `Authorization` header | Missing token (AC-5.4) | `status_code: 401` | exact (status) | N/A | N/A |
| 5.2 | `GET /vehicles` with `Authorization: Bearer <token signed by different secret>` | Invalid signature (AC-5.5) | `status_code: 401` | exact (status) | N/A | N/A |
| 5.3 | `GET /vehicles` with token where `exp` is `now - 120s` (outside 1-min skew) | Expired (AC-5.6) | `status_code: 401` | exact (status) | N/A | N/A |
| 5.4 | `GET /vehicles` with token where `exp` is `now - 30s` (inside 1-min skew per AC-5.2) | Within skew | `status_code: 200` | exact (status) | N/A | N/A |
| 5.5 | `GET /vehicles` with valid HS256 signature + lifetime, `permissions` claim absent | Missing claim (AC-5.8) | `status_code: 403` | exact (status) | N/A | N/A |
| 5.6 | `GET /vehicles` with valid HS256 signature + lifetime, `permissions == "ADMIN"` | Wrong claim value | `status_code: 403` | exact (status) | N/A | N/A |
| 5.7 | `GET /vehicles` with no `iss` and no `aud` claim, otherwise valid | `ValidateIssuer/ValidateAudience = false` (AC-5.3) | `status_code: 200` | exact (status) | N/A | N/A |
| 5.8 | Restart service with rotated `JWT_SECRET`, replay a previously valid token | Cross-rotation invalidation (AC-5.7) | `status_code: 401` | exact (status) | N/A | N/A |
| 5.2 | `GET /vehicles` with `Authorization: Bearer <token whose signature byte was flipped>` | Invalid signature (AC-5.5) | `status_code: 401` | exact (status) | N/A | N/A |
| 5.2b | `GET /vehicles` with token signed by an ECDSA keypair NOT present in the published JWKS | No matching public key (AC-5.5) | `status_code: 401` | exact (status) | N/A | N/A |
| 5.3 | `GET /vehicles` with token where `exp = now - 60s` (outside 30s skew) | Expired (AC-5.6) | `status_code: 401` | exact (status) | N/A | N/A |
| 5.4 | `GET /vehicles` with token where `exp = now - 15s` (inside 30s skew per AC-5.2) | Within skew | `status_code: 200` | exact (status) | N/A | N/A |
| 5.5 | `GET /vehicles` with valid signature + lifetime, `permissions` claim absent | Missing claim (AC-5.8) | `status_code: 403` | exact (status) | N/A | N/A |
| 5.6 | `GET /vehicles` with valid signature + lifetime, `permissions == "ADMIN"`; also `"fl"`, `"FLight"` | Wrong claim value (AC-9.2) | each: `status_code: 403` | exact (status) | N/A | N/A |
| 5.6b | `GET /vehicles` with valid signature + lifetime, `permissions: ["FL", "ADMIN"]` (multi-permission array) | Contains-match policy accepts (AC-9.1) | `status_code: 200` | exact (status) | N/A | N/A |
| 5.7 | `GET /vehicles` with token where `iss = "https://attacker.example.com"`, otherwise valid | Wrong issuer (AC-5.11) | `status_code: 401` | exact (status) | N/A | N/A |
| 5.7b | `GET /vehicles` with token where `aud = "wrong-audience"`, otherwise valid | Wrong audience (AC-5.12) | `status_code: 401` | exact (status) | N/A | N/A |
| 5.8 | JWKS key rotation: `POST jwks-mock:8443/rotate-key`, immediately replay a token signed with the old `kid` AFTER the JWKS cache refresh tick (≤ 90s) and AFTER `OldKeyGraceSeconds=5` elapses | Cross-rotation invalidation (AC-5.7) — **no missions restart** required | `status_code: 401`; `missions` container's `StartedAt` timestamp unchanged | exact (status, startup timestamp) | N/A | N/A |
| 5.9 | `GET /vehicles` with token forged using `alg: HS256` against the JWKS public key bytes (HS256-confusion attack) | Algorithm pin defense (AC-5.1, AC-5.10) | `status_code: 401` | exact (status) | N/A | N/A |
| 5.10 | Cold start: stop `jwks-mock`, restart `missions`, immediately `GET /vehicles` with a valid (pre-stop-acquired) token | Cold-start dependency on admin reachability (AC-5.9) | `status_code: 500`; log contains JWKS fetch error mentioning HTTPS / connection refused / timeout | exact (status), log_assertion (substring) | N/A | N/A |
### AC-6 — Service startup + schema migration
@@ -119,8 +124,10 @@ Bootstrap fixtures use a Postgres container started fresh per scenario.
| # | Input | Input Description | Expected Result | Comparison | Tolerance | Reference File |
|---|-------|-------------------|-----------------|------------|-----------|---------------|
| 6.1 | Start service with `DATABASE_URL=postgresql://u:p@h:5432/d` (URL form) | URL conversion (AC-6.1) | service binds `:8080`; `GET /health` returns `200`; logger does NOT emit a connection error | log_assertion (no error), exact (health 200) | N/A | N/A |
| 6.2 | Start service with `DATABASE_URL=Host=h;Database=d;Username=u;Password=p` (raw form) | Raw connection string accepted (AC-6.1) | same as 6.1 | log_assertion, exact | N/A | N/A |
| 6.1 | Start service with **all four required env vars set correctly** (`DATABASE_URL=postgresql://u:p@h:5432/d` URL form, plus `JWT_ISSUER`, `JWT_AUDIENCE`, `JWT_JWKS_URL`) | URL conversion + fail-fast config resolution (AC-6.1) | service binds `:8080`; `GET /health` returns `200`; logger does NOT emit a config/connection error | log_assertion (no error), exact (health 200) | N/A | N/A |
| 6.1b | Start service with any one of `DATABASE_URL` / `JWT_ISSUER` / `JWT_AUDIENCE` / `JWT_JWKS_URL` unset | Fail-fast on missing required config (AC-6.1, AC-6.2, E3) | process exits non-zero within `≤ 10s`; log contains `InvalidOperationException` referencing the missing env var or config key | exact (non-zero exit), log_assertion (substring) | N/A | N/A |
| 6.1c | Start service with `JWT_JWKS_URL=http://jwks-mock:8443/...` (HTTP not HTTPS); other three set correctly | HTTPS-only JWKS retriever (AC-6.12) | container STARTS (config resolution passes); first protected request returns `status_code: 500` with log line mentioning `RequireHttps` / HTTPS | exact (start ok, first-request 500), log_assertion | N/A | N/A |
| 6.2 | Start service with `DATABASE_URL=Host=h;Database=d;Username=u;Password=p` (raw form), other three set | Raw connection string accepted (AC-6.1) | same as 6.1 | log_assertion, exact | N/A | N/A |
| 6.3 | Start service against an empty `azaion` database, inspect schema after startup | Migrator creates 4 owned tables + 3 indexes (AC-6.4) | `SELECT to_regclass(t)` returns non-NULL for each of `vehicles, missions, waypoints, map_objects`; index list contains `ix_missions_vehicle_id, ix_waypoints_mission_id, ix_map_objects_mission_id` | set_equals (table set), set_contains (index set) | N/A | N/A |
| 6.4 | Start service twice in a row against the same DB | Idempotency (AC-6.6) | second startup completes with same exit code as first; no `relation already exists` error in logs | exact (exit code), log_assertion (no error) | N/A | N/A |
| 6.5 | Pre-create `orthophotos` and `gps_corrections` tables, then start a post-B9 service | One-shot legacy drop (AC-6.5, AC-10.5) | both tables are absent after startup; `SELECT to_regclass('orthophotos')` and `SELECT to_regclass('gps_corrections')` both return NULL | exact | N/A | N/A |
@@ -128,6 +135,10 @@ Bootstrap fixtures use a Postgres container started fresh per scenario.
| 6.7 | Start service against a postgres instance where the `azaion` database does NOT exist | DB missing (AC-6.8) | process exits with non-zero exit code; logger emits message containing Npgsql `3D000` | exact (non-zero), log_assertion (substring `3D000`) | N/A | N/A |
| 6.8 | Make any handler throw `InvalidOperationException`, observe response | `ErrorHandlingMiddleware` registered FIRST (AC-6.9) | response: `status_code: 409`; envelope is the camelCase `{ statusCode, message }`; logger captured stack | exact (status, envelope), log_assertion | N/A | N/A |
| 6.9 | Start service, run `curl localhost:8080` from inside container | Listens on port 8080 (AC-6.10) | TCP connect succeeds; `/health` returns `200` | exact | N/A | N/A |
| 6.10 | Start service with `ASPNETCORE_ENVIRONMENT=Production`, empty `CorsConfig:AllowedOrigins`, `CorsConfig:AllowAnyOrigin != true` | CORS Production-gate fail-fast (AC-6.11, E9) | process exits non-zero within `≤ 10s`; log contains `InvalidOperationException` mentioning `CorsConfig` and `Production` | exact (non-zero exit), log_assertion (substring) | N/A | N/A |
| 6.11 | Start service with `ASPNETCORE_ENVIRONMENT=Production`, `CorsConfig:AllowAnyOrigin=true` | Production with explicit any-origin (AC-6.11, E9) | service starts; logs may include a warning about permissive CORS in Production but no throw | log_assertion (no throw) | N/A | N/A |
| 6.12 | Start service with `ASPNETCORE_ENVIRONMENT=Production`, `CorsConfig:AllowedOrigins=["https://operator.example.com"]` | Production with explicit allow-list (AC-6.11, E9) | service starts; `OPTIONS /vehicles` preflight from `https://operator.example.com` returns `200` with the corresponding `Access-Control-Allow-Origin` echo; preflight from `https://attacker.example.com` returns without the echo | exact (preflight echo present / absent) | N/A | N/A |
| 6.13 | Start service with `ASPNETCORE_ENVIRONMENT=Test` (or any non-Production), empty `CorsConfig:AllowedOrigins` | Non-Production permissive fallback (AC-6.11, E9) | service starts; logs contain `PermissiveDefaultWarning`; `OPTIONS /vehicles` from any origin gets `200` with echo | log_assertion (warning), exact (preflight) | N/A | N/A |
### AC-7 — Health probe
@@ -154,8 +165,8 @@ Bootstrap fixtures use a Postgres container started fresh per scenario.
| # | Input | Input Description | Expected Result | Comparison | Tolerance | Reference File |
|---|-------|-------------------|-----------------|------------|-----------|---------------|
| 9.1 | Each protected endpoint (`/vehicles`, `/missions`, `/missions/{id}/waypoints`) called with token having `permissions == "FL"` | Policy "FL" satisfies (AC-9.1, AC-9.4) | each call: `status_code``{200, 201, 204}` (no 401/403) | exact (status set) | N/A | N/A |
| 9.2 | Any protected endpoint called with `permissions == "fl"` (lowercase) or `"FLight"` | Hardcoded string mismatch (AC-9.2) | `status_code: 403` | exact (status) | N/A | N/A |
| 9.1 | Each protected endpoint (`/vehicles`, `/missions`, `/missions/{id}/waypoints`) called with token having `permissions == "FL"` (single string OR array containing `"FL"`) | Policy "FL" satisfies via contains-match (AC-9.1, AC-9.4) | each call: `status_code``{200, 201, 204}` (no 401/403); a token with `permissions: ["FL", "ADMIN"]` is ALSO accepted | exact (status set) | N/A | N/A |
| 9.2 | Any protected endpoint called with `permissions == "fl"` (lowercase) or `"FLight"` or `"ADMIN"` | Hardcoded string mismatch (AC-9.2) | `status_code: 403` | exact (status) | N/A | N/A |
| 9.3 | `GET /health` with NO `Authorization` header | Health is exempt (AC-9.4 contrast) | `status_code: 200` | exact (status) | N/A | N/A |
### AC-10 — Operational invariants (verifiable observables)