mirror of
https://github.com/azaion/missions.git
synced 2026-06-22 12:41:07 +00:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3398ec49a0 | |||
| 001e80fe96 | |||
| 26126e6216 | |||
| 24c4561bef | |||
| 6b2c2d998e | |||
| 3c5354e56c |
@@ -0,0 +1,40 @@
|
||||
# Build artifacts
|
||||
**/bin/
|
||||
**/obj/
|
||||
|
||||
# Tests live in their own csproj files and are NOT part of the missions
|
||||
# service Docker image. Excluding them shrinks the build context and
|
||||
# prevents accidental glob inclusion (see Azaion.Missions.csproj note).
|
||||
tests/
|
||||
|
||||
# Documentation, internal process artifacts, and IDE/agent state
|
||||
_docs/
|
||||
.cursor/
|
||||
docs/
|
||||
|
||||
# Repository metadata
|
||||
.git/
|
||||
.gitignore
|
||||
.gitattributes
|
||||
.gitmodules
|
||||
|
||||
# Editor / OS detritus
|
||||
.vscode/
|
||||
.idea/
|
||||
.DS_Store
|
||||
*.swp
|
||||
|
||||
# CI / local infra files (the image doesn't need them at build time)
|
||||
.woodpecker/
|
||||
.github/
|
||||
docker-compose*.yml
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
|
||||
# Test outputs (when tests run on the host)
|
||||
test-results/
|
||||
|
||||
# Local environment files
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
@@ -52,6 +52,11 @@ public static class JwtExtensions
|
||||
if (refreshSeconds is int refreshSec)
|
||||
jwksConfigManager.RefreshInterval = TimeSpan.FromSeconds(refreshSec);
|
||||
|
||||
// Singleton so the (otherwise hidden) cache can be triggered from a
|
||||
// test-only endpoint when ASPNETCORE_ENVIRONMENT=Test. Production
|
||||
// never resolves it because the endpoint is not mapped.
|
||||
services.AddSingleton<IConfigurationManager<JsonWebKeySet>>(jwksConfigManager);
|
||||
|
||||
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||
.AddJwtBearer(options =>
|
||||
{
|
||||
|
||||
@@ -4,6 +4,16 @@
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
<!-- The test project lives under tests/ with its own csproj. Without these
|
||||
removes, Sdk.Web's default glob (**/*.cs under the project directory)
|
||||
would pull test sources into the service compile and fail because
|
||||
Xunit + SkippableFact references live only in the test csproj. -->
|
||||
<ItemGroup>
|
||||
<Compile Remove="tests/**" />
|
||||
<Content Remove="tests/**" />
|
||||
<None Remove="tests/**" />
|
||||
<EmbeddedResource Remove="tests/**" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="linq2db" Version="6.2.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.5" />
|
||||
|
||||
+6
-1
@@ -11,6 +11,11 @@ ENV AZAION_REVISION=$CI_COMMIT_SHA
|
||||
WORKDIR /app
|
||||
COPY --from=build /app .
|
||||
COPY docker-entrypoint.sh /docker-entrypoint.sh
|
||||
RUN chmod +x /docker-entrypoint.sh
|
||||
# wget is required by docker-compose.test.yml's /health probe. The aspnet
|
||||
# base image does not ship it; install with apt before stripping the cache.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends wget \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& chmod +x /docker-entrypoint.sh
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["/docker-entrypoint.sh", "dotnet", "Azaion.Missions.dll"]
|
||||
|
||||
+30
@@ -77,6 +77,36 @@ app.UseSwaggerUI();
|
||||
app.MapControllers();
|
||||
app.MapGet("/health", () => Results.Ok(new { status = "healthy" }));
|
||||
|
||||
// Test-only JWKS refresh hook. The Microsoft.IdentityModel ConfigurationManager
|
||||
// hard-pins the AutomaticRefreshInterval floor to 5 minutes (static field), so
|
||||
// JWKS-rotation e2e scenarios cannot rely on the proactive refresh path inside
|
||||
// a 15-minute CI window. RequestRefresh() itself is throttled by
|
||||
// RefreshInterval after the first call — two rotation tests running within
|
||||
// 1 second cannot both refresh through the public API. The endpoint sidesteps
|
||||
// the throttle by resetting `_isFirstRefreshRequest` via reflection so each
|
||||
// call behaves like the very first refresh request. This is a TEST-ONLY
|
||||
// affordance — gated on ASPNETCORE_ENVIRONMENT=Test; production never maps
|
||||
// the route. See Helpers/JwksRefreshHelper.cs for the test-side caller.
|
||||
if (app.Environment.IsEnvironment("Test"))
|
||||
{
|
||||
app.MapPost("/test/refresh-jwks", async (
|
||||
Microsoft.IdentityModel.Protocols.IConfigurationManager<Microsoft.IdentityModel.Tokens.JsonWebKeySet> mgr,
|
||||
CancellationToken cancel) =>
|
||||
{
|
||||
var firstField = mgr.GetType().GetField(
|
||||
"_isFirstRefreshRequest",
|
||||
System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
|
||||
firstField?.SetValue(mgr, true);
|
||||
mgr.RequestRefresh();
|
||||
var jwks = await mgr.GetConfigurationAsync(cancel).ConfigureAwait(false);
|
||||
return Results.Ok(new
|
||||
{
|
||||
refreshed = true,
|
||||
kids = jwks.GetSigningKeys().Select(k => k.KeyId).ToArray(),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
app.Run();
|
||||
|
||||
static string ConvertPostgresUrl(string url)
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
# Batch Report
|
||||
|
||||
**Batch**: 2
|
||||
**Tasks**: AZ-577, AZ-578, AZ-579, AZ-580
|
||||
**Date**: 2026-05-15
|
||||
**Run mode**: Test implementation (existing-code Step 6)
|
||||
**Total complexity**: 18 SP (5 + 5 + 5 + 3)
|
||||
|
||||
## Task Results
|
||||
|
||||
| Task | Status | Files Modified | Tests | AC Coverage | Issues |
|
||||
|------|--------|----------------|-------|-------------|--------|
|
||||
| AZ-577_test_vehicles_positive | Done | 3 added (1 deleted) | 6 / 6 pass discovery, AAA pass | 6/6 ACs covered | 1 carry-forward |
|
||||
| AZ-578_test_missions_positive | Done | 3 added (1 deleted) | 6 / 6 pass discovery, AAA pass | 6/6 ACs covered | 0 |
|
||||
| AZ-579_test_waypoints_health_positive | Done | 4 added (2 deleted) | 6 / 6 pass discovery, AAA pass | 6/6 ACs covered | 2 carry-forwards |
|
||||
| AZ-580_test_validation_authz_negative | Done | 5 added | 8 / 8 pass discovery, AAA pass | 8/8 ACs covered | 1 carry-forward |
|
||||
|
||||
## AC Test Coverage: All 26 covered
|
||||
|
||||
- **AZ-577 (6/6)**: AC-1 → FT_P_01, AC-2 → FT_P_02, AC-3 → FT_P_03 (carry-forward), AC-4 → FT_P_04, AC-5 → FT_P_05, AC-6 → FT_P_06.
|
||||
- **AZ-578 (6/6)**: AC-1 → FT_P_07, AC-2 → FT_P_08, AC-3 → FT_P_09, AC-4 → FT_P_10, AC-5 → FT_P_11, AC-6 → FT_P_12 (own collection `CascadeF3`).
|
||||
- **AZ-579 (6/6)**: AC-1 → FT_P_13, AC-2 → FT_P_14 (carry-forward flat geo), AC-3 → FT_P_15 (carry-forward flat geo), AC-4 → FT_P_16, AC-5 → FT_P_17 (SkippableFact gated on `COMPOSE_RESTART_ENABLED`), AC-6 → FT_P_18 (own collection `CascadeF4`).
|
||||
- **AZ-580 (8/8)**: AC-1 → FT_N_01, AC-2 → FT_N_02, AC-3 → FT_N_03, AC-4 → FT_N_04 (carry-forward), AC-5 → FT_N_05, AC-6 → FT_N_06 (own collection, pg_stat_statements + row-count belt-and-braces), AC-7 → FT_N_07 (carry-forward), AC-8 → FT_N_08 (own collection `ErrorEnvelope500`, SkippableFact).
|
||||
|
||||
## Code Review Verdict: PASS_WITH_WARNINGS (self-review)
|
||||
|
||||
Formal `/code-review` skill was not invoked for this batch (covered by the cumulative-review interval). Self-review:
|
||||
|
||||
- 0 Critical, 0 High, 0 Medium.
|
||||
- **Low — design**: 3 spec-vs-code carry-forwards explicitly documented as source-level `// CARRY-FORWARD` comments + `[Trait("carry_forward", ...)]` so the next divergence-resolution task can find them via filter.
|
||||
- **Low — coverage**: 2 SkippableFact tests (FT-P-17 and FT-N-08) require `COMPOSE_RESTART_ENABLED=1` plus `docker` CLI access in the e2e-consumer image. Today the consumer image is `mcr.microsoft.com/dotnet/sdk:10.0` without `docker-cli` installed and without a docker socket bind in `docker-compose.test.yml`. The skip reason is explicit (no silent pass).
|
||||
- **Low — coverage**: FT-N-06's strict "no DELETE statements emitted" check uses `pg_stat_statements`. The extension is not in the postgres-test image's `shared_preload_libraries` today, so `CREATE EXTENSION` will return SQLState 0A000. The test then falls back to a per-table row-count invariant check (which still catches the bug if cascade actually ran). When/if the postgres-test image gains the preload, the strict check activates automatically.
|
||||
|
||||
## Auto-Fix Attempts: 1
|
||||
|
||||
Initial build produced 89× xUnit1030 warnings ("Test methods should not call `ConfigureAwait(false)`"). Auto-fixed by removing all `.ConfigureAwait(false)` calls from test method bodies (Style/Low — eligible per Auto-Fix Gate matrix). Re-build: 0 warnings, 0 errors. Reporting + AaaPatternEnforcement tests still pass (5/5).
|
||||
|
||||
## Stuck Agents: None
|
||||
|
||||
## Spec-vs-Code Divergences (3 carry-forwards)
|
||||
|
||||
User chose "write tests TO CODE" for batch 2 (`/autodev` interactive choice, 2026-05-15). Each divergence is pinned with a `[Trait("carry_forward", ...)]` so a future cleanup task can `dotnet test --filter "carry_forward~..."` to locate every flip-when-resolved site.
|
||||
|
||||
| Site | Spec says | Code says | Test assertion |
|
||||
|------|-----------|-----------|----------------|
|
||||
| FT-P-03 setDefault — `Vehicles/PositiveTests.cs` | `POST /vehicles/{id}/setDefault` → `200` with `Vehicle` body | `[HttpPatch("{id:guid}/default")]` → `204 NoContent` | `PATCH … /default` + `204` + DB-side-channel default invariant |
|
||||
| FT-P-14 / FT-P-15 — `Waypoints/PositiveTests.cs` | response body has nested `GeoPoint:{Lat,Lon,Mgrs}` | response is the LinqToDB `Waypoint` entity with flat `Lat`/`Lon`/`Mgrs` columns | flat-shape assertions (`waypoint.Lat`, `waypoint.Mgrs`) |
|
||||
| FT-N-07 — `Waypoints/NegativeTests.cs` | missing parent mission → `404` with problem envelope | `WaypointService.GetWaypoints` does not check parent — returns `[]` | `200` + body `[]`, marked `[Trait("carry_forward", "AC-4.2")]` |
|
||||
|
||||
These flip the moment the spec/code is reconciled (either the controller adds the route + return shape, or the spec is updated). The tests will fail loudly at that point — that is intentional.
|
||||
|
||||
## Files Created (15)
|
||||
|
||||
### Helpers / Fixtures (shared scaffolding, 6 files)
|
||||
|
||||
- `tests/Azaion.Missions.E2E.Tests/Helpers/ApiDtos.cs` — wire DTOs (Vehicle, Mission, Waypoint, PaginatedResponse, Problem) with explicit `[JsonPropertyName]` so a future global camelCase migration breaks tests loudly
|
||||
- `tests/Azaion.Missions.E2E.Tests/Helpers/HttpAssertions.cs` — added `AssertProblemEnvelopeAsync(response, status)` (existing file extended; no behavior change to `AssertErrorEnvelopeAsync`)
|
||||
- `tests/Azaion.Missions.E2E.Tests/Fixtures/Seeds.cs` — `OneDefaultVehicle`, `Three_BR01_BR02_MQ9`, `TwentyFiveMissions`, `FiveWaypointsUnordered`
|
||||
- `tests/Azaion.Missions.E2E.Tests/Fixtures/StubSchema.cs` — borrowed-table CREATE IF NOT EXISTS for `media`, `annotations`, `detection`
|
||||
- `tests/Azaion.Missions.E2E.Tests/Fixtures/CascadeF3Fixture.cs` — loads `fixture_cascade_F3.sql`
|
||||
- `tests/Azaion.Missions.E2E.Tests/Fixtures/CascadeF4Fixture.cs` — loads `fixture_cascade_F4.sql`
|
||||
- `tests/Azaion.Missions.E2E.Tests/Fixtures/PostgresStopStartFixture.cs` — wraps `docker compose stop|start postgres-test` for FT-P-17, gated on `COMPOSE_RESTART_ENABLED=1`
|
||||
|
||||
### Test classes (10 files; the deleted `Sanity.cs` files are listed under "Files Deleted" below)
|
||||
|
||||
- `Tests/Vehicles/PositiveTests.cs` — FT-P-01..06
|
||||
- `Tests/Vehicles/NegativeTests.cs` — FT-N-01, FT-N-02, FT-N-03
|
||||
- `Tests/Missions/PositiveTests.cs` — FT-P-07..11
|
||||
- `Tests/Missions/CascadeF3Tests.cs` — FT-P-12 (own xUnit collection)
|
||||
- `Tests/Missions/NegativeTests.cs` — FT-N-04, FT-N-05
|
||||
- `Tests/Missions/CascadeShortCircuitTests.cs` — FT-N-06 (own collection)
|
||||
- `Tests/Waypoints/PositiveTests.cs` — FT-P-13, FT-P-14, FT-P-15
|
||||
- `Tests/Waypoints/CascadeF4Tests.cs` — FT-P-18 (own collection)
|
||||
- `Tests/Waypoints/NegativeTests.cs` — FT-N-07
|
||||
- `Tests/Health/HealthTests.cs` — FT-P-16, FT-P-17 (FT-P-17 is `[SkippableFact]`)
|
||||
- `Tests/Errors/Error500Tests.cs` — FT-N-08 (own collection `ErrorEnvelope500`, `[SkippableFact]`)
|
||||
|
||||
### Files Deleted (4 placeholder Sanity.cs)
|
||||
|
||||
Each Sanity test was a discovery-only `[Fact]` placed by AZ-576 to satisfy the "every test folder has ≥ 1 test" requirement. Now-replaced by full FT-P-* / FT-N-* coverage in the same folder, so deletion is dead-code hygiene.
|
||||
|
||||
- `Tests/Vehicles/Sanity.cs`, `Tests/Missions/Sanity.cs`, `Tests/Waypoints/Sanity.cs`, `Tests/Health/Sanity.cs`
|
||||
|
||||
### Compose updates
|
||||
|
||||
- `docker-compose.test.yml` — added `FIXTURE_SQL_DIR=/app/fixtures` env var and read-only volume mount `./_docs/00_problem/input_data/expected_results:/app/fixtures:ro` for the e2e-consumer service. Required because `Helpers/FixtureSql.cs` looks up SQL files at the canonical path; the AZ-576 compose file did not yet wire it.
|
||||
|
||||
## Local Verification
|
||||
|
||||
`dotnet build … -c Release` — 0 warnings, 0 errors after auto-fix.
|
||||
|
||||
`dotnet test … --filter "FullyQualifiedName~AaaPatternEnforcement|FullyQualifiedName~Reporting"` — 5 / 5 pass (the docker-free subset). The blackbox tests added in this batch require the docker compose stack and are validated by the autodev Step 7 (`test-run/SKILL.md`) gate.
|
||||
|
||||
`dotnet test … --list-tests | grep "FT_[PN]_"` — 26 tests discovered (18 FT-P + 8 FT-N), matching the 26 ACs across the four tasks.
|
||||
|
||||
## Docker Stack Validation
|
||||
|
||||
Not run as part of this batch — same hand-off as batch 1. Step 7 (`test-run/SKILL.md`) owns the `docker compose -f docker-compose.test.yml up --build --abort-on-container-exit e2e-consumer` gate. FT-P-17 and FT-N-08 are SkippableFacts — they activate when `COMPOSE_RESTART_ENABLED=1` is set in the consumer container AND the consumer image has `docker` CLI on PATH; otherwise they emit an explicit skip reason (no silent pass).
|
||||
|
||||
## Tracker Updates
|
||||
|
||||
Per `protocols.md` § Steps That Require Work Item Tracker, Step 6 (Implement Tests) does not create new tickets but transitions existing ones. The implement skill's Step 5 (`In Progress`) and Step 12 (`In Testing`) are followed manually for AZ-577 / AZ-578 / AZ-579 / AZ-580 since the Jira MCP transitions are out of band.
|
||||
|
||||
## Next Batch
|
||||
|
||||
All 11 test tasks (AZ-576 + AZ-577..AZ-586) span two batches in the dependency table. Batch 1 covered AZ-576. Batch 2 covers AZ-577..AZ-580 (functional positive + negative). Batch 3 will cover AZ-581..AZ-586 (security NFT-SEC, resilience NFT-RES, resource limits NFT-RES-LIM, performance NFT-PERF) — these are the heavier non-functional categories. **Recommend a session break before Batch 3** per the Context Management Protocol heuristic ("more than 2 batches in one session" caution zone).
|
||||
@@ -0,0 +1,114 @@
|
||||
# Batch Report
|
||||
|
||||
**Batch**: 3
|
||||
**Tasks**: AZ-581, AZ-582, AZ-583, AZ-584
|
||||
**Date**: 2026-05-15
|
||||
**Run mode**: Test implementation (existing-code Step 6)
|
||||
**Total complexity**: 18 SP (5 + 5 + 3 + 5)
|
||||
|
||||
## Task Results
|
||||
|
||||
| Task | Status | Files Modified | Tests | AC Coverage | Issues |
|
||||
|------|--------|----------------|-------|-------------|--------|
|
||||
| AZ-581_test_security_auth_claims | Done | 1 added, 1 helper added, 2 mock files modified | 8 / 8 discovery | 7/7 ACs covered | 0 |
|
||||
| AZ-582_test_security_alg_rotation_cors | Done | 5 added, 2 helpers added | 12 / 12 discovery | 7/7 NFT-SEC scenarios covered | 0 |
|
||||
| AZ-583_test_resilience_cascade_migrator | Done | 3 added | 4 / 4 discovery | 4/4 NFT-RES scenarios covered | 2 carry-forwards |
|
||||
| AZ-584_test_resilience_config_db_rotation_race | Done | 3 added | 8 / 8 discovery | 4/4 NFT-RES scenarios covered | 1 carry-forward |
|
||||
|
||||
## AC Test Coverage: All 22 NFT scenarios covered
|
||||
|
||||
- **AZ-581 (7/7)**: AC-1 → `NFT_SEC_01_*`, AC-2 → `NFT_SEC_02_*` (byte-flip + foreign keypair), AC-3 → `NFT_SEC_03_*` (−60s / −15s skew), AC-4 → `NFT_SEC_04_*`, AC-5 → `NFT_SEC_04b_*`, AC-6 → `NFT_SEC_05_*` (403), AC-7 → `NFT_SEC_06_*` (Theory for ADMIN/fl/FLight + Fact for `["FL","ADMIN"]`).
|
||||
- **AZ-582 (7/7)**: NFT-SEC-07 → `CrossCuttingTests.NFT_SEC_07_*`, NFT-SEC-08 → `ErrorRedactionTests.NFT_SEC_08_*` (SkippableFact, drops `vehicles` table), NFT-SEC-09 → `CrossCuttingTests.NFT_SEC_09_*`, NFT-SEC-10 → `CrossCuttingTests.NFT_SEC_10_*` (HS256 + alg=none), NFT-SEC-11 → `JwksRotationTests.NFT_SEC_11_*`, NFT-SEC-12 → `StartupConfigTests` (SkippableTheory + HTTP-JWKS variant), NFT-SEC-13 → `CorsConfigTests` (4 SkippableFact scenarios).
|
||||
- **AZ-583 (4/4)**: NFT-RES-01 → `CascadeF3Tests.NFT_RES_01_*` (mid-walk partial state today), NFT-RES-02 → `CascadeF4Tests.NFT_RES_02_*` (carry-forward AC-4.6/walk-order), NFT-RES-03 → `MigratorRestartTests.NFT_RES_03_*`, NFT-RES-04 → `MigratorRestartTests.NFT_RES_04_*`.
|
||||
- **AZ-584 (4/4)**: NFT-RES-05 → `ConfigDbStartupTests` (Theory for 5 missing-env cases + whitespace Fact + DB-down Fact), NFT-RES-06 → `ConfigDbStartupTests.NFT_RES_06_*` (drops `azaion` DB), NFT-RES-07 → `JwksRotationNoRestartTests.NFT_RES_07_*` (StartedAt invariant), NFT-RES-08 → `DefaultVehicleRaceTests.NFT_RES_08_*` (carry-forward AC-1.4).
|
||||
|
||||
## Code Review Verdict: PASS_WITH_WARNINGS (self-review)
|
||||
|
||||
Formal `/code-review` skill was not invoked separately — this batch is the 3rd in the run, so the cumulative-review step (every K=3 batches) runs immediately after the commit and acts as both per-batch and cross-batch review. Self-review pre-cumulative:
|
||||
|
||||
- 0 Critical, 0 High, 0 Medium.
|
||||
- **Low — coverage**: 7 of the 22 new test methods are `SkippableFact` / `SkippableTheory` gated on `COMPOSE_RESTART_ENABLED=1` plus a Docker CLI on PATH inside the e2e-consumer image. Today the consumer image is `mcr.microsoft.com/dotnet/sdk:10.0` without `docker-cli` installed and without a docker-socket bind in `docker-compose.test.yml`. Each skip emits an explicit reason (no silent pass). Activating these tests is its own infrastructure follow-up — recommended after Step 7.
|
||||
- **Low — design**: NFT-SEC-08 (`ErrorRedactionTests`) re-uses the same destructive primitive as FT-N-08 (DROP TABLE `vehicles`). Both tests deliberately collide on collection scope so the post-test teardown is owned by one fixture; this is intentional, not duplication.
|
||||
- **Low — maintainability**: `ConfigDbStartupTests.DropAzaionDatabase` performs a string-level `Replace("Database=azaion", "Database=postgres")` to switch to the admin DB for the `DROP DATABASE` call. Brittle if the connection string is later expressed in lowercase or with a different key casing — a single-purpose `NpgsqlConnectionStringBuilder.Database = "postgres"` would harden it. Captured as a follow-up note; the SkippableFact reports an explicit failure reason if the swap silently fails.
|
||||
|
||||
## Auto-Fix Attempts: 1
|
||||
|
||||
Initial cross-batch rebuild surfaced 3 stale errors from earlier batch files:
|
||||
- `Helpers/MissionsContainerHelper.cs:110` — missing `using System.Net;` (`HttpStatusCode.OK` reference)
|
||||
- `Tests/Security/CrossCuttingTests.cs:36,46` — missing `using System.Net.Http.Json;` (`ReadFromJsonAsync<T>` extension)
|
||||
|
||||
All three are Style/Low (missing-using) and auto-fix-eligible per the Auto-Fix Gate matrix. Resolved in a single edit each; rebuild: 0 warnings, 0 errors.
|
||||
|
||||
## Stuck Agents: None
|
||||
|
||||
## Spec-vs-Code Divergences (3 carry-forwards)
|
||||
|
||||
User chose "write tests TO CODE" for batch 2 (`/autodev` interactive choice, 2026-05-15); the same policy carries into batch 3. Divergences are pinned with `[Trait("carry_forward", ...)]` so a future cleanup task can filter every flip-when-resolved site.
|
||||
|
||||
| Site | Spec says | Code says | Test assertion |
|
||||
|------|-----------|-----------|----------------|
|
||||
| NFT-RES-01 — `Resilience/CascadeF3Tests.cs` | mid-walk failure leaves cascade strictly transactional | `MissionService.DeleteMission` is non-transactional — `map_objects` committed before the `media` lookup hits the dropped table | 500 + partial state (`map_objects=0`, `missions=1`); `[Trait("carry_forward", "ADR-006")]` |
|
||||
| NFT-RES-02 — `Resilience/CascadeF4Tests.cs` | waypoint cascade leaves `detection=0`, `waypoint=1` after mid-walk failure | `WaypointService.DeleteWaypoint` queries `media` BEFORE any deletion, so dropping `media` aborts the request at the FIRST step — nothing is deleted | 500 + `detection` count UNCHANGED + `waypoint` count UNCHANGED; `[Trait("carry_forward", "AC-4.6/walk-order")]` |
|
||||
| NFT-RES-08 — `Resilience/DefaultVehicleRaceTests.cs` | TOCTOU race observable — at least one of 100 iterations leaves two rows with `is_default=true` | `DatabaseMigrator` ships a partial unique index `ux_vehicles_one_default ON vehicles (is_default) WHERE is_default = TRUE` — the second writer always fails with `23505`, race CANNOT be observed | Max `is_default=true` count ≤ 1 across 100 iterations; `[Trait("carry_forward", "AC-1.4/index-closes-race")]`. Test fails loudly the day the index is removed/relaxed. |
|
||||
|
||||
These three carry-forwards flip the moment spec and code reconcile. The tests fail loudly at that point — that is intentional and is the signal to update `traceability_matrix.csv`.
|
||||
|
||||
## Files Created (11 test files + 3 helpers)
|
||||
|
||||
### Helpers / Fixtures (cross-cutting scaffolding, 3 files)
|
||||
|
||||
- `tests/Azaion.Missions.E2E.Tests/Helpers/ForeignKeypair.cs` — test-only P-256 ECDSA keypair generator + JWT signer for NFT-SEC-02. The keypair is NEVER registered with `missions` or `jwks-mock` — it produces a structurally-valid-but-unknown-key token to exercise the SUT's `IssuerSigningKeyResolver` path.
|
||||
- `tests/Azaion.Missions.E2E.Tests/Helpers/MissionsContainerHelper.cs` — `docker run` wrapper for standalone `azaion/missions:test` startup-time scenarios (NFT-SEC-12, NFT-SEC-13, NFT-RES-05, NFT-RES-06). Gated on `COMPOSE_RESTART_ENABLED=1` plus docker CLI; exposes `RunUntilExit`, `StartAndWaitForHealthAsync`, `GetStartedAt`.
|
||||
- `tests/Azaion.Missions.E2E.Tests/Helpers/DockerLogs.cs` — `docker logs --since` reader used by NFT-SEC-08 / NFT-RES-01..04 log-assertion paths.
|
||||
|
||||
### Modified test infrastructure (mock contract + minter)
|
||||
|
||||
- `tests/Azaion.Missions.JwksMock/Endpoints/SignEndpoint.cs` — `SignBody` now accepts either `permissions` (string) OR `permissions_array` (string[]); mutually exclusive. Required for NFT-SEC-06 multi-value tokens.
|
||||
- `tests/Azaion.Missions.JwksMock/Services/TokenSigner.cs` — array-permissions payload encoding + `kid_override` validation against `PublishedKeys()`. The kid validation enables NFT-SEC-11 AC-5.4 ("mock refuses old kid post-grace").
|
||||
- `tests/Azaion.Missions.E2E.Tests/TokenMinter.cs` — `SignRequest.PermissionsArray` field mirrors the mock contract.
|
||||
|
||||
### Test classes (11 files)
|
||||
|
||||
Security category (`Tests/Security/`):
|
||||
|
||||
- `AuthClaimsTests.cs` — NFT-SEC-01..06+04b (AZ-581)
|
||||
- `CrossCuttingTests.cs` — NFT-SEC-07, NFT-SEC-09, NFT-SEC-10 (AZ-582)
|
||||
- `ErrorRedactionTests.cs` — NFT-SEC-08 (`[SkippableFact]`, own collection) (AZ-582)
|
||||
- `JwksRotationTests.cs` — NFT-SEC-11 (own collection `JwksRotation`, 120s timeout) (AZ-582)
|
||||
- `StartupConfigTests.cs` — NFT-SEC-12 (SkippableTheory + HTTP-JWKS Fact) (AZ-582)
|
||||
- `CorsConfigTests.cs` — NFT-SEC-13 (4 SkippableFact scenarios) (AZ-582)
|
||||
|
||||
Resilience category (`Tests/Resilience/`):
|
||||
|
||||
- `CascadeF3Tests.cs` — NFT-RES-01 (own collection, SkippableFact, drops `media`) (AZ-583)
|
||||
- `CascadeF4Tests.cs` — NFT-RES-02 (own collection, SkippableFact, drops `media`; carry-forward) (AZ-583)
|
||||
- `MigratorRestartTests.cs` — NFT-RES-03 + NFT-RES-04 (collection `MigratorRestart`) (AZ-583)
|
||||
- `ConfigDbStartupTests.cs` — NFT-RES-05 (Theory + 2 Facts) + NFT-RES-06 (collection `MigratorRestart`) (AZ-584)
|
||||
- `JwksRotationNoRestartTests.cs` — NFT-RES-07 (collection `JwksRotation`) (AZ-584)
|
||||
- `DefaultVehicleRaceTests.cs` — NFT-RES-08 (carry-forward) (AZ-584)
|
||||
|
||||
## Local Verification
|
||||
|
||||
- `dotnet build tests/Azaion.Missions.E2E.Tests/Azaion.Missions.E2E.Tests.csproj` — 0 warnings, 0 errors after the `using`-fix auto-fix.
|
||||
- `dotnet build tests/Azaion.Missions.JwksMock/Azaion.Missions.JwksMock.csproj` — 0 warnings, 0 errors (mock contract additions compile cleanly).
|
||||
- Test discovery: 22 new NFT methods across 11 files, every method carries a `[Trait("Traces", "AC-X.Y")]` for traceability.
|
||||
|
||||
## Pre-existing scope notes (NOT introduced by this batch)
|
||||
|
||||
- The root project file `Azaion.Missions.csproj` (a `Microsoft.NET.Sdk.Web` project) globs `**/*.cs` under the repo root, which pulls test files into its compilation if `dotnet build Azaion.Missions.csproj` is invoked. The test project builds correctly via its own `csproj` (the normal path); the root-csproj scope is pre-existing project configuration drift outside the test-implementation scope. Recommend a separate refactor task to add a `<Compile Remove="tests/**" />` or move to a `.sln` file.
|
||||
|
||||
## Docker Stack Validation
|
||||
|
||||
Not run as part of this batch — same hand-off as batches 1 and 2. Step 7 (`test-run/SKILL.md`) owns the `docker compose -f docker-compose.test.yml up --build --abort-on-container-exit e2e-consumer` gate. The SkippableFacts above activate only when the e2e-consumer image gains a Docker CLI + socket bind; otherwise they emit explicit skip reasons (no silent pass).
|
||||
|
||||
## Tracker Updates
|
||||
|
||||
Per `protocols.md` § Steps That Require Work Item Tracker, Step 6 (Implement Tests) does not create new tickets but transitions existing ones. Step 5 (`In Progress`) and Step 12 (`In Testing`) are followed for AZ-581..AZ-584 via the Atlassian MCP after this commit (transitions are out-of-band and idempotent).
|
||||
|
||||
## Cumulative Code Review
|
||||
|
||||
Batch 3 is the 3rd batch in this test-implementation cycle — the every-K=3 cumulative review step runs immediately after the batch commit. Report will be saved as `_docs/03_implementation/cumulative_review_batches_01-03_cycle1_report.md`.
|
||||
|
||||
## Next Batch
|
||||
|
||||
Batch 4 covers the remaining 2 tasks (AZ-585 resource limits + AZ-586 performance, 3 + 3 = 6 SP). After Batch 4 + its cumulative slice, Step 6 is complete and autodev advances to Step 7 (Run Tests).
|
||||
@@ -0,0 +1,80 @@
|
||||
# Batch Report
|
||||
|
||||
**Batch**: 4
|
||||
**Tasks**: AZ-585, AZ-586
|
||||
**Date**: 2026-05-15
|
||||
**Run mode**: Test implementation (existing-code Step 6)
|
||||
**Total complexity**: 6 SP (3 + 3)
|
||||
|
||||
## Task Results
|
||||
|
||||
| Task | Status | Files Modified | Tests | AC Coverage | Issues |
|
||||
|------|--------|----------------|-------|-------------|--------|
|
||||
| AZ-585_test_resource_limits | Done | 3 added, 1 deleted | 4 / 4 discovery | 4/4 NFT-RES-LIM covered | 0 |
|
||||
| AZ-586_test_performance | Done | 1 added, 1 deleted, 2 helpers added, entrypoint.sh modified | 4 / 4 discovery | 4/4 NFT-PERF covered | 0 |
|
||||
|
||||
## AC Test Coverage: All 8 NFT scenarios covered
|
||||
|
||||
- **AZ-585 (4/4)**: NFT-RES-LIM-01 → `SteadyStateLoadTests.NFT_RES_LIM_01_*` (P95 RSS + no-leak ratio), NFT-RES-LIM-02 → `SteadyStateLoadTests.NFT_RES_LIM_02_*` (Npgsql conn cap + minute-1 mean), NFT-RES-LIM-03 → `SteadyStateLoadTests.NFT_RES_LIM_03_*` (FD cap + minute-1 anchor), NFT-RES-LIM-04 → `ColdStartRssTests.NFT_RES_LIM_04_*` (30s settle + cold-RSS cap).
|
||||
- **AZ-586 (4/4)**: NFT-PERF-01 → `PerformanceTests.NFT_PERF_01_*` (100 minimal-cascade DELETEs, P50 ≤ 50ms), NFT-PERF-02 → `*.NFT_PERF_02_*` (50 F3-shape cascade DELETEs, provisional P50 ≤ 200ms), NFT-PERF-03 → `*.NFT_PERF_03_*` (100 `/health`, P50 ≤ 10ms), NFT-PERF-04 → `*.NFT_PERF_04_*` (100 paginated lists vs 1000-mission seed, provisional P95 ≤ 100ms).
|
||||
|
||||
## Code Review Verdict: PASS_WITH_WARNINGS (self-review)
|
||||
|
||||
- 0 Critical, 0 High, 0 Medium.
|
||||
- **Low — coverage**: 4 of 4 ResLim tests are `SkippableFact` gated on `COMPOSE_RESTART_ENABLED=1` + docker CLI in the e2e-consumer image — same Docker-socket follow-up already flagged in batch 3 report. NFT-RES-LIM-04 additionally requires `docker compose stop|rm|up` access; same gate.
|
||||
- **Low — maintainability**: `SteadyStateLoadFixture.ParseHumanBytes` and `ColdStartRssTests.ParseHumanBytes` are duplicated. Both files parse the LHS of `docker stats --no-stream --format '{{.MemUsage}}'`; the duplication is intentional today because the two files have different gating predicates (fixture uses `Enabled` property + `CommandAvailable` probe, ColdStart uses `MissionsContainerHelper.Enabled`), and lifting the helper to `Helpers/HumanBytes.cs` would be a shared-helper change worth a separate refactor. Captured as a follow-up note; not auto-fixed because it touches both files. **Recommend folding into the docker-CLI follow-up task.**
|
||||
- **Low — observability**: `PerformanceTests` swallows non-2xx-non-404 with `InvalidOperationException` (warmup + measured), so a misbehaving SUT mid-run yields a clear stack trace; no silent pass. This is intended.
|
||||
|
||||
## Auto-Fix Attempts: 1
|
||||
|
||||
`SteadyStateLoadFixture.cs:59` initially called `new TokenMinter()` (parameter-less ctor); `TokenMinter` requires `signUrl`. Fixed to `new TokenMinter(TestEnvironment.JwksMockBaseUrl + "/sign")` — same pattern as `TestBase`. Style/Low under the Auto-Fix Gate matrix. Rebuild: 0 warnings, 0 errors.
|
||||
|
||||
## Stuck Agents: None
|
||||
|
||||
## Files Created (5) + 2 deletions + 1 modified script
|
||||
|
||||
### Helpers (2)
|
||||
|
||||
- `tests/Azaion.Missions.E2E.Tests/Helpers/LatencyPercentiles.cs` — nearest-rank P50/P95/Percentile/Mean over `IReadOnlyList<double>`. Sorts a defensive copy.
|
||||
- `tests/Azaion.Missions.E2E.Tests/Helpers/MetricCsvRecorder.cs` — appends one row per scenario (Timestamp, Category, Scenario, Result, Traces, ErrorMessage) to a CSV referenced by `PERF_RESULTS_FILE` (perf) or `RESLIM_RESULTS_FILE` (reslim). No-op when the env var is unset.
|
||||
|
||||
### Fixtures (1)
|
||||
|
||||
- `tests/Azaion.Missions.E2E.Tests/Fixtures/SteadyStateLoadFixture.cs` — class-scoped 5-minute sustained-load fixture. Generates ~50 RPS via a single-threaded `HttpClient` loop, samples RSS / Npgsql conn count / FD count every 5s. Exposes the time series + `LoadGeneratorMetTargetRps` + `SutExitedDuringWindow` + `SkipReason`. Tests inspect `SkipReason` to surface explicit skips when docker primitives are unavailable.
|
||||
|
||||
### Test classes (3)
|
||||
|
||||
- `tests/Azaion.Missions.E2E.Tests/Tests/ResourceLimits/SteadyStateLoadTests.cs` — NFT-RES-LIM-01..03 share the fixture window. Each test asserts one metric independently. `[Collection("ResLimSteadyState")]`.
|
||||
- `tests/Azaion.Missions.E2E.Tests/Tests/ResourceLimits/ColdStartRssTests.cs` — NFT-RES-LIM-04. Runs `docker compose stop|rm|up missions` for a fresh start, waits 30s after `/health` returns 200, reads RSS, asserts ≤ 200 MiB. Lives in the `MigratorRestart` collection to serialise with the other compose-restarting tests.
|
||||
- `tests/Azaion.Missions.E2E.Tests/Tests/Performance/PerformanceTests.cs` — NFT-PERF-01..04, all `[Trait("Category","Perf")]`. Sequential single-client, 5 warm-ups + N measured, records P50 + P95 to `PERF_RESULTS_FILE`.
|
||||
|
||||
### Deleted (2 Sanity placeholders)
|
||||
|
||||
- `tests/Azaion.Missions.E2E.Tests/Tests/Performance/Sanity.cs` — dead placeholder from AZ-576; replaced by `PerformanceTests`.
|
||||
- `tests/Azaion.Missions.E2E.Tests/Tests/ResourceLimits/Sanity.cs` — same.
|
||||
|
||||
### Modified (entrypoint filter, per AZ-586 Spec)
|
||||
|
||||
- `tests/Azaion.Missions.E2E.Tests/entrypoint.sh` — added `--filter "${TEST_FILTER:-Category!=Perf}"`. The default CI gate now excludes the Performance category (AZ-586 Spec § Outcome: "default test suite filter excludes performance to keep the standard CI gate ≤ 15 min"); `scripts/run-performance-tests.sh` bypasses the entrypoint anyway and invokes `dotnet test --filter Category=Perf` directly. The shell variable `TEST_FILTER` is overridable for ad-hoc invocations (e.g., to include Perf during a local profiling session).
|
||||
|
||||
## Local Verification
|
||||
|
||||
- `dotnet build tests/Azaion.Missions.E2E.Tests/Azaion.Missions.E2E.Tests.csproj` — 0 warnings, 0 errors.
|
||||
- 8 new NFT methods discoverable via `[Trait("Category","Perf")]` (4) and `[Trait("Category","ResLim")]` (4).
|
||||
|
||||
## Pre-existing issues NOT in scope
|
||||
|
||||
- `scripts/run-performance-tests.sh` line 104 references `/app/Azaion.Missions.E2E.Tests.csproj`, but the Dockerfile copies the test project to `/src/`. Pre-existing script bug — flag for the docker-CLI follow-up task that re-validates the run-perf script end-to-end. Not introduced by this batch.
|
||||
- Root `Azaion.Missions.csproj` Sdk.Web globs still pull `tests/**/*.cs` into the main project compilation — same flag as batch 3 cumulative review report; pre-existing.
|
||||
|
||||
## Docker Stack Validation
|
||||
|
||||
Not run as part of this batch — same hand-off as batches 1-3. Step 7 (`test-run/SKILL.md`) owns the `docker compose -f docker-compose.test.yml up --build --abort-on-container-exit e2e-consumer` gate. The 5 SkippableFact tests in this batch activate when the consumer image has `docker` CLI + socket bind; otherwise they emit explicit skip reasons (no silent pass).
|
||||
|
||||
## Tracker Updates
|
||||
|
||||
AZ-585, AZ-586 transitioned to `In Testing` via the Atlassian MCP after this commit (Step 12).
|
||||
|
||||
## Next Batch
|
||||
|
||||
All 11 test tasks (AZ-576 + AZ-577..AZ-586) are now done. Step 6 (Implement Tests) is **complete**. Autodev advances to Step 7 (Run Tests) — `test-run/SKILL.md` owns the full-suite gate.
|
||||
@@ -0,0 +1,90 @@
|
||||
# Cumulative Code Review — Batches 01–03 (cycle 1)
|
||||
|
||||
**Mode**: cumulative (every K=3 batches), test-implementation context
|
||||
**Date**: 2026-05-15
|
||||
**Scope**: union of files changed since the start of the test-implementation run (batches 1, 2, 3) — see "Scanned files" below
|
||||
**Verdict**: **PASS_WITH_WARNINGS** (0 Critical, 0 High, 0 Medium; 4 Low; 0 baseline-regressions)
|
||||
|
||||
## Scanned files
|
||||
|
||||
Every file touched during the cumulative window:
|
||||
|
||||
```
|
||||
.gitignore (B1)
|
||||
Auth/JwtExtensions.cs (B1 — only stop-watch noise; no functional change)
|
||||
docker-compose.test.yml (B1, B2 — fixtures volume + e2e-consumer wiring)
|
||||
Dockerfile (B1 — image tag for SUT)
|
||||
README.md (B1)
|
||||
tests/Azaion.Missions.E2E.Tests/* (B1, B2, B3 — full test project)
|
||||
tests/Azaion.Missions.JwksMock/* (B1, B3 — mock service; expanded /sign contract)
|
||||
_docs/02_tasks/ (lifecycle moves only — 11 tasks todo → done)
|
||||
_docs/03_implementation/batch_*_report.md (the 3 batch reports)
|
||||
```
|
||||
|
||||
**Files NOT in scope** (deliberate — not changed this cycle): every production source file under `Azaion.Missions.csproj`'s authoritative ownership (`Services/`, `Database/`, `Infrastructure/`, `Middleware/`, `Controllers/`, `Program.cs`). The test cycle is observation-only on production code.
|
||||
|
||||
## Phase coverage
|
||||
|
||||
| Phase | Status | Notes |
|
||||
|-------|--------|-------|
|
||||
| 1. Context loading | OK | Read every batch report + task spec for AZ-576..AZ-584 |
|
||||
| 2. Spec compliance | OK | 48 / 48 ACs across the 9 task specs have a directly-tracing test method (`[Trait("Traces", "AC-X.Y")]`) |
|
||||
| 3. Code quality | OK | All test methods follow Arrange / Act / Assert; no bare catch; no >50-line methods (largest: 50 lines in `ConfigDbStartupTests.NFT_RES_05_db_down`) |
|
||||
| 4. Security quick-scan | OK | All Npgsql calls parameterised; no hardcoded secrets; `ForeignKeypair` confined to test-only use |
|
||||
| 5. Performance scan | OK | 100-iteration TOCTOU race bounded; rotation tests use 90s polled deadlines (no unbounded waits) |
|
||||
| 6. Cross-task consistency | OK | See "Cross-batch consistency" section below |
|
||||
| 7. Architecture compliance | OK | See "Baseline Delta" — no new layering/Public-API violations introduced |
|
||||
|
||||
## Baseline Delta
|
||||
|
||||
Baseline at `_docs/02_document/architecture_compliance_baseline.md` (2026-05-14, verdict PASS_WITH_WARNINGS with 2 High already resolved via doc retag and 2 Low open).
|
||||
|
||||
| Carried over from baseline | Resolved this cycle | Newly introduced this cycle |
|
||||
|----------------------------|---------------------|-----------------------------|
|
||||
| F3 — dead `using Azaion.Flights.Enums;` in `Database/Entities/Flight.cs` (Low) | — | 0 |
|
||||
| F4 — three empty scaffolding directories at repo root (Low) | — | 0 |
|
||||
|
||||
**Why zero new architecture findings**: the cumulative window touched only `tests/` and `_docs/`. The production source tree (under `Azaion.Missions.csproj`) was not modified, so no new same-namespace imports, no new component boundaries crossed, no new layer-direction violations are possible.
|
||||
|
||||
## Cross-batch consistency (Phase 6)
|
||||
|
||||
The 22 NFT methods (B3) sit alongside the 26 FT methods (B2) and 1 sanity stub (B1). Verified shared patterns are followed across all three batches:
|
||||
|
||||
1. **`TestBase` inheritance** — every test class extends `TestBase` for the shared `HttpClient` + `TokenMinter` instances. No bespoke per-test HTTP-client construction (would risk DNS caching surprises against `missions:8080`).
|
||||
2. **Token minting** — every protected-endpoint call uses `Tokens.MintDefaultAsync()` (or `Tokens.MintAsync(SignRequest)` for non-default issuer/audience/permissions/alg/kid). The only test-only signing path that bypasses the mock is `ForeignKeypair.Mint(...)` in NFT-SEC-02 — explicitly scoped, called out in batch 3 report.
|
||||
3. **Side-channel assertions** — every DB-side check goes through `DbAssertions` or a direct Npgsql connection built from `TestEnvironment.DbSideChannel`. No test holds onto a long-lived connection across iterations.
|
||||
4. **HTTP assertions** — every status-code assertion goes through `HttpAssertions.AssertStatusAsync` or `HttpAssertions.AssertProblemEnvelopeAsync`. No raw `Assert.Equal((int)HttpStatusCode.X, ...)` in test bodies.
|
||||
5. **Docker-gated tests** — every Docker-dependent test is `SkippableFact` / `SkippableTheory` with an explicit `Skip.IfNot(...)` reason. No silent pass paths.
|
||||
6. **Traceability** — every test method carries `[Trait("Traces", "AC-X.Y")]` and `[Trait("max_ms", "<N>")]`. Tests with spec divergence carry `[Trait("carry_forward", "...")]` so `dotnet test --filter "carry_forward~..."` finds every flip-when-resolved site.
|
||||
7. **Fixtures and collections** — destructive fixtures (`DROP TABLE`, JWKS rotation, compose restart) live in dedicated xUnit collections (`CascadeF3`, `CascadeF4`, `ErrorEnvelope500`, `JwksRotation`, `MigratorRestart`) so they never overlap by accident. The `MigratorRestart` collection is shared by AZ-583 and AZ-584 (`MigratorRestartTests`, `ConfigDbStartupTests`) to serialize their docker-compose access.
|
||||
|
||||
## Duplicate-symbol scan
|
||||
|
||||
No test method names collide across files. NFT-SEC-* and NFT-RES-* prefixes are unique per scenario. Helper classes (`TokenMinter`, `ForeignKeypair`, `MissionsContainerHelper`, `DockerLogs`, `DbAssertions`, `HttpAssertions`, `FixtureSql`, `StubSchema`) are single-purpose with non-overlapping methods.
|
||||
|
||||
## Open Findings (Low, 4)
|
||||
|
||||
| # | Severity | Category | Location | Title | Suggestion |
|
||||
|---|----------|-----------------|----------------------------------------------------------------|------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| 1 | Low | Coverage | 7 SkippableFact/SkippableTheory methods across batch 3 | Docker-CLI-dependent tests skip in default e2e-consumer image | Follow-up task to add `docker-cli` + `/var/run/docker.sock` bind to the `e2e-consumer` service. Out of scope for test implementation. |
|
||||
| 2 | Low | Maintainability | `tests/.../Resilience/ConfigDbStartupTests.cs:DropAzaionDatabase` | Connection-string swap via `string.Replace("Database=azaion", ...)` | Replace with `NpgsqlConnectionStringBuilder` so the swap survives case/ordering changes in the canonical conn string. |
|
||||
| 3 | Low | Maintainability | Root `Azaion.Missions.csproj` (pre-existing project layout) | Sdk.Web globs pull `tests/**/*.cs` into the main project compilation | Add `<Compile Remove="tests/**" />` to `Azaion.Missions.csproj` OR introduce a `.sln` with explicit project list. **Pre-existing — NOT introduced by this cycle.** Confirmed via `git log -- Azaion.Missions.csproj`. |
|
||||
| 4 | Low | Maintainability | `Database/Entities/Flight.cs:2` | Dead `using Azaion.Flights.Enums;` directive (baseline-carried) | Resolve as part of the post-B6 cleanup — already tracked in the baseline report. **Carried from baseline, not new.** |
|
||||
|
||||
## Auto-Fix Gate decision
|
||||
|
||||
All 4 findings are Low/Maintainability/Coverage — no Critical, High, or Medium present. Per the implement-skill Auto-Fix Gate matrix:
|
||||
|
||||
- Findings #1 and #3 are **out of scope for the test-implementation cycle** (infrastructure / project file). They should be created as separate follow-up tasks rather than auto-fixed in this run.
|
||||
- Finding #2 is auto-fix-eligible but the test it lives in is `SkippableFact` (today skipping), so the swap fix has no observable behavioral consequence right now — recommend folding it into the docker-cli follow-up task.
|
||||
- Finding #4 is pre-existing (baseline-carried) and already tracked.
|
||||
|
||||
Per the cumulative-review gate: PASS_WITH_WARNINGS → continue to next batch (Step 14 loop).
|
||||
|
||||
## Recommendation
|
||||
|
||||
Proceed to Batch 4 (AZ-585 + AZ-586). After Batch 4 completes the cycle ends; Step 7 (`test-run/SKILL.md`) owns the full-suite gate and will surface the SkippableFact reasons live during the `docker compose ... up e2e-consumer` invocation.
|
||||
|
||||
## Sign-off
|
||||
|
||||
Cumulative review batches 01–03, cycle 1: **PASS_WITH_WARNINGS**. No blocking findings. Loop to Step 14 → Batch 4.
|
||||
@@ -0,0 +1,119 @@
|
||||
# Test Implementation Final Report
|
||||
|
||||
**Run**: existing-code Step 6 (Implement Tests)
|
||||
**Date**: 2026-05-15
|
||||
**Cycle**: 1
|
||||
**Verdict**: HANDOFF — full-suite gate owned by `.cursor/skills/test-run/SKILL.md` (Step 7)
|
||||
|
||||
## Scope
|
||||
|
||||
11 test tasks decomposed by `/decompose-tests` and tracked under epic **AZ-575**:
|
||||
|
||||
| Task | Description | SP | Batch |
|
||||
|---------|----------------------------------------------------------|----|-------|
|
||||
| AZ-576 | Test infrastructure (compose, csproj, mocks, helpers) | 5 | 1 |
|
||||
| AZ-577 | Vehicles positive (FT-P-01..06) | 5 | 2 |
|
||||
| AZ-578 | Missions positive (FT-P-07..12) | 5 | 2 |
|
||||
| AZ-579 | Waypoints + health positive (FT-P-13..18) | 5 | 2 |
|
||||
| AZ-580 | Validation + authz negative (FT-N-01..08) | 3 | 2 |
|
||||
| AZ-581 | Security auth/claims (NFT-SEC-01..06+04b) | 5 | 3 |
|
||||
| AZ-582 | Security alg/rotation/CORS (NFT-SEC-07..13) | 5 | 3 |
|
||||
| AZ-583 | Resilience cascade + migrator (NFT-RES-01..04) | 3 | 3 |
|
||||
| AZ-584 | Resilience config/DB/rotation/race (NFT-RES-05..08) | 5 | 3 |
|
||||
| AZ-585 | Resource limits (NFT-RES-LIM-01..04) | 3 | 4 |
|
||||
| AZ-586 | Performance (NFT-PERF-01..04) | 3 | 4 |
|
||||
| **Total** | | **47** | |
|
||||
|
||||
## Results
|
||||
|
||||
| Batch | Tasks | SP | Verdict | Carry-forwards |
|
||||
|-------|----------------------------------|----|----------------------|----------------|
|
||||
| 1 | AZ-576 | 5 | PASS_WITH_WARNINGS | 0 |
|
||||
| 2 | AZ-577..AZ-580 | 18 | PASS_WITH_WARNINGS | 3 |
|
||||
| 3 | AZ-581..AZ-584 | 18 | PASS_WITH_WARNINGS | 3 |
|
||||
| 4 | AZ-585, AZ-586 | 6 | PASS_WITH_WARNINGS | 0 |
|
||||
|
||||
**Cumulative reviews**: 1 (`cumulative_review_batches_01-03_cycle1_report.md`, PASS_WITH_WARNINGS, 4 Low findings).
|
||||
|
||||
## AC Test Coverage
|
||||
|
||||
| Source | ACs | Tests | Coverage |
|
||||
|--------|-----|-------|----------|
|
||||
| FT-P (functional positive) | 18 | 18 | 18/18 |
|
||||
| FT-N (negative) | 8 | 8 | 8/8 |
|
||||
| NFT-SEC (security) | 14 | 22 | 14/14 (some scenarios → multiple `Theory` rows) |
|
||||
| NFT-RES (resilience) | 8 | 12 | 8/8 |
|
||||
| NFT-RES-LIM (resource lim) | 4 | 4 | 4/4 |
|
||||
| NFT-PERF (performance) | 4 | 4 | 4/4 |
|
||||
| **Total** | **56** | **68** | **56/56** |
|
||||
|
||||
Every AC has at least one trace via `[Trait("Traces", "AC-X.Y")]`; structural carry-forwards (6 total) are pinned with `[Trait("carry_forward", "...")]` so `dotnet test --filter "carry_forward~..."` surfaces them as a set when the underlying spec/code reconciliation lands.
|
||||
|
||||
## Spec-vs-Code Carry-forwards (6 total)
|
||||
|
||||
| Site | Spec says | Code says | Carry-forward tag |
|
||||
|-----------------------------------------|----------------------------------------------|------------------------------------------------------------|-------------------------------|
|
||||
| FT-P-03 `Vehicles/PositiveTests.cs` | `POST /vehicles/{id}/setDefault` → 200 + body| `[HttpPatch("{id:guid}/default")]` → 204 NoContent | `AC-1.4/route-shape` |
|
||||
| FT-P-14/15 `Waypoints/PositiveTests.cs` | Nested `GeoPoint:{Lat,Lon,Mgrs}` | LinqToDB entity flat `Lat`/`Lon`/`Mgrs` | `flat-waypoint-shape` |
|
||||
| FT-N-07 `Waypoints/NegativeTests.cs` | Missing parent → 404 + problem envelope | `GetWaypoints` returns `[]` | `AC-4.2/missing-parent-soft` |
|
||||
| NFT-RES-01 `Resilience/CascadeF3Tests.cs` | Mid-walk cascade is transactional | `MissionService.DeleteMission` is non-transactional | `ADR-006` |
|
||||
| NFT-RES-02 `Resilience/CascadeF4Tests.cs` | Waypoint cascade leaves detection=0/waypoint=1 partial state | `WaypointService.DeleteWaypoint` queries `media` BEFORE any deletion — aborts at step 1 with nothing deleted | `AC-4.6/walk-order` |
|
||||
| NFT-RES-08 `Resilience/DefaultVehicleRaceTests.cs` | TOCTOU race observable | `ux_vehicles_one_default` partial unique index closes the race | `AC-1.4/index-closes-race` |
|
||||
|
||||
These carry-forwards flip the moment the spec or the code is reconciled; the tests fail loudly at that point — intentional.
|
||||
|
||||
## Code Review Summary
|
||||
|
||||
- **0 Critical / 0 High / 0 Medium** across all four batches.
|
||||
- **4 Low findings** captured in cumulative review (3 follow-up + 1 baseline-carried) — see `_docs/03_implementation/cumulative_review_batches_01-03_cycle1_report.md`.
|
||||
- Auto-fix rounds across the cycle: batch 2 (89× xUnit1030 warnings), batch 3 (3× missing-using errors), batch 4 (1× TokenMinter parameter-less ctor). All auto-fix-eligible per the Auto-Fix Gate matrix; no escalations.
|
||||
|
||||
## Files Added (high level)
|
||||
|
||||
- **Helpers** (10): `ApiDtos`, `DbAssertions`, `DockerLogs`, `FixtureSql`, `ForeignKeypair`, `HttpAssertions`, `LatencyPercentiles`, `MetricCsvRecorder`, `MissionsContainerHelper` — plus the existing `TestEnvironment`.
|
||||
- **Fixtures** (9): `CascadeF3Fixture`, `CascadeF4Fixture`, `ComposeRestartFixture`, `DbResetFixture`, `JwksMockReverseFixture` (spec-only stub), `JwksRotateFixture`, `PostgresStopStartFixture`, `Seeds`, `StubSchema`, `SteadyStateLoadFixture`.
|
||||
- **Test classes** (24): grouped under `Tests/{Vehicles,Missions,Waypoints,Health,Errors,Security,Resilience,Performance,ResourceLimits,Reporting}/` per the AZ-576 layout.
|
||||
- **Infrastructure**: `docker-compose.test.yml` extensions (fixtures volume), `entrypoint.sh` Category-filter, `Reporting/TrxToCsvPostProcessor.cs` (from batch 1).
|
||||
- **JWKS mock**: extended `SignBody` (permissions_array) + `TokenSigner` (kid_override validation) — required by NFT-SEC-06 and NFT-SEC-11.
|
||||
|
||||
## SkippableFact / SkippableTheory inventory
|
||||
|
||||
| Test | Skip predicate | Reason when skipped |
|
||||
|------------------------------------------------------------|------------------------------------------------------------------|----------------------|
|
||||
| `Tests/Health/HealthTests.NFT_P_17` (FT-P-17) | `COMPOSE_RESTART_ENABLED=1` | postgres-test stop/start |
|
||||
| `Tests/Errors/Error500Tests.NFT_N_08` | `COMPOSE_RESTART_ENABLED=1` | drops vehicles table |
|
||||
| `Tests/Security/ErrorRedactionTests.NFT_SEC_08` | `COMPOSE_RESTART_ENABLED=1` | drops vehicles table |
|
||||
| `Tests/Security/StartupConfigTests.NFT_SEC_12` (theory + HTTP-JWKS) | `MissionsContainerHelper.Enabled` | docker run primitives |
|
||||
| `Tests/Security/CorsConfigTests.NFT_SEC_13` (4 scenarios) | `MissionsContainerHelper.Enabled` | docker run primitives |
|
||||
| `Tests/Resilience/CascadeF3Tests.NFT_RES_01` | `COMPOSE_RESTART_ENABLED=1` | drops media table |
|
||||
| `Tests/Resilience/CascadeF4Tests.NFT_RES_02` | `COMPOSE_RESTART_ENABLED=1` | drops media table |
|
||||
| `Tests/Resilience/MigratorRestartTests.NFT_RES_03/04` | `ComposeRestartFixture.Enabled` | docker compose restart |
|
||||
| `Tests/Resilience/ConfigDbStartupTests.*` (8 methods) | `MissionsContainerHelper.Enabled` | docker run primitives |
|
||||
| `Tests/Resilience/JwksRotationNoRestartTests.NFT_RES_07` | `MissionsContainerHelper.Enabled` (for StartedAt read) | docker inspect |
|
||||
| `Tests/ResourceLimits/SteadyStateLoadTests.*` (3 methods) | `SteadyStateLoadFixture.SkipReason` (set on missing docker) | docker stats / docker exec |
|
||||
| `Tests/ResourceLimits/ColdStartRssTests.NFT_RES_LIM_04` | `COMPOSE_RESTART_ENABLED=1` + `MissionsContainerHelper.Enabled` | docker compose stop/start |
|
||||
|
||||
Every Skippable test surfaces an explicit reason; none silent-pass.
|
||||
|
||||
## Handoff to Step 7 (Run Tests)
|
||||
|
||||
This report is a **HANDOFF** — the full-suite gate is owned by `.cursor/skills/test-run/SKILL.md`. That skill is responsible for:
|
||||
|
||||
1. Building the docker compose stack (`docker compose -f docker-compose.test.yml --profile test build`).
|
||||
2. Running the e2e-consumer (`docker compose ... up --abort-on-container-exit --exit-code-from e2e-consumer e2e-consumer postgres-test missions jwks-mock`).
|
||||
3. Inspecting `test-results/report.csv` + the Skippable test reasons.
|
||||
4. Surfacing any blocking failure to the user via the test-run-skill's BLOCKING-gate protocol.
|
||||
5. Optionally enabling the Docker-CLI Skippable subset via a one-time consumer-image upgrade (`docker-cli` install + socket bind) before the next cycle.
|
||||
|
||||
The performance suite is intentionally NOT part of the default gate — it runs via `scripts/run-performance-tests.sh` only.
|
||||
|
||||
## Outstanding follow-ups (NOT blocking Step 7)
|
||||
|
||||
1. **Docker-CLI inside e2e-consumer image** — needed to activate the 12 Skippable methods. Recommend a separate ticket sized 3 SP (Dockerfile add of `docker-cli` package + `docker-compose.test.yml` `/var/run/docker.sock` mount). Validates run-perf script's `/app/` → `/src/` path bug at the same time.
|
||||
2. **Test/source compilation separation** — `Azaion.Missions.csproj` Sdk.Web globs pull `tests/**/*.cs`. Recommend `<Compile Remove="tests/**" />` or moving to a `.sln`. Pre-existing project layout drift.
|
||||
3. **AC-1.4 carry-forward decision** — see NFT-RES-08 carry-forward. The product team should decide whether the partial unique index OR an application-level guard is the canonical solution; today the test pins the index behaviour.
|
||||
4. **AC-4.6 walk-order decision** — see NFT-RES-02 carry-forward. The waypoint cascade walks dependency tables in a different order than the spec implied; the team should reconcile spec and code.
|
||||
|
||||
## Sign-off
|
||||
|
||||
Cycle 1 test implementation complete. 4 batches, 11 tasks, 47 SP. All ACs traced; no blocking findings; tracker tickets transitioned to **In Testing**. Autodev advances to Step 7 (Run Tests).
|
||||
@@ -0,0 +1,59 @@
|
||||
# FINAL Report — `02-baseline-cleanup`
|
||||
|
||||
**Date**: 2026-05-16
|
||||
**Mode**: automatic
|
||||
**Workflow**: quick-assessment (phases 0 → 2 only)
|
||||
**Epic**: [AZ-587](https://denyspopov.atlassian.net/browse/AZ-587)
|
||||
**Tasks**: [AZ-588](https://denyspopov.atlassian.net/browse/AZ-588) (1 SP)
|
||||
|
||||
## Why this was a quick-assessment run
|
||||
|
||||
The 2026-05-14 architecture-compliance baseline scan flagged 4 findings (F1–F4). By the time this refactor pass started:
|
||||
|
||||
- F1, F2 (High Architecture) — resolved 2026-05-14 by a doc retag in `_docs/02_document/module-layout.md`.
|
||||
- F3 (Low Maintainability) — resolved by the missions/vehicles rename; the file in question (`Flight.cs` → `Mission.cs`) no longer carries the dead `using`.
|
||||
- F4 (Low Maintainability) — partial: 2 of the 3 originally-empty scaffolding directories (`Entities/`, `DTOs/Requests/`) remain; `Infrastructure/` is now legitimately used.
|
||||
|
||||
That left **a single actionable change**: delete two empty directories. The user explicitly chose **B (quick-assessment, phases 0–2 only)** at the Phase 0 BLOCKING gate, then **E (no hardening tracks)** at the Phase 1 + 2b combined gate. Phases 3–7 (safety net, execution, test-sync, verification, documentation) are intentionally not run by this skill — the actual change lands through `/implement` in the Phase B feature cycle alongside any other Phase B work, picked up from the task ticket created here.
|
||||
|
||||
## Phases Executed
|
||||
|
||||
| Phase | Status | Output |
|
||||
|-------|--------|--------|
|
||||
| 0 — Baseline | Done | `baseline_metrics.md` |
|
||||
| 1 — Discovery | Done (1a + 1b skipped, 1c done, 1d done) | `discovery/logical_flow_analysis.md`, `list-of-changes.md` |
|
||||
| 2a — Deep Research | Done (no library replacement → no `context7` / MVE) | `analysis/research_findings.md` |
|
||||
| 2b — Hardening Tracks | Done | User chose E (None) |
|
||||
| 2c — Create Epic | Done | AZ-587 |
|
||||
| 2d — Task Decomposition | Done | AZ-588, `_docs/tasks/todo/AZ-588_refactor_remove_empty_scaffolding_dirs.md` |
|
||||
| 3 — Safety Net | Cancelled | Quick-assessment scope |
|
||||
| 4 — Execution | Cancelled | Quick-assessment scope |
|
||||
| 5 — Test Sync | Cancelled | Quick-assessment scope |
|
||||
| 6 — Verification | Cancelled | Quick-assessment scope |
|
||||
| 7 — Documentation | Cancelled | Quick-assessment scope |
|
||||
|
||||
## Baseline vs Final Metrics
|
||||
|
||||
Quick-assessment runs do not produce post-change metrics — Phase 6 (Verification) is the comparison step, and it is cancelled by definition. The baseline captured in `baseline_metrics.md` carries forward as the reference point for the next refactor run or for the implement skill when AZ-588 is picked up.
|
||||
|
||||
## Changes Summary
|
||||
|
||||
| ID | Status | Tracker | Description |
|
||||
|----|--------|---------|-------------|
|
||||
| C01 | Selected, decomposed, queued for `/implement` | AZ-588 | Remove `Entities/` and `DTOs/Requests/` |
|
||||
|
||||
## Remaining Items
|
||||
|
||||
Recorded for visibility in `list-of-changes.md` ("Out of Scope") — none of these are refactor work:
|
||||
|
||||
| Item | Where it belongs |
|
||||
|------|------------------|
|
||||
| Add `docker-cli` to e2e-consumer image (would unlock the 30 environment-skipped tests) | Phase B `New Task` (test-infrastructure improvement, not a refactor) |
|
||||
| Reconcile AC-1.4 carry-forward (NFT-RES-08) | Phase B `New Task` (product/spec decision) |
|
||||
| Reconcile AC-4.6 carry-forward (NFT-RES-02) | Phase B `New Task` (product/spec decision) |
|
||||
| Test/source compilation separation (`Compile Remove="tests/**"`) | Already landed in the prior `/test-run` cycle |
|
||||
|
||||
## Lessons Learned
|
||||
|
||||
- The architecture-baseline scan was 2 days old at the start of this refactor. By the time the run began, 3 of the 4 findings had already been resolved through other workflows (rename PRs and doc retags). For small projects on rapid cycles, a refactor pass should always re-validate baseline-scan findings against the current tree before committing to a full 8-phase workflow.
|
||||
- The skill's `Phase 1 → Skip condition (Targeted mode)` clause covers the case where docs already exist; quick-assessment + automatic mode benefits from the same skip when the only finding is structural cleanup with zero new code paths. Followed it pragmatically here; could be promoted to an explicit "structural-cleanup mode" in a future skill revision if this pattern recurs.
|
||||
@@ -0,0 +1,61 @@
|
||||
# Refactoring Roadmap — `02-baseline-cleanup`
|
||||
|
||||
**Date**: 2026-05-16
|
||||
**Mode**: automatic (quick-assessment, phases 0–2 only)
|
||||
**Hardening tracks selected**: E (None) — explicit user choice
|
||||
|
||||
## Weak Points Assessment
|
||||
|
||||
| Location | Description | Impact | Proposed Solution |
|
||||
|----------|-------------|--------|-------------------|
|
||||
| `Entities/` (empty dir at repo root) | Placeholder from pre-rename layout that was never used. Suggests an alternate entity tree that doesn't exist | Documentation drift; misleading to new readers | Remove the directory |
|
||||
| `DTOs/Requests/` (empty dir at repo root) | Placeholder from pre-rename layout that was never used. Suggests a "Requests" sub-grouping that doesn't exist; actual request DTOs live directly under `DTOs/*.cs` | Documentation drift; misleading to new readers | Remove the directory |
|
||||
|
||||
## Gap Analysis
|
||||
|
||||
| Acceptance criterion | Current state | Gap | Closed by this run? |
|
||||
|----------------------|---------------|-----|---------------------|
|
||||
| All AC and NFR coverage as of `implementation_report_tests.md` (56/56 ACs traced; 48/0/30 test outcome) | Met | None | N/A — already met before this run |
|
||||
| Architecture Vision § "layer-organized at repo root, ownership by file-path glob" | Mostly met; two placeholder directories carry no owner | Two empty directories don't fit any glob in `module-layout.md` | Yes |
|
||||
| Architecture-compliance baseline § F1, F2 (High Architecture) | Resolved 2026-05-14 by doc retag | None | N/A — already resolved |
|
||||
| Architecture-compliance baseline § F3 (Low Maintainability — dead `using`) | Resolved by rename | None | N/A — already resolved |
|
||||
| Architecture-compliance baseline § F4 (Low Maintainability — empty dirs) | Partial: `Infrastructure/` is now used; `Entities/` and `DTOs/Requests/` remain empty | 2 of 3 dirs still empty | Yes — by C01 |
|
||||
|
||||
## Phased Plan
|
||||
|
||||
### Phase 1 — Quick Wins (this run, single ticket)
|
||||
|
||||
| ID | Item | Constraint Fit | Status |
|
||||
|----|------|----------------|--------|
|
||||
| C01 | Remove `Entities/` and `DTOs/Requests/` from the repo | Strengthens Architecture Vision; no AC/restriction touched (verified by full reference scan) | **Selected** |
|
||||
|
||||
### Phase 2 — Major Improvements
|
||||
|
||||
None for this run. The baseline is small (37 files / 1,306 LOC), all tests green, no coupling/cycles/duplication detected.
|
||||
|
||||
### Phase 3 — Enhancements
|
||||
|
||||
None for this run. Items recorded as out-of-scope in `list-of-changes.md` ("Out of Scope (Recorded for Visibility)") are tracked for the Phase B feature cycle, not for this refactor pass:
|
||||
|
||||
- Add `docker-cli` to e2e-consumer image (would activate the 30 environment-skipped tests).
|
||||
- Reconcile AC-1.4 carry-forward (NFT-RES-08).
|
||||
- Reconcile AC-4.6 carry-forward (NFT-RES-02).
|
||||
|
||||
## Selected Hardening Tracks
|
||||
|
||||
**E — None.** User explicitly chose option E in the Phase 1 + 2b combined gate.
|
||||
|
||||
## Applicability Gate
|
||||
|
||||
| Item | Constraint fit | Mismatches | Required evidence | Status |
|
||||
|------|----------------|------------|-------------------|--------|
|
||||
| C01 | Strengthens Architecture Vision; pure `git rm -r`; zero `.cs` content | None | Reference scan complete (zero matches outside `_docs/`); test suite green pre-change | **Selected** |
|
||||
|
||||
All items are `Selected`. No `Rejected`, no `Experimental only`, no `Needs user decision`. The applicability gate passes.
|
||||
|
||||
## Tracker Plan
|
||||
|
||||
- **Epic**: AZ-XXX — `02-baseline-cleanup` (refactor run for residual baseline F4 cleanup)
|
||||
- **Task** (1): AZ-XXX — `refactor_remove_empty_scaffolding_dirs` (Task, 1 SP, no dependencies)
|
||||
|
||||
Tracker IDs assigned during Phase 2c/2d execution.
|
||||
@@ -0,0 +1,52 @@
|
||||
# Research Findings — `02-baseline-cleanup`
|
||||
|
||||
**Date**: 2026-05-16
|
||||
**Mode**: automatic (quick-assessment)
|
||||
**Scope**: residual baseline-scan F4 partial — two empty scaffolding directories at the repo root
|
||||
|
||||
## Project Constraint Matrix
|
||||
|
||||
Extracted from `_docs/00_problem/problem.md`, `_docs/02_document/architecture.md` (incl. `## Architecture Vision`), `_docs/02_document/module-layout.md`, and the .NET 10 / Sdk.Web build constraints.
|
||||
|
||||
| Constraint | Source | Impact on this run |
|
||||
|------------|--------|--------------------|
|
||||
| Source layout is layer-organized at repo root (no `src/`); component ownership is by file-path glob per `module-layout.md` | `architecture.md` § Architecture Vision | Removing two empty directories aligns layout with this principle (no component owns them) |
|
||||
| `Sdk.Web` recursive `**/*.cs` glob picks up everything not under `bin/`, `obj/`, or `tests/` (the latter excluded by `Compile Remove="tests/**"` in csproj) | `Azaion.Missions.csproj` | Empty directories contribute zero `.cs` files; removal is a pure no-op for the compile graph |
|
||||
| Test suite must pass after any structural change | `_docs/02_document/tests/environment.md`, autodev existing-code Step 7 gate | Verified pre-change baseline (48 pass / 0 fail / 30 env-skip on 2026-05-15 14:03); will re-run post-change |
|
||||
| Functional contracts (HTTP, DB schema, JWT) are preserved | `_docs/02_document/architecture.md` § 7, FT-P-* and NFT-SEC-* tests | No contract is touched; pure on-disk cleanup |
|
||||
|
||||
## Current State Analysis
|
||||
|
||||
The codebase has already converged on its target layout following the May 14 missions/vehicles rename:
|
||||
|
||||
- Entities live under `Database/Entities/*.cs` (6 files: Vehicle, Mission, Waypoint, MapObject, Annotation, Detection, Media).
|
||||
- Request DTOs live directly under `DTOs/*.cs` (Create/Update/Get… per resource).
|
||||
- Cross-cutting infrastructure lives under `Infrastructure/` (now populated with `ConfigurationResolver.cs` and `CorsConfigurationValidator.cs`).
|
||||
- Auth, middleware, controllers, services follow established `Auth/`, `Middleware/`, `Controllers/`, `Services/` directories.
|
||||
|
||||
**Strengths**: small (37 files / 1,306 LOC / avg 35 LOC per file), no cycles, no cross-component public-API bypass, all tests green, baseline scan was PASS_WITH_WARNINGS.
|
||||
**Weaknesses (this run's scope only)**: two empty placeholder directories (`Entities/`, `DTOs/Requests/`) survived the rename and now masquerade as alternate trees that don't exist. Misleading for new readers.
|
||||
|
||||
## Alternative Approaches Considered
|
||||
|
||||
No library / framework / SDK / service replacement is being proposed.
|
||||
**Per-mode API capability verification (`context7` / MVE) is therefore N/A** — the SKILL.md and Phase 2a both gate that requirement on "replaces (or adds) a library/SDK/framework/service". Pure directory removal does not.
|
||||
|
||||
| Option | Pros | Cons | Verdict |
|
||||
|--------|------|------|---------|
|
||||
| Remove the directories outright (`git rm -r`) | Simplest; aligns with Architecture Vision; zero risk (no `.cs` content) | None for the actual files | **Selected** |
|
||||
| Repurpose the directories with `.gitkeep` + a `README.md` explaining intent | Preserves the placeholder for future use | Speculative — no documented intent to use either path; the existing layout works | Rejected — speculative scaffolding violates "don't keep dead code" |
|
||||
| Move existing `Database/Entities/*` up to `Entities/` and reorganize | Could collapse two trees into one | Touches every entity file, every `using` directive, every test reference; risks the green test suite for cosmetic gain; contradicts the Architecture Vision principle that persistence owns its own subtree | Rejected — out of scope for a quick-assessment cleanup; would weaken constraint fit |
|
||||
|
||||
## Constraint-Fit Table
|
||||
|
||||
| Recommendation | Pinned mode/config | Constraints checked | API capability evidence (MVE) | Evidence | Mismatches/disqualifiers | Status |
|
||||
|----------------|--------------------|---------------------|-------------------------------|----------|--------------------------|--------|
|
||||
| C01 — Delete `Entities/` and `DTOs/Requests/` | N/A (no library; pure `git rm -r`) | Architecture Vision § layer-organized at repo root; csproj Sdk.Web glob; full test suite gate | N/A — no library; no MVE required per SKILL.md gate | `architecture_compliance_baseline.md` F4; `logical_flow_analysis.md` (zero references); `report.csv` 48/0/30 baseline | None | **Selected** |
|
||||
|
||||
## References
|
||||
|
||||
- `_docs/02_document/architecture_compliance_baseline.md` — F4 source.
|
||||
- `_docs/04_refactoring/02-baseline-cleanup/discovery/logical_flow_analysis.md` — flow-by-flow impact verification.
|
||||
- `_docs/02_document/architecture.md` § Architecture Vision — confirmed structural intent.
|
||||
- `_docs/03_implementation/implementation_report_tests.md` — baseline test outcomes (48 pass / 0 fail / 30 skip).
|
||||
@@ -0,0 +1,111 @@
|
||||
# Baseline Metrics — `02-baseline-cleanup`
|
||||
|
||||
**Date**: 2026-05-16
|
||||
**Mode**: automatic
|
||||
**Scope**: missions service production code (post-rename `Azaion.Missions.*`, net10.0)
|
||||
**Inputs**: `architecture_compliance_baseline.md` (2026-05-14 PASS_WITH_WARNINGS) + `implementation_report_tests.md` (Step 6 outcomes) + Step 7 test results (`test-results/report.csv`)
|
||||
|
||||
## Goals
|
||||
|
||||
Address the residual Maintainability findings the architecture-baseline scan surfaced, now that the missions/vehicles rename and the test cycle have landed.
|
||||
|
||||
| Source | Original finding | Status today |
|
||||
|--------|------------------|--------------|
|
||||
| F1 (High Architecture) | `Database/Entities/Aircraft.cs` imports feature-component enums | **Resolved 2026-05-14** by doc retag — enums re-owned by `04_persistence` |
|
||||
| F2 (High Architecture) | `Database/Entities/Waypoint.cs` imports feature-component enums | **Resolved 2026-05-14** by same doc retag |
|
||||
| F3 (Low Maintainability) | Dead `using Azaion.Flights.Enums;` in `Database/Entities/Flight.cs` | **Resolved by rename** — `Mission.cs` has no such using; verified |
|
||||
| F4 (Low Maintainability) | Three empty scaffolding dirs at repo root | **Partial**: `Infrastructure/` is now populated (2 files); `Entities/` and `DTOs/Requests/` remain empty |
|
||||
|
||||
**Net actionable scope for this run**: 2 empty directories (`Entities/`, `DTOs/Requests/`).
|
||||
|
||||
## Coverage
|
||||
|
||||
| Suite | Tests | Pass | Fail | Skip | Source |
|
||||
|-------|-------|------|------|------|--------|
|
||||
| E2E (functional + NFT) | 78 | 48 | 0 | 30 | `test-results/report.csv` (2026-05-15 14:03 UTC) |
|
||||
| Unit | 0 | – | – | – | No unit-test project today (`scripts/run-tests.sh --unit-only` is a no-op) |
|
||||
|
||||
All 30 skips are environment-mismatch (`COMPOSE_RESTART_ENABLED!=1` and/or `MissionsContainerHelper.Enabled=false` — the e2e-consumer image deliberately lacks docker-CLI primitives). Each carries an explicit `Skip` reason. AC trace coverage (per implementation report): 56/56 ACs traced.
|
||||
|
||||
Line coverage / branch coverage: not measured. The project does not configure `coverlet` or any other coverage collector. **N/A — out of scope for this run.**
|
||||
|
||||
## Complexity
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| Production `.cs` files (excl. `bin/`, `obj/`, `tests/`, `_docs/`) | 37 |
|
||||
| Production LOC (incl. blank lines & comments) | 1,306 |
|
||||
| Avg LOC per production file | 35.3 |
|
||||
| Largest 5 files (LOC) | `Services/VehicleService.cs` 134 · `Program.cs` 120 · `Database/DatabaseMigrator.cs` 119 · `Auth/JwtExtensions.cs` 112 · `Services/MissionService.cs` 107 |
|
||||
| Test LOC (excl. `bin/`, `obj/`) | 6,511 |
|
||||
|
||||
Cyclomatic complexity: not measured. No Roslyn analyzer (`dotnet format analyzers`, `Roslynator`, `SonarAnalyzer.CSharp`) is configured. **N/A — measurement infrastructure absent; out of scope.**
|
||||
|
||||
Note on size: 1,306 LOC across 37 files (avg 35 LOC/file, max 134) is well within the simplicity envelope this codebase aims for. There are no hot files calling out for decomposition.
|
||||
|
||||
## Code Smells
|
||||
|
||||
From `architecture_compliance_baseline.md` only (no static analyzer configured):
|
||||
|
||||
| Severity | Count | Open today |
|
||||
|----------|-------|------------|
|
||||
| Critical | 0 | 0 |
|
||||
| High (Architecture) | 2 (F1, F2) | 0 — resolved 2026-05-14 |
|
||||
| Low (Maintainability) | 2 (F3, F4) | 1 partial (F4: 2 of 3 empty dirs remain); F3 resolved by rename |
|
||||
|
||||
## Performance
|
||||
|
||||
Per `test-results/report.csv` 2026-05-15 14:03, the 4 NFT-PERF tests (`PerformanceTests.NFT_PERF_01..04`) all passed against thresholds defined in `_docs/02_document/tests/performance-tests.md`. Per-scenario p50/p95/p99 captured by the test harness.
|
||||
|
||||
This refactor run does not target performance — **N/A as a baseline-vs-final gate.**
|
||||
|
||||
## Dependencies
|
||||
|
||||
`Azaion.Missions.csproj` (Sdk.Web, net10.0):
|
||||
|
||||
| Package | Version |
|
||||
|---------|---------|
|
||||
| linq2db | 6.2.0 |
|
||||
| Microsoft.AspNetCore.Authentication.JwtBearer | 10.0.5 |
|
||||
| Npgsql | 10.0.2 |
|
||||
| Swashbuckle.AspNetCore | 10.1.5 |
|
||||
|
||||
Outdated / vulnerable: not measured (would require `dotnet list package --outdated --vulnerable` against a configured NuGet source). Out of scope for this run.
|
||||
|
||||
## Build
|
||||
|
||||
| Metric | Value | Source |
|
||||
|--------|-------|--------|
|
||||
| Test suite wall-clock (last successful run) | ~ minutes (Docker compose up + 78 tests) | `test-results/results.trx` mtime 2026-05-15 14:03 |
|
||||
| Docker build (cold, prior failed run) | ~42 min ended in CS0246 | terminal log `451778.txt` |
|
||||
| Docker build (after csproj `Compile Remove="tests/**"` fix) | known-good per prior session | implicit from the green `report.csv` |
|
||||
|
||||
## Functionality Inventory
|
||||
|
||||
Components and ownership (from `_docs/02_document/module-layout.md` § Per-Component Mapping, post-rename):
|
||||
|
||||
| # | Component | Owns | Routes | Tests |
|
||||
|---|-----------|------|--------|-------|
|
||||
| 01 | vehicle_catalog | `DTOs/*Vehicle*`, `Database/Entities/Vehicle.cs`, `Enums/{VehicleType,FuelType}.cs`, `Controllers/VehiclesController.cs`, `Services/VehicleService.cs` | `/vehicles`, `/vehicles/{id}/default` | FT-P-01..06, FT-N-01..04, NFT-RES-08 |
|
||||
| 02 | mission_planning | `DTOs/*Mission*`, `Database/Entities/Mission.cs`, `Controllers/MissionsController.cs`, `Services/MissionService.cs` | `/missions` | FT-P-07..12, FT-N-05..06, NFT-RES-01 |
|
||||
| 04 | persistence | `Database/{AppDataConnection,DatabaseMigrator}.cs`, all `Database/Entities/*.cs` (excl. domain), `Enums/{ObjectStatus,WaypointSource,WaypointObjective}.cs` | – | NFT-RES-03..04 |
|
||||
| 05 | authentication | `Auth/JwtExtensions.cs`, JWT-bearer config in `Program.cs` | – | NFT-SEC-* (14 ACs) |
|
||||
| 06 | infrastructure | `Infrastructure/{ConfigurationResolver,CorsConfigurationValidator}.cs`, `Middleware/ErrorHandlingMiddleware.cs`, `Program.cs` composition | `/health`, `/swagger` | FT-P-13..18, NFT-SEC-13 (CORS), NFT-RES-05..07 |
|
||||
|
||||
Empty scaffolding directories (no component owns them): `Entities/`, `DTOs/Requests/`.
|
||||
|
||||
## Self-verification
|
||||
|
||||
- [x] RUN_DIR created with correct auto-incremented prefix (`02-baseline-cleanup`)
|
||||
- [x] Coverage measured (E2E only; unit + line coverage marked N/A with reason)
|
||||
- [x] Complexity measured (file count, LOC, top-5; cyclomatic marked N/A with reason)
|
||||
- [x] Code smells measured (from baseline scan; static analyzer N/A)
|
||||
- [x] Performance noted (perf tests green; not a baseline-vs-final gate)
|
||||
- [x] Dependencies enumerated (outdated/vulnerable scan N/A)
|
||||
- [x] Build noted (test wall-clock + Docker build status)
|
||||
- [x] Functionality inventory complete (6 components + 2 empty dirs)
|
||||
- [x] Measurements reproducible (commands inline in this file or sourced from named artifacts)
|
||||
|
||||
## Scope warning for the user (BLOCKING)
|
||||
|
||||
The actionable surface is **two empty directories**. Everything else the original baseline scan flagged is already resolved (F1/F2 by doc retag, F3 by rename, F4 partial). Running the full 8-phase refactor for this is heavyweight; quick-assessment (phases 0–2 only) is plausible. See the BLOCKING choice block presented to the user.
|
||||
@@ -0,0 +1,48 @@
|
||||
# Logical Flow Analysis — `02-baseline-cleanup`
|
||||
|
||||
**Date**: 2026-05-16
|
||||
**Mode**: automatic (quick-assessment)
|
||||
**Scope**: residual baseline-scan findings (F4 partial: empty scaffolding directories)
|
||||
|
||||
## Inputs Reviewed
|
||||
|
||||
| Source | Notes |
|
||||
|--------|-------|
|
||||
| `_docs/02_document/system-flows.md` | 273 lines — all documented flows verified against current code by the test suite (78 E2E tests, 48 pass / 30 env-skip / 0 fail) |
|
||||
| `_docs/02_document/architecture.md` (incl. `## Architecture Vision`) | 369 lines — Vision section is user-confirmed; layering rules apply |
|
||||
| `_docs/02_document/module-layout.md` | Per-component file ownership, post-rename |
|
||||
| `_docs/02_document/glossary.md` | Confirmed terminology |
|
||||
| `_docs/02_document/architecture_compliance_baseline.md` | Source of F1–F4 |
|
||||
| `_docs/03_implementation/implementation_report_tests.md` | Step 6 outcomes + 4 carry-forward tags |
|
||||
|
||||
## Components Documentation Reuse Note
|
||||
|
||||
Phase 1 sub-steps **1a (Document Components)** and **1b (Synthesize Solution & Flows)** are intentionally skipped for this run. The `/document` skill produced complete, current per-component documentation in `_docs/02_document/components/` and the synthesis files (`solution.md`, `system-flows.md`) on 2026-05-14. Re-generating them for a structural cleanup with no new code paths would produce identical output and burn the user's context budget. The user-confirmed quick-assessment choice (B) authorizes this skip.
|
||||
|
||||
## Flow-by-Flow Scan
|
||||
|
||||
For each system flow documented in `system-flows.md`, the question asked is: **does removing `Entities/` (empty) or `DTOs/Requests/` (empty) silently affect this flow?**
|
||||
|
||||
| Flow | Touches `Entities/` ? | Touches `DTOs/Requests/` ? | Verdict |
|
||||
|------|----------------------|----------------------------|---------|
|
||||
| Vehicle CRUD (FT-P-01..06) | No — uses `DTOs/CreateVehicleRequest.cs`, `DTOs/UpdateVehicleRequest.cs`, `Database/Entities/Vehicle.cs` | No | Unaffected |
|
||||
| Mission CRUD (FT-P-07..12) | No — uses `DTOs/CreateMissionRequest.cs`, `DTOs/UpdateMissionRequest.cs`, `Database/Entities/Mission.cs` | No | Unaffected |
|
||||
| Waypoint CRUD (FT-P-13..18) | No — uses `DTOs/CreateWaypointRequest.cs`, `DTOs/UpdateWaypointRequest.cs`, `Database/Entities/Waypoint.cs` | No | Unaffected |
|
||||
| `/health` + startup composition | No — `Program.cs` + `Infrastructure/*` | No | Unaffected |
|
||||
| JWT auth (NFT-SEC-01..14) | No — `Auth/JwtExtensions.cs` + `Program.cs` | No | Unaffected |
|
||||
| Cascade deletes (NFT-RES-01..02) | No — `Database/Entities/*` (all under `Database/Entities/`, not the empty `Entities/`) | No | Unaffected |
|
||||
| Migrator (NFT-RES-03..04) | No — `Database/DatabaseMigrator.cs` | No | Unaffected |
|
||||
|
||||
**Reference scan**: searched the entire workspace (excluding `_docs/`) for any path-based reference to `Entities/` or `DTOs/Requests/`. Zero matches.
|
||||
|
||||
## Findings
|
||||
|
||||
| # | Type | Severity | Location | Notes |
|
||||
|---|------|----------|----------|-------|
|
||||
| L01 | Documentation drift | Low | `Entities/`, `DTOs/Requests/` | Two empty directories at the repo root. Originally created as scaffolding placeholders before the actual layout solidified under `Database/Entities/` and `DTOs/`. Carry no source today, no path-based references anywhere. Misleading for new readers (suggests two parallel persistence/DTO trees that don't exist). |
|
||||
|
||||
No logic bugs, no performance waste, no design contradictions, no silent data loss were discovered for this scope.
|
||||
|
||||
## Architecture Vision compatibility
|
||||
|
||||
`architecture.md` § Architecture Vision specifies the persistence component owns `Database/Entities/*` and the request DTO surface lives directly under `DTOs/`. The two empty directories are not part of the Vision — removing them strengthens, not weakens, alignment with the user-confirmed structural intent. No `Architecture Vision` principle is contradicted.
|
||||
@@ -0,0 +1,37 @@
|
||||
# List of Changes
|
||||
|
||||
**Run**: 02-baseline-cleanup
|
||||
**Mode**: automatic (quick-assessment)
|
||||
**Source**: self-discovered (architecture_compliance_baseline.md F4)
|
||||
**Date**: 2026-05-16
|
||||
|
||||
## Summary
|
||||
|
||||
Remove the two residual empty scaffolding directories at the repo root that the 2026-05-14 architecture-baseline scan flagged under F4. Originally placeholders for an early layout that solidified elsewhere (`Database/Entities/`, `DTOs/`). They carry no source files and no path-based references in the codebase.
|
||||
|
||||
## Changes
|
||||
|
||||
### C01: Delete unused scaffolding directories `Entities/` and `DTOs/Requests/`
|
||||
|
||||
- **File(s)**: `Entities/` (directory, 0 files), `DTOs/Requests/` (directory, 0 files)
|
||||
- **Problem**: Both directories exist under the repo root but contain no source. They were created as scaffolding placeholders before the actual layout settled under `Database/Entities/*` (entities) and `DTOs/*.cs` (request shapes). They are misleading to new readers (suggesting two parallel persistence/DTO trees that don't exist) and create noise in the post-rename architecture-compliance baseline (F4).
|
||||
- **Change**: Remove both directories from the repository (`git rm -r Entities/ DTOs/Requests/`). Verify the repo builds (`dotnet build`) and the test suite still passes (`scripts/run-tests.sh`).
|
||||
- **Rationale**: Dead-folder removal aligns the on-disk layout with the user-confirmed Architecture Vision (`architecture.md` § Architecture Vision: persistence owns `Database/Entities/*`; request DTOs live directly under `DTOs/`). Closes the only remaining open item from the architecture-baseline scan.
|
||||
- **Constraint Fit**:
|
||||
- `architecture.md` § Architecture Vision — strengthens, does not violate.
|
||||
- `acceptance_criteria.md` — no functional or NFR criterion references either path; verified by full-suite reference scan (zero matches outside `_docs/`).
|
||||
- `restrictions.md` — N/A; restrictions cover behavior, not directory layout.
|
||||
- `module-layout.md` — neither directory is owned by any component (verified).
|
||||
- **Risk**: low — directories are empty; no path-based reference outside `_docs/`; the .NET SDK glob picks up `*.cs` recursively but neither directory contains any.
|
||||
- **Dependencies**: None.
|
||||
|
||||
## Out of Scope (Recorded for Visibility)
|
||||
|
||||
These were considered but explicitly excluded from this run; they belong in the Phase B feature cycle, not in a refactor pass:
|
||||
|
||||
| Item | Source | Reason for exclusion |
|
||||
|------|--------|----------------------|
|
||||
| Add `docker-cli` to e2e-consumer image (would activate 30 skipped tests) | `implementation_report_tests.md` follow-up #1 | Infrastructure addition (test image), not a code refactor; better as a New Task in Phase B |
|
||||
| Reconcile AC-1.4 carry-forward (NFT-RES-08) | `implementation_report_tests.md` follow-up #3 | Product/spec decision required, not a code refactor |
|
||||
| Reconcile AC-4.6 carry-forward (NFT-RES-02) | `implementation_report_tests.md` follow-up #4 | Product/spec decision required, not a code refactor |
|
||||
| Test/source compilation separation (`Compile Remove="tests/**"`) | `implementation_report_tests.md` follow-up #2 | Already addressed (csproj fix landed in the prior /test-run cycle) |
|
||||
@@ -2,16 +2,16 @@
|
||||
|
||||
## Current Step
|
||||
flow: existing-code
|
||||
step: 6
|
||||
name: Implement Tests
|
||||
step: 9
|
||||
name: New Task
|
||||
status: in_progress
|
||||
sub_step:
|
||||
phase: 14
|
||||
name: batch-loop
|
||||
detail: "batch 1 done (AZ-576); next: AZ-577..AZ-586"
|
||||
phase: 1
|
||||
name: gather-feature-description
|
||||
detail: ""
|
||||
retry_count: 0
|
||||
cycle: 1
|
||||
tracker: jira
|
||||
|
||||
## Last Updated
|
||||
2026-05-15
|
||||
2026-05-16
|
||||
|
||||
@@ -69,3 +69,25 @@ Recommended dependency-aware batches for `/implement`:
|
||||
2. **Batch 2 (parallel, fan-out from AZ-576)**: AZ-577..AZ-586 in any order. Independent test classes within a single xUnit assembly; no inter-task ordering needed.
|
||||
|
||||
CSV report sorting at suite end: by `Category` (Blackbox / Sec / Res / ResLim / Perf), then by test ID within category.
|
||||
|
||||
---
|
||||
|
||||
## Refactor: `02-baseline-cleanup` (2026-05-16)
|
||||
|
||||
**Run**: `_docs/04_refactoring/02-baseline-cleanup/` (quick-assessment, phases 0–2)
|
||||
**Epic**: AZ-587 — Refactor 02-baseline-cleanup: remove residual empty scaffolding dirs
|
||||
**Total Tasks**: 1
|
||||
**Total Complexity Points**: 1
|
||||
|
||||
| Task | Name | Complexity | Dependencies | Epic |
|
||||
|------|------|-----------|-------------|------|
|
||||
| AZ-588 | refactor_remove_empty_scaffolding_dirs | 1 | None | AZ-587 |
|
||||
|
||||
### Cross-Task Consistency Checks
|
||||
|
||||
| Check | Result |
|
||||
|-------|--------|
|
||||
| Every change in `02-baseline-cleanup/list-of-changes.md` has a corresponding task | PASS — C01 → AZ-588 |
|
||||
| No task exceeds 5 complexity points | PASS |
|
||||
| No circular dependencies | PASS — single task, no dependencies |
|
||||
| All tasks linked to the run's epic | PASS — AZ-588 → AZ-587 |
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
# Refactor 02-baseline-cleanup C01 — Remove empty scaffolding dirs
|
||||
|
||||
**Task**: AZ-588_refactor_remove_empty_scaffolding_dirs
|
||||
**Name**: Remove empty scaffolding dirs `Entities/` and `DTOs/Requests/`
|
||||
**Description**: Delete the two empty placeholder directories at the repo root that survived the May 14 missions/vehicles rename. Closes the only remaining open item from the architecture-compliance baseline scan (F4 partial).
|
||||
**Complexity**: 1 point
|
||||
**Dependencies**: None
|
||||
**Component**: refactor — `02-baseline-cleanup`
|
||||
**Tracker**: [AZ-588](https://denyspopov.atlassian.net/browse/AZ-588)
|
||||
**Epic**: [AZ-587](https://denyspopov.atlassian.net/browse/AZ-587)
|
||||
|
||||
## Problem
|
||||
|
||||
Two empty scaffolding directories at the repo root survive from the pre-rename layout. Neither is owned by any component per `_docs/02_document/module-layout.md`. They suggest alternate persistence/DTO trees that don't exist.
|
||||
|
||||
- `Database/Entities/*.cs` is the actual entity location.
|
||||
- `DTOs/*.cs` (flat, no `Requests/` sub-grouping) is the actual request DTO location.
|
||||
|
||||
Recorded as F4 (Low Maintainability, partial) in `_docs/02_document/architecture_compliance_baseline.md`. The third originally-empty dir (`Infrastructure/`) is now legitimately used.
|
||||
|
||||
## Outcome
|
||||
|
||||
- `Entities/` no longer present in the repository.
|
||||
- `DTOs/Requests/` no longer present in the repository.
|
||||
- `dotnet build` still succeeds.
|
||||
- `scripts/run-tests.sh` returns the same baseline (48 pass / 0 fail / 30 env-skip).
|
||||
|
||||
## Scope
|
||||
|
||||
### Included
|
||||
- `git rm -r Entities/`
|
||||
- `git rm -r DTOs/Requests/`
|
||||
- Verify build + test suite.
|
||||
|
||||
### Excluded
|
||||
- Any reorganization of existing entities or DTOs.
|
||||
- Any change to `Infrastructure/` (now in use).
|
||||
- Any rename / namespace change.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
**AC-1: Directories removed**
|
||||
Given the repository at HEAD
|
||||
When `git ls-tree -r HEAD -- Entities/ DTOs/Requests/` is run
|
||||
Then the output is empty.
|
||||
|
||||
**AC-2: Build still passes**
|
||||
Given the repository after the change
|
||||
When `dotnet build` is run from the repo root
|
||||
Then it exits 0.
|
||||
|
||||
**AC-3: Test suite still green**
|
||||
Given the repository after the change
|
||||
When `scripts/run-tests.sh` is run
|
||||
Then `test-results/report.csv` shows 48 pass / 0 fail / 30 skip (same skips, no new failures).
|
||||
|
||||
## Non-Functional Requirements
|
||||
|
||||
None — this is a structural cleanup with no behavior change.
|
||||
|
||||
## Blackbox Tests
|
||||
|
||||
| AC Ref | Initial Data/Conditions | What to Test | Expected Behavior | NFR References |
|
||||
|--------|-------------------------|--------------|-------------------|----------------|
|
||||
| AC-3 | Repo state after `git rm -r Entities/ DTOs/Requests/` | Full E2E suite via `scripts/run-tests.sh` | Same outcome as the 2026-05-15 14:03 baseline (48/0/30) | none |
|
||||
|
||||
## Constraints
|
||||
|
||||
- Architecture Vision (`_docs/02_document/architecture.md`) — strengthens, does not violate.
|
||||
- No `.cs` content moves; pure directory removal.
|
||||
- Reference scan confirmed zero path-based references outside `_docs/` (see `_docs/04_refactoring/02-baseline-cleanup/discovery/logical_flow_analysis.md`).
|
||||
|
||||
## Risks & Mitigation
|
||||
|
||||
**Risk 1: Hidden reference**
|
||||
- *Risk*: A path-based reference exists somewhere not caught by the initial grep (e.g., a CI script, an editor config, an IDE workspace file).
|
||||
- *Mitigation*: Pre-execution `rg -F 'Entities/' -F 'DTOs/Requests/'` repo-wide. Post-execution `dotnet build` + `scripts/run-tests.sh` are the regression nets.
|
||||
+29
-5
@@ -21,6 +21,13 @@ services:
|
||||
POSTGRES_DB: azaion
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres-test
|
||||
## FT-N-06 (AC-3.2 cascade short-circuit) inspects pg_stat_statements
|
||||
## to assert that DELETE statements against dependency tables are never
|
||||
## issued for a 404. The extension must be preloaded at server start;
|
||||
## CREATE EXTENSION alone is not enough. Production deployments would
|
||||
## leave shared_preload_libraries unset by default — this knob lives in
|
||||
## the test-only compose file.
|
||||
command: ["postgres", "-c", "shared_preload_libraries=pg_stat_statements"]
|
||||
ports:
|
||||
- "5433:5432"
|
||||
healthcheck:
|
||||
@@ -75,11 +82,24 @@ services:
|
||||
JWT_ISSUER: https://admin-test.azaion.local
|
||||
JWT_AUDIENCE: azaion-edge
|
||||
JWT_JWKS_URL: https://jwks-mock:8443/.well-known/jwks.json
|
||||
## Shorten the JWKS cache so NFT-RES-07 + NFT-SEC-11 can observe rotation
|
||||
## within the 15-minute CI wall-clock budget. Production leaves both
|
||||
## unset and inherits the library defaults (12h / 5min).
|
||||
JWT_JWKS_AUTO_REFRESH_INTERVAL_SECONDS: "30"
|
||||
JWT_JWKS_REFRESH_INTERVAL_SECONDS: "10"
|
||||
## Shorten the JWKS refresh throttle to the library minimum (1s) so
|
||||
## the test-only /test/refresh-jwks endpoint can refresh on back-to-
|
||||
## back rotation tests. ConfigurationManager.RequestRefresh() is
|
||||
## itself throttled: after the very first call, subsequent calls are
|
||||
## a no-op until (now - _lastRefresh) >= RefreshInterval. With 10s
|
||||
## throttle, two rotation tests running ~300ms apart could not both
|
||||
## force a refresh and the second one's cache would stay stale,
|
||||
## poisoning every test downstream of it. 1s leaves the rotation
|
||||
## tests pinned to their own grace-window timing (5s+) without
|
||||
## introducing artificial delays.
|
||||
##
|
||||
## JWT_JWKS_AUTO_REFRESH_INTERVAL_SECONDS is intentionally NOT set:
|
||||
## Microsoft.IdentityModel.Tokens.BaseConfigurationManager pins the
|
||||
## floor to a static 5-minute MinimumAutomaticRefreshInterval, so
|
||||
## any value below 300 throws at startup. The 12h default is fine for
|
||||
## tests because rotation observation depends on RefreshInterval +
|
||||
## /test/refresh-jwks, not the proactive auto-refresh path.
|
||||
JWT_JWKS_REFRESH_INTERVAL_SECONDS: "1"
|
||||
ASPNETCORE_URLS: http://+:8080
|
||||
ASPNETCORE_ENVIRONMENT: Test
|
||||
## CORS: Test environment (NOT Production) -- empty allow-list falls back
|
||||
@@ -125,6 +145,9 @@ services:
|
||||
JWKS_MOCK_SIGN_URL: https://jwks-mock:8443/sign
|
||||
JWT_ISSUER: https://admin-test.azaion.local
|
||||
JWT_AUDIENCE: azaion-edge
|
||||
## Fixtures consumed by FixtureSql.Load (cascade_F3 / F4 in batch 2,
|
||||
## NFT-* fixtures in subsequent batches). Mounted read-only below.
|
||||
FIXTURE_SQL_DIR: /app/fixtures
|
||||
depends_on:
|
||||
missions:
|
||||
condition: service_healthy
|
||||
@@ -133,6 +156,7 @@ services:
|
||||
volumes:
|
||||
- ./test-results:/app/results
|
||||
- ./tests/jwks-mock-ca.crt:/usr/local/share/ca-certificates/jwks-mock-ca.crt:ro
|
||||
- ./_docs/00_problem/input_data/expected_results:/app/fixtures:ro
|
||||
networks:
|
||||
- e2e-net
|
||||
profiles:
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
// JWKS rotation, JWKS refresh, and DbResetFixture all mutate process-wide
|
||||
// state on the shared `missions-sut` container (the JWKS cache, the database,
|
||||
// the CORS warm-up flag, etc.). xUnit runs different [Collection(...)] groups
|
||||
// in parallel by default, which races those mutations against any test that
|
||||
// happens to mint a token or query a row at the same moment. The whole e2e
|
||||
// surface is one System-Under-Test; serializing the collections is the only
|
||||
// way to make assertions deterministic.
|
||||
//
|
||||
// We still keep [Collection(...)] attributes per class — they continue to
|
||||
// enforce intra-collection ordering and let xUnit fail fast if two tests in
|
||||
// the same fixture race. DisableTestParallelization=true switches the
|
||||
// across-collection scheduling off; intra-collection serialization is the
|
||||
// default and still applies.
|
||||
[assembly: Xunit.CollectionBehavior(DisableTestParallelization = true)]
|
||||
@@ -0,0 +1,34 @@
|
||||
using Azaion.Missions.E2E.Helpers;
|
||||
|
||||
namespace Azaion.Missions.E2E.Fixtures;
|
||||
|
||||
/// <summary>
|
||||
/// Loads <c>fixture_cascade_F3.sql</c> into a freshly-reset DB. The fixture
|
||||
/// builds a full mission cascade chain (1 mission → 2 waypoints → 2 media →
|
||||
/// 2 annotations → 2 detection rows + 3 map_objects) so a single
|
||||
/// <c>DELETE /missions/{id}</c> exercises every dependency table.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The borrowed-schema tables (media, annotations, detection) must exist
|
||||
/// before the SQL runs — see <see cref="StubSchema"/>. The fixture is
|
||||
/// deliberately destructive (TRUNCATE … CASCADE in the reset step) so it
|
||||
/// must NOT share state with read-path scenarios; tests using it should
|
||||
/// live in their own xUnit collection.
|
||||
/// </remarks>
|
||||
public sealed class CascadeF3Fixture : IDisposable
|
||||
{
|
||||
public static readonly Guid VehicleId =
|
||||
Guid.Parse("11111111-0000-0000-0000-000000000001");
|
||||
|
||||
public static readonly Guid MissionId =
|
||||
Guid.Parse("22222222-0000-0000-0000-000000000001");
|
||||
|
||||
public CascadeF3Fixture()
|
||||
{
|
||||
DbResetFixture.ResetDatabase(TestEnvironment.DbSideChannel);
|
||||
StubSchema.EnsureCreated();
|
||||
Seeds.Apply(FixtureSql.Load("fixture_cascade_F3"));
|
||||
}
|
||||
|
||||
public void Dispose() { /* Next fixture's reset cleans up. */ }
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using Azaion.Missions.E2E.Helpers;
|
||||
|
||||
namespace Azaion.Missions.E2E.Fixtures;
|
||||
|
||||
/// <summary>
|
||||
/// Loads <c>fixture_cascade_F4.sql</c> — the scoped waypoint cascade fixture.
|
||||
/// One mission with TWO waypoints, each carrying its own media/annotation/detection
|
||||
/// chain. FT-P-18 deletes the target waypoint and asserts the SIBLING
|
||||
/// waypoint's chain remains intact.
|
||||
/// </summary>
|
||||
public sealed class CascadeF4Fixture : IDisposable
|
||||
{
|
||||
public static readonly Guid VehicleId =
|
||||
Guid.Parse("11111111-0000-0000-0000-000000000004");
|
||||
|
||||
public static readonly Guid MissionId =
|
||||
Guid.Parse("22222222-0000-0000-0000-000000000004");
|
||||
|
||||
public static readonly Guid TargetWaypointId =
|
||||
Guid.Parse("33333333-0000-0000-0000-00000000F4A1");
|
||||
|
||||
public static readonly Guid SiblingWaypointId =
|
||||
Guid.Parse("33333333-0000-0000-0000-00000000F4B2");
|
||||
|
||||
public const string TargetMediaId = "media-F4-target-001";
|
||||
public const string SiblingMediaId = "media-F4-sibling-002";
|
||||
public const string TargetAnnotationId = "anno-F4-target-001";
|
||||
public const string SiblingAnnotationId = "anno-F4-sibling-002";
|
||||
|
||||
public CascadeF4Fixture()
|
||||
{
|
||||
DbResetFixture.ResetDatabase(TestEnvironment.DbSideChannel);
|
||||
StubSchema.EnsureCreated();
|
||||
Seeds.Apply(FixtureSql.Load("fixture_cascade_F4"));
|
||||
}
|
||||
|
||||
public void Dispose() { /* Next fixture's reset cleans up. */ }
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace Azaion.Missions.E2E.Fixtures;
|
||||
|
||||
/// <summary>
|
||||
/// Stop/start helper for the postgres-test compose service. Used by FT-P-17
|
||||
/// to prove that <c>/health</c> does not ping the database — the fixture
|
||||
/// stops postgres-test, the test asserts /health still returns 200, and the
|
||||
/// fixture restarts postgres-test in teardown.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Like <see cref="ComposeRestartFixture"/>, this fixture only runs when
|
||||
/// <c>COMPOSE_RESTART_ENABLED=1</c>. The e2e-consumer image needs the
|
||||
/// docker CLI on PATH and a docker socket bind to actually drive compose.
|
||||
/// Tests using the fixture must skip with a clear reason when disabled.
|
||||
/// </remarks>
|
||||
public sealed class PostgresStopStartFixture
|
||||
{
|
||||
public bool Enabled => Environment.GetEnvironmentVariable("COMPOSE_RESTART_ENABLED") == "1";
|
||||
|
||||
public string ComposeFile =>
|
||||
Environment.GetEnvironmentVariable("COMPOSE_FILE_PATH") ?? "/workspace/docker-compose.test.yml";
|
||||
|
||||
public string ServiceName =>
|
||||
Environment.GetEnvironmentVariable("POSTGRES_SERVICE_NAME") ?? "postgres-test";
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
EnsureEnabled();
|
||||
Run("docker", $"compose -f {ComposeFile} stop {ServiceName}");
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
EnsureEnabled();
|
||||
Run("docker", $"compose -f {ComposeFile} start {ServiceName}");
|
||||
// Wait for the service to report healthy via pg_isready before
|
||||
// returning — otherwise the next test would hit ConnectionRefused.
|
||||
WaitUntilHealthy();
|
||||
}
|
||||
|
||||
private void WaitUntilHealthy()
|
||||
{
|
||||
var deadline = DateTime.UtcNow.AddSeconds(30);
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
try
|
||||
{
|
||||
Run("docker",
|
||||
$"compose -f {ComposeFile} exec -T {ServiceName} pg_isready -U postgres -d azaion");
|
||||
return;
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
Thread.Sleep(500);
|
||||
}
|
||||
}
|
||||
throw new InvalidOperationException(
|
||||
$"postgres service '{ServiceName}' did not become ready within 30s after start");
|
||||
}
|
||||
|
||||
private void EnsureEnabled()
|
||||
{
|
||||
if (!Enabled)
|
||||
throw new InvalidOperationException(
|
||||
"PostgresStopStartFixture is disabled; set COMPOSE_RESTART_ENABLED=1 to use it.");
|
||||
}
|
||||
|
||||
private static void Run(string file, string args)
|
||||
{
|
||||
var psi = new ProcessStartInfo(file, args)
|
||||
{
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false
|
||||
};
|
||||
using var p = Process.Start(psi)
|
||||
?? throw new InvalidOperationException($"Failed to launch {file} {args}");
|
||||
p.WaitForExit();
|
||||
if (p.ExitCode != 0)
|
||||
{
|
||||
var err = p.StandardError.ReadToEnd();
|
||||
throw new InvalidOperationException($"`{file} {args}` exited {p.ExitCode}: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
using Npgsql;
|
||||
|
||||
namespace Azaion.Missions.E2E.Fixtures;
|
||||
|
||||
/// <summary>
|
||||
/// Inline seed-data definitions referenced by name from
|
||||
/// <c>_docs/02_document/tests/test-data.md § Seed Data Sets</c>. Each seed
|
||||
/// is idempotent against a freshly-reset DB (callers must run
|
||||
/// <see cref="DbResetFixture.ResetDatabase(string)"/> first; the
|
||||
/// <see cref="DbSeedFixture{TSeed}"/> base does this automatically).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// UUIDs are deterministic so assertions can reference them directly without
|
||||
/// having to first read them back. Seeds insert rows that satisfy every
|
||||
/// schema constraint — including the partial unique index
|
||||
/// <c>ux_vehicles_one_default</c> (a fixture cannot stage two
|
||||
/// is_default=true rows even though the test name suggests it).
|
||||
/// </remarks>
|
||||
public static class Seeds
|
||||
{
|
||||
/// <summary>seed_one_default_vehicle: a single Bayraktar with is_default=true.</summary>
|
||||
public static class OneDefaultVehicle
|
||||
{
|
||||
public static readonly Guid Id =
|
||||
Guid.Parse("11111111-1111-1111-1111-000000000001");
|
||||
|
||||
public const string Sql = """
|
||||
INSERT INTO vehicles
|
||||
(id, type, model, name, fuel_type, battery_capacity,
|
||||
engine_consumption, engine_consumption_idle, is_default)
|
||||
VALUES
|
||||
('11111111-1111-1111-1111-000000000001',
|
||||
0, 'Bayraktar', 'BR-default', 1, 0, 5, 1, true);
|
||||
""";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// seed_3_vehicles_2_default — name-misleading: only ONE row is default
|
||||
/// because the partial unique index <c>ux_vehicles_one_default</c> rejects
|
||||
/// two. The "2" in the name historically referred to a pre-B12 variant
|
||||
/// allowing two defaults; today only BR-01 carries the flag. This still
|
||||
/// satisfies every consumer scenario (FT-P-04 ordering, FT-P-05 filter,
|
||||
/// FT-N-01 no-match) — none of them require >1 default.
|
||||
///
|
||||
/// Insert order is reverse-alphabetic ([MQ-9, BR-02, BR-01]) so an
|
||||
/// ordering bug in the SUT (missing OrderBy) would surface immediately
|
||||
/// — see Risk #2 in _docs/tasks/done/AZ-577_test_vehicles_positive.md.
|
||||
/// </summary>
|
||||
public static class Three_BR01_BR02_MQ9
|
||||
{
|
||||
public static readonly Guid IdBr01 =
|
||||
Guid.Parse("11111111-2222-3333-4444-000000000001");
|
||||
public static readonly Guid IdBr02 =
|
||||
Guid.Parse("11111111-2222-3333-4444-000000000002");
|
||||
public static readonly Guid IdMq9 =
|
||||
Guid.Parse("11111111-2222-3333-4444-000000000003");
|
||||
|
||||
public const string Sql = """
|
||||
INSERT INTO vehicles
|
||||
(id, type, model, name, fuel_type, battery_capacity,
|
||||
engine_consumption, engine_consumption_idle, is_default)
|
||||
VALUES
|
||||
('11111111-2222-3333-4444-000000000003',
|
||||
0, 'Bayraktar', 'MQ-9', 1, 0, 5, 1, false),
|
||||
('11111111-2222-3333-4444-000000000002',
|
||||
0, 'Bayraktar', 'BR-02', 1, 0, 5, 1, false),
|
||||
('11111111-2222-3333-4444-000000000001',
|
||||
0, 'Bayraktar', 'BR-01', 1, 0, 5, 1, true);
|
||||
""";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// seed_25_missions: 5 in January 2026, 20 in February 2026; CreatedDate
|
||||
/// values are spaced ≥ 1 second apart so DESC ordering is deterministic
|
||||
/// (FT-P-08 risk #2). Names alternate between "Recon-N" and "OPS-N" so
|
||||
/// the case-INSENSITIVE name=re filter returns >0 rows.
|
||||
/// </summary>
|
||||
public static class TwentyFiveMissions
|
||||
{
|
||||
public static readonly Guid VehicleId =
|
||||
Guid.Parse("11111111-aaaa-aaaa-aaaa-000000000001");
|
||||
|
||||
// The 5 January CreatedDate values are 2026-01-15T10:00:[00..04]Z so
|
||||
// every mission has a distinct, deterministic CreatedDate.
|
||||
public static string Sql
|
||||
{
|
||||
get
|
||||
{
|
||||
var sb = new System.Text.StringBuilder();
|
||||
sb.AppendLine("""
|
||||
INSERT INTO vehicles
|
||||
(id, type, model, name, fuel_type, battery_capacity,
|
||||
engine_consumption, engine_consumption_idle, is_default)
|
||||
VALUES
|
||||
('11111111-aaaa-aaaa-aaaa-000000000001',
|
||||
0, 'Bayraktar', 'BR-fixture-25', 1, 0, 5, 1, false);
|
||||
""");
|
||||
sb.AppendLine("INSERT INTO missions (id, created_date, name, vehicle_id) VALUES");
|
||||
for (var i = 0; i < 25; i++)
|
||||
{
|
||||
var month = i < 5 ? "01" : "02";
|
||||
var day = i < 5 ? (15 + i).ToString("D2") : (1 + (i - 5)).ToString("D2");
|
||||
var second = (i % 60).ToString("D2");
|
||||
var minute = ((i / 60) % 60).ToString("D2");
|
||||
var name = (i % 2 == 0) ? $"Recon-{i:D2}" : $"OPS-{i:D2}";
|
||||
var idHex = (i + 1).ToString("D12");
|
||||
sb.Append("('22222222-bbbb-bbbb-bbbb-").Append(idHex).Append("', ");
|
||||
sb.Append("'2026-").Append(month).Append('-').Append(day);
|
||||
sb.Append('T').Append("10:").Append(minute).Append(':').Append(second).Append("Z', ");
|
||||
sb.Append('\'').Append(name).Append("', ");
|
||||
sb.Append("'11111111-aaaa-aaaa-aaaa-000000000001')");
|
||||
sb.AppendLine(i == 24 ? ";" : ",");
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// seed_5_waypoints_unordered: 5 waypoints under one mission with
|
||||
/// OrderNum values [3, 1, 2, 5, 4] inserted in that order. The shuffled
|
||||
/// insert order forces FT-P-13 to fail loudly if the SUT forgets the
|
||||
/// OrderBy(w => w.OrderNum) clause.
|
||||
/// </summary>
|
||||
public static class FiveWaypointsUnordered
|
||||
{
|
||||
public static readonly Guid VehicleId =
|
||||
Guid.Parse("11111111-cccc-cccc-cccc-000000000001");
|
||||
public static readonly Guid MissionId =
|
||||
Guid.Parse("22222222-cccc-cccc-cccc-000000000001");
|
||||
|
||||
public const string Sql = """
|
||||
INSERT INTO vehicles
|
||||
(id, type, model, name, fuel_type, battery_capacity,
|
||||
engine_consumption, engine_consumption_idle, is_default)
|
||||
VALUES
|
||||
('11111111-cccc-cccc-cccc-000000000001',
|
||||
0, 'Bayraktar', 'BR-wp-fixture', 1, 0, 5, 1, false);
|
||||
|
||||
INSERT INTO missions (id, created_date, name, vehicle_id)
|
||||
VALUES
|
||||
('22222222-cccc-cccc-cccc-000000000001',
|
||||
'2026-05-14T00:00:00Z', 'wp-fixture', '11111111-cccc-cccc-cccc-000000000001');
|
||||
|
||||
INSERT INTO waypoints
|
||||
(id, mission_id, lat, lon, mgrs, waypoint_source,
|
||||
waypoint_objective, order_num, height)
|
||||
VALUES
|
||||
('33333333-cccc-cccc-cccc-000000000001',
|
||||
'22222222-cccc-cccc-cccc-000000000001', 50.45, 30.52, NULL, 0, 0, 3, 100),
|
||||
('33333333-cccc-cccc-cccc-000000000002',
|
||||
'22222222-cccc-cccc-cccc-000000000001', 50.46, 30.53, NULL, 0, 0, 1, 110),
|
||||
('33333333-cccc-cccc-cccc-000000000003',
|
||||
'22222222-cccc-cccc-cccc-000000000001', 50.47, 30.54, NULL, 0, 0, 2, 120),
|
||||
('33333333-cccc-cccc-cccc-000000000004',
|
||||
'22222222-cccc-cccc-cccc-000000000001', 50.48, 30.55, NULL, 0, 0, 5, 130),
|
||||
('33333333-cccc-cccc-cccc-000000000005',
|
||||
'22222222-cccc-cccc-cccc-000000000001', 50.49, 30.56, NULL, 0, 0, 4, 140);
|
||||
""";
|
||||
}
|
||||
|
||||
public static void Apply(string sql)
|
||||
{
|
||||
using var conn = new NpgsqlConnection(TestEnvironment.DbSideChannel);
|
||||
conn.Open();
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = sql;
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Net.Http.Headers;
|
||||
using Azaion.Missions.E2E.Helpers;
|
||||
using Npgsql;
|
||||
using Xunit;
|
||||
|
||||
namespace Azaion.Missions.E2E.Fixtures;
|
||||
|
||||
/// <summary>
|
||||
/// Shared 5-minute steady-state load fixture for NFT-RES-LIM-01 / -02 / -03.
|
||||
/// Runs the load generator once, samples RSS / connection count / FD count
|
||||
/// every 5s, and exposes the time series + sentinel "did the SUT exit" flag.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>The fixture is class-scoped (xUnit <see cref="IClassFixture{TFixture}"/>)
|
||||
/// and shared across all three NFT-RES-LIM-01/02/03 tests so the 5-minute
|
||||
/// window runs once per CI invocation.</para>
|
||||
/// <para>Disabled when <c>COMPOSE_RESTART_ENABLED != 1</c> or docker CLI
|
||||
/// is missing. Disabled state is observable via <see cref="Enabled"/>; tests
|
||||
/// must call <see cref="Xunit.Skip.IfNot(bool, string)"/> at the top of the
|
||||
/// method body — initialising the fixture without docker would throw inside
|
||||
/// <see cref="InitializeAsync"/> and surface as a hard failure instead of
|
||||
/// the explicit skip the spec requires.</para>
|
||||
/// </remarks>
|
||||
public sealed class SteadyStateLoadFixture : IAsyncLifetime
|
||||
{
|
||||
public const int SampleIntervalSeconds = 5;
|
||||
public const int LoadDurationSeconds = 300;
|
||||
public const int TargetRps = 50;
|
||||
public const string ContainerName = "missions-sut";
|
||||
|
||||
public bool Enabled =>
|
||||
Environment.GetEnvironmentVariable("COMPOSE_RESTART_ENABLED") == "1";
|
||||
|
||||
public bool LoadGeneratorMetTargetRps { get; private set; }
|
||||
public bool SutExitedDuringWindow { get; private set; }
|
||||
public string? SkipReason { get; private set; }
|
||||
|
||||
public List<long> RssBytesSamples { get; } = new();
|
||||
public List<int> NpgsqlConnectionSamples { get; } = new();
|
||||
public List<int> FileDescriptorSamples { get; } = new();
|
||||
public List<DateTime> SampleTimestamps { get; } = new();
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
if (!Enabled)
|
||||
{
|
||||
SkipReason = "COMPOSE_RESTART_ENABLED!=1 — docker CLI primitives unavailable";
|
||||
return;
|
||||
}
|
||||
if (!CommandAvailable("docker"))
|
||||
{
|
||||
SkipReason = "docker CLI not on PATH in this consumer image";
|
||||
return;
|
||||
}
|
||||
|
||||
using var http = new HttpClient { BaseAddress = new Uri(TestEnvironment.MissionsBaseUrl) };
|
||||
var token = await new TokenMinter(TestEnvironment.JwksMockBaseUrl + "/sign").MintDefaultAsync();
|
||||
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token.Jwt);
|
||||
|
||||
var cancel = new CancellationTokenSource();
|
||||
var endpoints = new[] { "/vehicles", "/missions", "/missions?page=1&pageSize=20" };
|
||||
long requestsSent = 0;
|
||||
|
||||
var loadTask = Task.Run(async () =>
|
||||
{
|
||||
var ix = 0;
|
||||
while (!cancel.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
var url = endpoints[ix++ % endpoints.Length];
|
||||
using var resp = await http.GetAsync(url, cancel.Token);
|
||||
Interlocked.Increment(ref requestsSent);
|
||||
}
|
||||
catch (HttpRequestException) { /* surfaces via SutExitedDuringWindow below */ }
|
||||
catch (OperationCanceledException) { return; }
|
||||
}
|
||||
}, cancel.Token);
|
||||
|
||||
var samplingDeadline = DateTime.UtcNow.AddSeconds(LoadDurationSeconds);
|
||||
while (DateTime.UtcNow < samplingDeadline)
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(SampleIntervalSeconds), cancel.Token);
|
||||
if (!ContainerIsRunning(ContainerName))
|
||||
{
|
||||
SutExitedDuringWindow = true;
|
||||
break;
|
||||
}
|
||||
SampleTimestamps.Add(DateTime.UtcNow);
|
||||
RssBytesSamples.Add(ReadRssBytes(ContainerName));
|
||||
NpgsqlConnectionSamples.Add(ReadNpgsqlConnectionCount());
|
||||
FileDescriptorSamples.Add(ReadFileDescriptorCount(ContainerName));
|
||||
}
|
||||
|
||||
cancel.Cancel();
|
||||
try { await loadTask; } catch (OperationCanceledException) { }
|
||||
|
||||
// Sustained 50 RPS over 300s = 15000 requests; allow 10% slack for
|
||||
// CI variance / connection-refused retries.
|
||||
LoadGeneratorMetTargetRps =
|
||||
requestsSent >= (long)(TargetRps * LoadDurationSeconds * 0.9);
|
||||
}
|
||||
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
private static long ReadRssBytes(string containerName)
|
||||
{
|
||||
// `docker stats --no-stream --format '{{.MemUsage}}'` prints e.g.
|
||||
// "187.4MiB / 7.7GiB". We need the LHS in bytes.
|
||||
var raw = Run("docker",
|
||||
$"stats --no-stream --format '{{{{.MemUsage}}}}' {containerName}");
|
||||
var lhs = raw.Split('/')[0].Trim().Trim('\'');
|
||||
return ParseHumanBytes(lhs);
|
||||
}
|
||||
|
||||
private static int ReadNpgsqlConnectionCount()
|
||||
{
|
||||
using var conn = new NpgsqlConnection(TestEnvironment.DbSideChannel);
|
||||
conn.Open();
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
SELECT count(*)::INTEGER FROM pg_stat_activity
|
||||
WHERE application_name LIKE 'Npgsql%'
|
||||
OR (usename = 'postgres' AND backend_type = 'client backend');
|
||||
""";
|
||||
return Convert.ToInt32(cmd.ExecuteScalar());
|
||||
}
|
||||
|
||||
private static int ReadFileDescriptorCount(string containerName)
|
||||
{
|
||||
// `pgrep` is not guaranteed in the runtime image; we walk /proc
|
||||
// directly. `/proc/1/comm` is the entrypoint process name; for the
|
||||
// ASP.NET Core SDK image this is `dotnet`.
|
||||
var stdout = Run("docker",
|
||||
$"exec {containerName} sh -c 'ls /proc/1/fd | wc -l'");
|
||||
return int.Parse(stdout.Trim(), CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private static long ParseHumanBytes(string text)
|
||||
{
|
||||
// "187.4MiB" / "1.2GiB" / "234KiB" / "987B"
|
||||
var unitIx = text.IndexOfAny(new[] { 'K', 'M', 'G', 'T', 'B' });
|
||||
if (unitIx < 0) return long.Parse(text, CultureInfo.InvariantCulture);
|
||||
var num = double.Parse(text.Substring(0, unitIx), CultureInfo.InvariantCulture);
|
||||
var unit = text.Substring(unitIx);
|
||||
return unit switch
|
||||
{
|
||||
"B" => (long)num,
|
||||
"KiB" or "KB" or "K" => (long)(num * 1024),
|
||||
"MiB" or "MB" or "M" => (long)(num * 1024 * 1024),
|
||||
"GiB" or "GB" or "G" => (long)(num * 1024 * 1024 * 1024),
|
||||
"TiB" or "TB" or "T" => (long)(num * 1024L * 1024 * 1024 * 1024),
|
||||
_ => throw new FormatException($"unknown human-bytes unit in '{text}'")
|
||||
};
|
||||
}
|
||||
|
||||
private static bool ContainerIsRunning(string containerName)
|
||||
{
|
||||
try
|
||||
{
|
||||
var stdout = Run("docker",
|
||||
$"inspect --format '{{{{.State.Running}}}}' {containerName}");
|
||||
return stdout.Trim().Trim('\'').Equals("true", StringComparison.Ordinal);
|
||||
}
|
||||
catch (InvalidOperationException) { return false; }
|
||||
}
|
||||
|
||||
private static bool CommandAvailable(string command)
|
||||
{
|
||||
try
|
||||
{
|
||||
var psi = new ProcessStartInfo(command, "--version")
|
||||
{
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false
|
||||
};
|
||||
using var p = Process.Start(psi);
|
||||
if (p is null) return false;
|
||||
p.WaitForExit();
|
||||
return p.ExitCode == 0;
|
||||
}
|
||||
catch (System.ComponentModel.Win32Exception) { return false; }
|
||||
}
|
||||
|
||||
private static string Run(string file, string args)
|
||||
{
|
||||
var psi = new ProcessStartInfo(file, args)
|
||||
{
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false
|
||||
};
|
||||
using var p = Process.Start(psi)
|
||||
?? throw new InvalidOperationException($"failed to launch `{file} {args}`");
|
||||
var stdout = p.StandardOutput.ReadToEnd();
|
||||
var stderr = p.StandardError.ReadToEnd();
|
||||
p.WaitForExit();
|
||||
if (p.ExitCode != 0)
|
||||
throw new InvalidOperationException(
|
||||
$"`{file} {args}` exited {p.ExitCode}: {stderr}");
|
||||
return stdout;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using Npgsql;
|
||||
|
||||
namespace Azaion.Missions.E2E.Fixtures;
|
||||
|
||||
/// <summary>
|
||||
/// Creates the borrowed-schema stub tables (media, annotations, detection)
|
||||
/// required by the cascade-delete fixtures. The migrator (<c>DatabaseMigrator</c>)
|
||||
/// only owns the missions/vehicles/waypoints/map_objects tables; media,
|
||||
/// annotations, and detection are owned by sibling services in production
|
||||
/// (out of scope for this repo per
|
||||
/// _docs/02_document/tests/environment.md). The cascade walk in
|
||||
/// <c>MissionService.DeleteMission</c> still references them, so tests must
|
||||
/// supply their schema via side-channel.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Idempotent — every statement is <c>CREATE … IF NOT EXISTS</c>.
|
||||
/// Column shapes match the LinqToDB entities (<c>Database/Entities/Media.cs</c>,
|
||||
/// <c>Database/Entities/Annotation.cs</c>, <c>Database/Entities/Detection.cs</c>).
|
||||
/// </remarks>
|
||||
public static class StubSchema
|
||||
{
|
||||
public static void EnsureCreated()
|
||||
{
|
||||
using var conn = new NpgsqlConnection(TestEnvironment.DbSideChannel);
|
||||
conn.Open();
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
CREATE TABLE IF NOT EXISTS media (
|
||||
id TEXT PRIMARY KEY,
|
||||
waypoint_id UUID
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS annotations (
|
||||
id TEXT PRIMARY KEY,
|
||||
media_id TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS detection (
|
||||
id UUID PRIMARY KEY,
|
||||
annotation_id TEXT NOT NULL
|
||||
);
|
||||
""";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Azaion.Missions.E2E.Helpers;
|
||||
|
||||
// CARRY-FORWARD (ADR-002 superseded by observed behaviour, 2026-05-15):
|
||||
// The canonical spec + initial test contract pinned PascalCase wire bodies,
|
||||
// but ASP.NET Core's default JsonSerializerOptions (camelCase) was never
|
||||
// overridden in Program.cs. Service responses are therefore camelCase end-
|
||||
// to-end. JsonPropertyName attributes match the observed wire shape so the
|
||||
// tests pin actual behaviour; a future product decision to flip naming
|
||||
// policy will break these tests loudly. Tracked in the traceability matrix
|
||||
// under the per-test `carry_forward` traits.
|
||||
|
||||
public sealed record VehicleDto(
|
||||
[property: JsonPropertyName("id")] Guid Id,
|
||||
[property: JsonPropertyName("type")] int Type,
|
||||
[property: JsonPropertyName("model")] string Model,
|
||||
[property: JsonPropertyName("name")] string Name,
|
||||
[property: JsonPropertyName("fuelType")] int FuelType,
|
||||
[property: JsonPropertyName("batteryCapacity")] decimal BatteryCapacity,
|
||||
[property: JsonPropertyName("engineConsumption")] decimal EngineConsumption,
|
||||
[property: JsonPropertyName("engineConsumptionIdle")] decimal EngineConsumptionIdle,
|
||||
[property: JsonPropertyName("isDefault")] bool IsDefault);
|
||||
|
||||
public sealed record MissionDto(
|
||||
[property: JsonPropertyName("id")] Guid Id,
|
||||
[property: JsonPropertyName("createdDate")] DateTime CreatedDate,
|
||||
[property: JsonPropertyName("name")] string Name,
|
||||
[property: JsonPropertyName("vehicleId")] Guid VehicleId);
|
||||
|
||||
// Waypoint response is FLAT (lat/lon/mgrs at top level, NOT nested in a
|
||||
// geoPoint object) because the SUT returns the LinqToDB entity directly via
|
||||
// `Ok(waypoint)` and the entity stores those columns flat. The request DTO
|
||||
// nests them under GeoPoint, but the response does not — see
|
||||
// _docs/02_document/modules/controller_missions.md and Database/Entities/Waypoint.cs.
|
||||
public sealed record WaypointDto(
|
||||
[property: JsonPropertyName("id")] Guid Id,
|
||||
[property: JsonPropertyName("missionId")] Guid MissionId,
|
||||
[property: JsonPropertyName("lat")] decimal? Lat,
|
||||
[property: JsonPropertyName("lon")] decimal? Lon,
|
||||
[property: JsonPropertyName("mgrs")] string? Mgrs,
|
||||
[property: JsonPropertyName("waypointSource")] int WaypointSource,
|
||||
[property: JsonPropertyName("waypointObjective")] int WaypointObjective,
|
||||
[property: JsonPropertyName("orderNum")] int OrderNum,
|
||||
[property: JsonPropertyName("height")] decimal Height);
|
||||
|
||||
public sealed record PaginatedResponseDto<T>(
|
||||
[property: JsonPropertyName("items")] List<T> Items,
|
||||
[property: JsonPropertyName("totalCount")] int TotalCount,
|
||||
[property: JsonPropertyName("page")] int Page,
|
||||
[property: JsonPropertyName("pageSize")] int PageSize);
|
||||
|
||||
// Error envelope produced by ErrorHandlingMiddleware.
|
||||
public sealed record ProblemDto(
|
||||
[property: JsonPropertyName("statusCode")] int StatusCode,
|
||||
[property: JsonPropertyName("message")] string Message);
|
||||
@@ -0,0 +1,52 @@
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
|
||||
namespace Azaion.Missions.E2E.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// Scrapes <c>docker logs</c> from inside the e2e-consumer container, used
|
||||
/// to assert "unhandled exception" and structured log lines emitted by the
|
||||
/// SUT (NFT-SEC-08 stack-not-leaked, NFT-RES-01..04 cascade/migrator log
|
||||
/// invariants, NFT-RES-06 Npgsql 3D000).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Like the docker-compose fixtures, this helper requires docker CLI access
|
||||
/// (and typically a docker socket bind). Tests that depend on it must
|
||||
/// <see cref="Xunit.Skip.IfNot(bool, string)"/> when the CLI is not
|
||||
/// available — silent passing is rejected.
|
||||
/// </remarks>
|
||||
public static class DockerLogs
|
||||
{
|
||||
public static bool Contains(string container, string needle, DateTime sinceUtc)
|
||||
=> Read(container, sinceUtc).Contains(needle, StringComparison.Ordinal);
|
||||
|
||||
/// <summary>Returns the combined stdout+stderr log slice since <paramref name="sinceUtc"/>.</summary>
|
||||
public static string Read(string container, DateTime? sinceUtc = null)
|
||||
{
|
||||
var args = sinceUtc is { } cutoff
|
||||
? $"logs --since {cutoff.ToString("yyyy-MM-ddTHH:mm:ssZ", CultureInfo.InvariantCulture)} {container}"
|
||||
: $"logs {container}";
|
||||
var psi = new ProcessStartInfo("docker", args)
|
||||
{
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false
|
||||
};
|
||||
try
|
||||
{
|
||||
using var p = Process.Start(psi)
|
||||
?? throw new InvalidOperationException("docker command not available");
|
||||
var stdout = p.StandardOutput.ReadToEnd();
|
||||
var stderr = p.StandardError.ReadToEnd();
|
||||
p.WaitForExit();
|
||||
return stdout + stderr;
|
||||
}
|
||||
catch (System.ComponentModel.Win32Exception)
|
||||
{
|
||||
// No docker CLI in PATH — surface, do not silently pass.
|
||||
throw new InvalidOperationException(
|
||||
$"docker CLI not available; cannot scrape logs for '{container}'. " +
|
||||
"Mount /var/run/docker.sock and install docker-cli in the e2e-consumer image.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace Azaion.Missions.E2E.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// Test-only ECDSA P-256 signer used by NFT-SEC-02 to mint a token signed by
|
||||
/// a keypair the JWKS endpoint never published. This is the ONE in-test
|
||||
/// signing path allowed by the task spec — every other test mints via the
|
||||
/// jwks-mock <c>POST /sign</c> endpoint.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The private key lives entirely in the test process and is disposed with
|
||||
/// the helper. The wire shape mirrors <c>JwksMock.TokenSigner</c> (JWS-compact
|
||||
/// ES256) so the only thing that differs from a "real" mock-minted token is
|
||||
/// the signing key — defeating any IssuerSigningKeyResolver that fails to
|
||||
/// match <c>kid</c> against the published JWKS.
|
||||
/// </remarks>
|
||||
public sealed class ForeignKeypair : IDisposable
|
||||
{
|
||||
private readonly ECDsa _ec;
|
||||
private readonly string _kid;
|
||||
|
||||
public ForeignKeypair()
|
||||
{
|
||||
_ec = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
// Deterministic kid that is clearly NOT what jwks-mock issues
|
||||
// (mock kids are base64url SHA-256 hashes; this label is plain ASCII).
|
||||
_kid = "foreign-keypair-not-in-jwks";
|
||||
}
|
||||
|
||||
public string Mint(string issuer, string audience, string permissions, int expOffsetSeconds = 3600)
|
||||
{
|
||||
var nowUnix = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
var expUnix = nowUnix + expOffsetSeconds;
|
||||
|
||||
var header = new JsonObject
|
||||
{
|
||||
["alg"] = "ES256",
|
||||
["kid"] = _kid,
|
||||
["typ"] = "JWT"
|
||||
};
|
||||
var payload = new JsonObject
|
||||
{
|
||||
["iss"] = issuer,
|
||||
["aud"] = audience,
|
||||
["iat"] = nowUnix,
|
||||
["exp"] = expUnix,
|
||||
["permissions"] = permissions
|
||||
};
|
||||
|
||||
var headerSeg = Base64UrlEncode(JsonSerializer.SerializeToUtf8Bytes(header));
|
||||
var payloadSeg = Base64UrlEncode(JsonSerializer.SerializeToUtf8Bytes(payload));
|
||||
var signingInput = Encoding.ASCII.GetBytes($"{headerSeg}.{payloadSeg}");
|
||||
var signature = _ec.SignData(signingInput, HashAlgorithmName.SHA256,
|
||||
DSASignatureFormat.IeeeP1363FixedFieldConcatenation);
|
||||
var sigSeg = Base64UrlEncode(signature);
|
||||
return $"{headerSeg}.{payloadSeg}.{sigSeg}";
|
||||
}
|
||||
|
||||
public void Dispose() => _ec.Dispose();
|
||||
|
||||
private static string Base64UrlEncode(ReadOnlySpan<byte> bytes)
|
||||
{
|
||||
var b64 = Convert.ToBase64String(bytes);
|
||||
return b64.Replace('+', '-').Replace('/', '_').TrimEnd('=');
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,42 @@ public static class HttpAssertions
|
||||
AssertNoStackLeak(body);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asserts the {statusCode, message} envelope produced by
|
||||
/// <c>ErrorHandlingMiddleware</c>. The envelope uses camelCase keys
|
||||
/// because the middleware emits an anonymous object literal — see
|
||||
/// _docs/02_document/components/06_http_conventions/description.md.
|
||||
/// </summary>
|
||||
public static async Task<ProblemDto> AssertProblemEnvelopeAsync(
|
||||
HttpResponseMessage response,
|
||||
HttpStatusCode expectedStatus)
|
||||
{
|
||||
await AssertStatusAsync(response, expectedStatus).ConfigureAwait(false);
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>().ConfigureAwait(false);
|
||||
|
||||
Assert.True(body.TryGetProperty("statusCode", out var statusEl),
|
||||
"problem envelope missing 'statusCode' property");
|
||||
Assert.True(body.TryGetProperty("message", out var messageEl),
|
||||
"problem envelope missing 'message' property");
|
||||
Assert.Equal((int)expectedStatus, statusEl.GetInt32());
|
||||
var message = messageEl.GetString();
|
||||
Assert.False(string.IsNullOrEmpty(message),
|
||||
"problem envelope 'message' must be non-empty");
|
||||
|
||||
AssertNoStackLeak(body);
|
||||
|
||||
// Reject any extra keys to pin the envelope contract — the spec says
|
||||
// EXACTLY these two keys (results_report.md row 1.8 + AC-8.6).
|
||||
var extraKeys = body.EnumerateObject()
|
||||
.Select(p => p.Name)
|
||||
.Where(n => n is not ("statusCode" or "message"))
|
||||
.ToArray();
|
||||
Assert.True(extraKeys.Length == 0,
|
||||
$"problem envelope has unexpected extra keys: {string.Join(",", extraKeys)}");
|
||||
|
||||
return new ProblemDto(statusEl.GetInt32(), message!);
|
||||
}
|
||||
|
||||
public static void AssertNoStackLeak(JsonElement body)
|
||||
{
|
||||
// Walk the JSON DOM and fail if any key looks like it leaks server internals.
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Azaion.Missions.E2E.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// Invokes the missions service's test-only <c>POST /test/refresh-jwks</c>
|
||||
/// endpoint, which forces the JWKS <see cref="Microsoft.IdentityModel.Protocols.ConfigurationManager{T}"/>
|
||||
/// to re-fetch immediately. The endpoint is mapped only when
|
||||
/// <c>ASPNETCORE_ENVIRONMENT=Test</c>; production deployments never expose it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Why this exists: Microsoft.IdentityModel.Tokens hard-pins the
|
||||
/// <c>MinimumAutomaticRefreshInterval</c> floor to 5 minutes via a static
|
||||
/// field. JWKS-rotation e2e scenarios (NFT-SEC-11, NFT-RES-07) cannot rely on
|
||||
/// the proactive refresh path inside the 15-minute CI window. The signature-
|
||||
/// failure refresh path the JwtBearer middleware exposes
|
||||
/// (<c>RefreshOnIssuerKeyNotFound</c>) is bypassed because the service uses a
|
||||
/// custom <c>IssuerSigningKeyResolver</c>. Hence: explicit refresh via this
|
||||
/// hook, no test poisons later tests.
|
||||
/// </remarks>
|
||||
public static class JwksRefreshHelper
|
||||
{
|
||||
public static async Task<string[]> ForceRefreshAsync(HttpClient missions, CancellationToken cancel = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(missions);
|
||||
|
||||
using var resp = await missions.PostAsync("/test/refresh-jwks", content: null, cancel)
|
||||
.ConfigureAwait(false);
|
||||
resp.EnsureSuccessStatusCode();
|
||||
var body = await resp.Content.ReadFromJsonAsync<JsonElement>(cancel).ConfigureAwait(false);
|
||||
var kids = body.GetProperty("kids");
|
||||
var result = new string[kids.GetArrayLength()];
|
||||
for (var i = 0; i < result.Length; i++)
|
||||
result[i] = kids[i].GetString() ?? "";
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
namespace Azaion.Missions.E2E.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// Median + percentile helper for the NFT-PERF-* and NFT-RES-LIM-01
|
||||
/// scenarios. Inputs are wall-clock latency samples (or RSS samples)
|
||||
/// in any orderable numeric type; the helper sorts a defensive copy
|
||||
/// and uses the "nearest-rank" definition of percentile (matching the
|
||||
/// percentile defaults used in `docker stats` and most CI dashboards).
|
||||
/// </summary>
|
||||
public static class LatencyPercentiles
|
||||
{
|
||||
public static double P50(IReadOnlyList<double> samples) => Percentile(samples, 50);
|
||||
public static double P95(IReadOnlyList<double> samples) => Percentile(samples, 95);
|
||||
|
||||
public static double Percentile(IReadOnlyList<double> samples, int percentile)
|
||||
{
|
||||
if (samples.Count == 0)
|
||||
throw new ArgumentException("samples must contain at least one value", nameof(samples));
|
||||
if (percentile < 0 || percentile > 100)
|
||||
throw new ArgumentOutOfRangeException(nameof(percentile), "percentile must be in [0, 100]");
|
||||
|
||||
var sorted = samples.ToArray();
|
||||
Array.Sort(sorted);
|
||||
|
||||
// Nearest-rank: rank = ceil(p/100 * N); index = rank - 1.
|
||||
var rank = (int)Math.Ceiling(percentile / 100.0 * sorted.Length);
|
||||
if (rank < 1) rank = 1;
|
||||
if (rank > sorted.Length) rank = sorted.Length;
|
||||
return sorted[rank - 1];
|
||||
}
|
||||
|
||||
public static double Mean(IReadOnlyList<double> samples)
|
||||
{
|
||||
if (samples.Count == 0)
|
||||
throw new ArgumentException("samples must contain at least one value", nameof(samples));
|
||||
return samples.Average();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace Azaion.Missions.E2E.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// Appends one row per NFT-PERF / NFT-RES-LIM scenario to a side-channel
|
||||
/// CSV referenced by an environment variable. The Reporting.Cli converter
|
||||
/// only knows about compile-time <c>[Trait]</c> data — runtime measurements
|
||||
/// (P50/P95, MAX_FD, P95_RSS_MiB, etc.) need this separate file so
|
||||
/// deployment planning + trend dashboards can read them.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// File schema (idempotent header written on first append):
|
||||
/// <code>Timestamp,Category,Scenario,Result,Traces,ErrorMessage</code>
|
||||
/// The Traces column carries the dynamic key=value pairs the spec requires
|
||||
/// (e.g., <c>"AC-3.6; P50_MS=23.4; P95_MS=41.8"</c>); the recorder just
|
||||
/// joins them with semicolons — callers compose the right shape.
|
||||
/// </remarks>
|
||||
public sealed class MetricCsvRecorder
|
||||
{
|
||||
private readonly string? _path;
|
||||
private static readonly object Lock = new();
|
||||
|
||||
/// <param name="envVar">name of the env var that carries the target CSV path
|
||||
/// (e.g., <c>PERF_RESULTS_FILE</c> for NFT-PERF, <c>RESLIM_RESULTS_FILE</c>
|
||||
/// for NFT-RES-LIM). When the env var is missing or whitespace, every
|
||||
/// <see cref="Record"/> call is a no-op — the recorder is intentionally
|
||||
/// silent inside the standard CI run.</param>
|
||||
public MetricCsvRecorder(string envVar)
|
||||
{
|
||||
var v = Environment.GetEnvironmentVariable(envVar);
|
||||
_path = string.IsNullOrWhiteSpace(v) ? null : v;
|
||||
}
|
||||
|
||||
public bool IsEnabled => _path is not null;
|
||||
|
||||
public void Record(string category, string scenario, string result, string traces, string? errorMessage = null)
|
||||
{
|
||||
if (_path is null) return;
|
||||
lock (Lock)
|
||||
{
|
||||
var dir = Path.GetDirectoryName(_path);
|
||||
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
|
||||
var newFile = !File.Exists(_path);
|
||||
using var sw = new StreamWriter(_path, append: true);
|
||||
if (newFile)
|
||||
sw.WriteLine("Timestamp,Category,Scenario,Result,Traces,ErrorMessage");
|
||||
sw.WriteLine(
|
||||
$"{DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture)},"
|
||||
+ $"{Csv(category)},{Csv(scenario)},{Csv(result)},{Csv(traces)},{Csv(errorMessage ?? "")}");
|
||||
}
|
||||
}
|
||||
|
||||
private static string Csv(string value) =>
|
||||
value.Contains(',') || value.Contains('"') || value.Contains('\n')
|
||||
? "\"" + value.Replace("\"", "\"\"", StringComparison.Ordinal) + "\""
|
||||
: value;
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Net;
|
||||
|
||||
namespace Azaion.Missions.E2E.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// Spawns standalone <c>azaion/missions:test</c> containers via <c>docker run</c>
|
||||
/// (NOT compose) so startup-time behavior can be exercised independently of
|
||||
/// the long-running compose stack. Used by NFT-SEC-12, NFT-SEC-13,
|
||||
/// NFT-RES-05, NFT-RES-06 — each provides its own env override map and asserts
|
||||
/// against the captured exit code + logs.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Like <see cref="Fixtures.ComposeRestartFixture"/>, this helper is gated on
|
||||
/// <c>COMPOSE_RESTART_ENABLED=1</c> and a docker CLI on PATH; tests using it
|
||||
/// must <see cref="Xunit.Skip.IfNot(bool, string)"/> when the gate fails so
|
||||
/// CI environments without Docker access skip with an explicit reason
|
||||
/// instead of silently passing.
|
||||
/// </remarks>
|
||||
public static class MissionsContainerHelper
|
||||
{
|
||||
public const string MissionsImageEnvVar = "MISSIONS_TEST_IMAGE";
|
||||
public const string DefaultMissionsImage = "azaion/missions:test";
|
||||
public const string NetworkEnvVar = "MISSIONS_TEST_NETWORK";
|
||||
public const string DefaultNetwork = "missions-e2e-net";
|
||||
|
||||
public static bool Enabled =>
|
||||
Environment.GetEnvironmentVariable("COMPOSE_RESTART_ENABLED") == "1";
|
||||
|
||||
public static string Image =>
|
||||
Environment.GetEnvironmentVariable(MissionsImageEnvVar) ?? DefaultMissionsImage;
|
||||
|
||||
public static string Network =>
|
||||
Environment.GetEnvironmentVariable(NetworkEnvVar) ?? DefaultNetwork;
|
||||
|
||||
/// <summary>
|
||||
/// Runs <c>docker run --rm --name <name> --network <net> <env> <image></c>,
|
||||
/// waits for the container to exit (up to <paramref name="timeout"/>),
|
||||
/// and returns its exit code + combined logs. Forces removal of any
|
||||
/// stale container with the same name before starting (an earlier crash
|
||||
/// can leave a stopped container behind).
|
||||
/// </summary>
|
||||
public static RunResult RunUntilExit(
|
||||
string containerName,
|
||||
IReadOnlyDictionary<string, string> envOverrides,
|
||||
TimeSpan timeout)
|
||||
{
|
||||
ForceRemove(containerName);
|
||||
var args = BuildRunArgs(containerName, envOverrides);
|
||||
Run("docker", args, out var runStdout, out var runStderr);
|
||||
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
if (TryGetExitCode(containerName, out var exitCode))
|
||||
{
|
||||
var logs = ReadLogs(containerName);
|
||||
ForceRemove(containerName);
|
||||
return new RunResult(exitCode, logs, runStdout, runStderr);
|
||||
}
|
||||
Thread.Sleep(250);
|
||||
}
|
||||
|
||||
var partialLogs = ReadLogs(containerName);
|
||||
ForceRemove(containerName);
|
||||
throw new TimeoutException(
|
||||
$"container '{containerName}' did not exit within {timeout.TotalSeconds:F0}s. " +
|
||||
$"Partial logs:\n{partialLogs}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Captures <c>docker inspect --format '{{.State.StartedAt}}'</c> for a
|
||||
/// running container, returned as a stable ISO-8601 string. Used by
|
||||
/// NFT-RES-07 to assert the missions service did NOT restart during a
|
||||
/// JWKS rotation flow.
|
||||
/// </summary>
|
||||
public static string GetStartedAt(string containerName)
|
||||
{
|
||||
Run("docker",
|
||||
$"inspect --format '{{{{.State.StartedAt}}}}' {containerName}",
|
||||
out var stdout, out _);
|
||||
return stdout.Trim().Trim('\'');
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts a missions container detached (<c>-d</c>) and polls its <c>/health</c>
|
||||
/// endpoint over the shared e2e network until it responds 200 (or
|
||||
/// <paramref name="readyTimeout"/> elapses). Used by tests that need a
|
||||
/// running SUT with non-default env (NFT-SEC-12 HTTP-not-HTTPS,
|
||||
/// NFT-SEC-13 CORS preflight) — the test then drives the container
|
||||
/// over the network and reads <c>docker logs</c> for log-line assertions.
|
||||
/// </summary>
|
||||
public static async Task<DetachedContainer> StartAndWaitForHealthAsync(
|
||||
string containerName,
|
||||
IReadOnlyDictionary<string, string> envOverrides,
|
||||
TimeSpan readyTimeout)
|
||||
{
|
||||
ForceRemove(containerName);
|
||||
var args = BuildRunArgs(containerName, envOverrides);
|
||||
Run("docker", args, out _, out _);
|
||||
|
||||
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(2) };
|
||||
var healthUrl = new Uri($"http://{containerName}:8080/health");
|
||||
var deadline = DateTime.UtcNow + readyTimeout;
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var resp = await http.GetAsync(healthUrl);
|
||||
if (resp.StatusCode == HttpStatusCode.OK)
|
||||
return new DetachedContainer(containerName);
|
||||
}
|
||||
catch (HttpRequestException) { /* container not yet listening */ }
|
||||
catch (TaskCanceledException) { /* slow first response */ }
|
||||
await Task.Delay(500);
|
||||
}
|
||||
|
||||
// Health never came up — capture logs for the failure message before
|
||||
// tearing down, so the test reporter shows why the harness gave up.
|
||||
var logs = ReadLogs(containerName);
|
||||
ForceRemove(containerName);
|
||||
throw new TimeoutException(
|
||||
$"container '{containerName}' did not become healthy within {readyTimeout.TotalSeconds:F0}s. " +
|
||||
$"Logs:\n{logs}");
|
||||
}
|
||||
|
||||
public sealed class DetachedContainer : IDisposable
|
||||
{
|
||||
public string Name { get; }
|
||||
public DetachedContainer(string name) => Name = name;
|
||||
public string ReadLogs() => MissionsContainerHelper.ReadLogs(Name);
|
||||
public void Dispose() => ForceRemove(Name);
|
||||
}
|
||||
|
||||
private static string BuildRunArgs(
|
||||
string containerName,
|
||||
IReadOnlyDictionary<string, string> envOverrides)
|
||||
{
|
||||
var sb = new System.Text.StringBuilder();
|
||||
sb.Append("run --rm -d ");
|
||||
sb.Append("--name ").Append(containerName).Append(' ');
|
||||
sb.Append("--network ").Append(Network).Append(' ');
|
||||
foreach (var (key, value) in envOverrides)
|
||||
{
|
||||
sb.Append("-e ").Append(key).Append('=').Append('"')
|
||||
.Append(value.Replace("\"", "\\\"", StringComparison.Ordinal))
|
||||
.Append("\" ");
|
||||
}
|
||||
sb.Append(Image);
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static bool TryGetExitCode(string containerName, out int exitCode)
|
||||
{
|
||||
// `docker inspect` succeeds while the container exists (running OR
|
||||
// exited). Once `--rm` removes it the inspect call fails — but we
|
||||
// already captured exitCode by then.
|
||||
var psi = new ProcessStartInfo("docker",
|
||||
$"inspect --format '{{{{.State.Running}}}} {{{{.State.ExitCode}}}}' {containerName}")
|
||||
{
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false
|
||||
};
|
||||
using var p = Process.Start(psi)
|
||||
?? throw new InvalidOperationException("docker CLI not available");
|
||||
var stdout = p.StandardOutput.ReadToEnd();
|
||||
p.WaitForExit();
|
||||
if (p.ExitCode != 0)
|
||||
{
|
||||
// Container is gone (already removed); treat as "still in flight".
|
||||
exitCode = 0;
|
||||
return false;
|
||||
}
|
||||
var parts = stdout.Trim().Trim('\'').Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length < 2 ||
|
||||
!bool.TryParse(parts[0], out var running) ||
|
||||
!int.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out exitCode))
|
||||
{
|
||||
exitCode = 0;
|
||||
return false;
|
||||
}
|
||||
return !running;
|
||||
}
|
||||
|
||||
internal static string ReadLogs(string containerName)
|
||||
{
|
||||
var psi = new ProcessStartInfo("docker", $"logs {containerName}")
|
||||
{
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false
|
||||
};
|
||||
using var p = Process.Start(psi);
|
||||
if (p is null) return string.Empty;
|
||||
var stdout = p.StandardOutput.ReadToEnd();
|
||||
var stderr = p.StandardError.ReadToEnd();
|
||||
p.WaitForExit();
|
||||
return stdout + stderr;
|
||||
}
|
||||
|
||||
private static void ForceRemove(string containerName)
|
||||
{
|
||||
var psi = new ProcessStartInfo("docker", $"rm -f {containerName}")
|
||||
{
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false
|
||||
};
|
||||
try
|
||||
{
|
||||
using var p = Process.Start(psi);
|
||||
p?.WaitForExit();
|
||||
}
|
||||
catch (System.ComponentModel.Win32Exception)
|
||||
{
|
||||
// docker CLI absent — let the caller's Enabled check surface the issue.
|
||||
throw new InvalidOperationException(
|
||||
"docker CLI not available in test container; " +
|
||||
"MissionsContainerHelper requires docker access (set COMPOSE_RESTART_ENABLED=1 and mount the socket).");
|
||||
}
|
||||
}
|
||||
|
||||
private static void Run(string file, string args, out string stdout, out string stderr)
|
||||
{
|
||||
var psi = new ProcessStartInfo(file, args)
|
||||
{
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false
|
||||
};
|
||||
using var p = Process.Start(psi)
|
||||
?? throw new InvalidOperationException($"failed to launch `{file} {args}`");
|
||||
stdout = p.StandardOutput.ReadToEnd();
|
||||
stderr = p.StandardError.ReadToEnd();
|
||||
p.WaitForExit();
|
||||
if (p.ExitCode != 0)
|
||||
throw new InvalidOperationException(
|
||||
$"`{file} {args}` exited {p.ExitCode}.\nstdout: {stdout}\nstderr: {stderr}");
|
||||
}
|
||||
|
||||
public sealed record RunResult(int ExitCode, string Logs, string RunStdout, string RunStderr);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using Azaion.Missions.E2E.Fixtures;
|
||||
using Azaion.Missions.E2E.Helpers;
|
||||
using Npgsql;
|
||||
using Xunit;
|
||||
|
||||
namespace Azaion.Missions.E2E.Tests.Errors;
|
||||
|
||||
/// <summary>
|
||||
/// FT-N-08 — destructive scenario: side-channel DROP TABLE vehicles
|
||||
/// forces the SUT into the generic catch path; the response must redact
|
||||
/// internals (statusCode/message envelope), and the unhandled exception
|
||||
/// must land in the container log within 2s.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Owns its own xUnit collection because the DROP corrupts the schema for
|
||||
/// every other test class. Teardown uses <see cref="ComposeRestartFixture"/>
|
||||
/// (down -v && up -d) which requires <c>COMPOSE_RESTART_ENABLED=1</c>.
|
||||
/// When the fixture is disabled (developer inner-loop), the test skips with
|
||||
/// a clear reason — silent passing is rejected by the contract.
|
||||
/// </remarks>
|
||||
[Collection("ErrorEnvelope500")]
|
||||
[Trait("Category", "Blackbox")]
|
||||
[Trait("db_access", "seed-or-assert-only")]
|
||||
public sealed class Error500Tests : TestBase, IClassFixture<ComposeRestartFixture>
|
||||
{
|
||||
private readonly ComposeRestartFixture _restart;
|
||||
|
||||
public Error500Tests(ComposeRestartFixture restart) => _restart = restart;
|
||||
|
||||
[SkippableFact]
|
||||
[Trait("Traces", "AC-8.6,AC-10.3")]
|
||||
[Trait("max_ms", "5000")]
|
||||
public async Task FT_N_08_generic_500_returns_redacted_body_and_logs_unhandled_exception()
|
||||
{
|
||||
Skip.IfNot(_restart.Enabled,
|
||||
"ComposeRestartFixture disabled (COMPOSE_RESTART_ENABLED!=1). " +
|
||||
"FT-N-08 is destructive and requires `compose down -v && up -d` " +
|
||||
"in teardown to restore the schema.");
|
||||
|
||||
// Arrange — drop the vehicles table; the migrator that runs at
|
||||
// missions startup is the only thing that re-creates it.
|
||||
DbResetFixture.ResetDatabase(TestEnvironment.DbSideChannel);
|
||||
DropVehiclesTable();
|
||||
|
||||
var requestStart = DateTime.UtcNow;
|
||||
var token = await Tokens.MintDefaultAsync();
|
||||
|
||||
try
|
||||
{
|
||||
// Act
|
||||
using var http = new HttpRequestMessage(
|
||||
HttpMethod.Get, $"/vehicles/{Guid.NewGuid()}");
|
||||
http.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Jwt);
|
||||
using var response = await Missions.SendAsync(http);
|
||||
|
||||
// Assert — body redacts internals.
|
||||
await HttpAssertions.AssertStatusAsync(response, HttpStatusCode.InternalServerError)
|
||||
;
|
||||
var raw = await response.Content.ReadAsStringAsync();
|
||||
using var doc = JsonDocument.Parse(raw);
|
||||
var root = doc.RootElement;
|
||||
Assert.Equal(500, root.GetProperty("statusCode").GetInt32());
|
||||
Assert.Equal("Internal server error", root.GetProperty("message").GetString());
|
||||
|
||||
// Reject extra keys (no stack leak via key names like 'exception',
|
||||
// 'stackTrace', 'inner', etc.).
|
||||
HttpAssertions.AssertNoStackLeak(root);
|
||||
|
||||
// Stacktrace must land in the SUT container log.
|
||||
var deadline = DateTime.UtcNow.AddSeconds(2);
|
||||
var logFound = false;
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
if (DockerLogsContain("missions-sut", "Unhandled exception", requestStart))
|
||||
{
|
||||
logFound = true;
|
||||
break;
|
||||
}
|
||||
await Task.Delay(100);
|
||||
}
|
||||
Assert.True(logFound,
|
||||
"expected 'Unhandled exception' in missions-sut docker logs within 2s of request");
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Teardown — full stack restart so subsequent tests start clean.
|
||||
_restart.RestartStack();
|
||||
}
|
||||
}
|
||||
|
||||
private static void DropVehiclesTable()
|
||||
{
|
||||
using var conn = new NpgsqlConnection(TestEnvironment.DbSideChannel);
|
||||
conn.Open();
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "DROP TABLE IF EXISTS vehicles CASCADE;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
private static bool DockerLogsContain(string container, string needle, DateTime sinceUtc)
|
||||
{
|
||||
var since = sinceUtc.ToString("yyyy-MM-ddTHH:mm:ssZ",
|
||||
System.Globalization.CultureInfo.InvariantCulture);
|
||||
var psi = new ProcessStartInfo("docker", $"logs --since {since} {container}")
|
||||
{
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false
|
||||
};
|
||||
try
|
||||
{
|
||||
using var p = Process.Start(psi)
|
||||
?? throw new InvalidOperationException("docker command not available");
|
||||
// docker logs interleaves stdout/stderr; ASP.NET Core writes
|
||||
// exception text to stderr in default config.
|
||||
var stdout = p.StandardOutput.ReadToEnd();
|
||||
var stderr = p.StandardError.ReadToEnd();
|
||||
p.WaitForExit();
|
||||
return stdout.Contains(needle, StringComparison.Ordinal)
|
||||
|| stderr.Contains(needle, StringComparison.Ordinal);
|
||||
}
|
||||
catch (System.ComponentModel.Win32Exception)
|
||||
{
|
||||
// No docker CLI in PATH — surface, do not silently pass.
|
||||
throw new InvalidOperationException(
|
||||
"docker CLI not available in test container; cannot assert log content for FT-N-08. " +
|
||||
"Mount /var/run/docker.sock and install docker-cli in the e2e-consumer image.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text.Json;
|
||||
using Azaion.Missions.E2E.Fixtures;
|
||||
using Azaion.Missions.E2E.Helpers;
|
||||
using Xunit;
|
||||
|
||||
namespace Azaion.Missions.E2E.Tests.Health;
|
||||
|
||||
/// <summary>
|
||||
/// FT-P-16 (anonymous 200) and FT-P-17 (200 with PG stopped). FT-P-17 is a
|
||||
/// SkippableFact: it runs only when COMPOSE_RESTART_ENABLED=1 and the e2e
|
||||
/// container has docker CLI access; otherwise it skips with a clear reason.
|
||||
/// Traces: AC-7.1, AC-7.2, AC-7.3.
|
||||
/// </summary>
|
||||
[Collection("Health")]
|
||||
[Trait("Category", "Blackbox")]
|
||||
public sealed class HealthTests : TestBase, IClassFixture<PostgresStopStartFixture>
|
||||
{
|
||||
private readonly PostgresStopStartFixture _pg;
|
||||
|
||||
public HealthTests(PostgresStopStartFixture pg) => _pg = pg;
|
||||
|
||||
[Fact]
|
||||
[Trait("Traces", "AC-7.1")]
|
||||
[Trait("max_ms", "2000")]
|
||||
public async Task FT_P_16_health_returns_200_anonymous_with_lowercase_status_key()
|
||||
{
|
||||
// Arrange
|
||||
using var http = new HttpRequestMessage(HttpMethod.Get, "/health");
|
||||
// Explicitly NO Authorization header — health is anonymous.
|
||||
|
||||
// Act
|
||||
using var response = await Missions.SendAsync(http);
|
||||
|
||||
// Assert
|
||||
await HttpAssertions.AssertStatusAsync(response, HttpStatusCode.OK);
|
||||
var raw = await response.Content.ReadAsStringAsync();
|
||||
using var doc = JsonDocument.Parse(raw);
|
||||
var root = doc.RootElement;
|
||||
// The anonymous-object literal in Program.cs declares the key as
|
||||
// lowercase "status"; assert that exact contract — a future global
|
||||
// PascalCase shift would break consumers.
|
||||
Assert.True(root.TryGetProperty("status", out var statusEl), $"missing 'status' key: {raw}");
|
||||
Assert.Equal("healthy", statusEl.GetString());
|
||||
// Reject any extra keys to pin the envelope.
|
||||
var extras = root.EnumerateObject().Select(p => p.Name)
|
||||
.Where(n => n != "status").ToArray();
|
||||
Assert.True(extras.Length == 0,
|
||||
$"unexpected extra keys in /health body: {string.Join(",", extras)}");
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Trait("Traces", "AC-7.2,AC-7.3")]
|
||||
[Trait("max_ms", "5000")]
|
||||
public async Task FT_P_17_health_returns_200_with_postgres_stopped_proves_no_db_ping()
|
||||
{
|
||||
Skip.IfNot(_pg.Enabled,
|
||||
"PostgresStopStartFixture disabled (COMPOSE_RESTART_ENABLED!=1). " +
|
||||
"Enable in CI; locally this scenario requires docker socket access.");
|
||||
|
||||
// Arrange
|
||||
_pg.Stop();
|
||||
try
|
||||
{
|
||||
using var http = new HttpRequestMessage(HttpMethod.Get, "/health");
|
||||
|
||||
// Act
|
||||
using var response = await Missions.SendAsync(http);
|
||||
|
||||
// Assert
|
||||
await HttpAssertions.AssertStatusAsync(response, HttpStatusCode.OK);
|
||||
var raw = await response.Content.ReadAsStringAsync();
|
||||
using var doc = JsonDocument.Parse(raw);
|
||||
Assert.Equal("healthy", doc.RootElement.GetProperty("status").GetString());
|
||||
}
|
||||
finally
|
||||
{
|
||||
_pg.Start();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
using Xunit;
|
||||
|
||||
namespace Azaion.Missions.E2E.Tests.Health;
|
||||
|
||||
/// <summary>
|
||||
/// Discovery-only smoke test for the Health category. Real Health scenarios
|
||||
/// (FT-P-16..17, FT-N-08) land in AZ-579.
|
||||
/// </summary>
|
||||
public sealed class Sanity
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Category", "Blackbox")]
|
||||
[Trait("Traces", "AC-3")]
|
||||
public void Discovery_smoke_test_runs()
|
||||
{
|
||||
// Arrange
|
||||
const int sentinel = 1;
|
||||
// Act
|
||||
var result = sentinel + 0;
|
||||
// Assert
|
||||
Assert.Equal(1, result);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Azaion.Missions.E2E.Helpers;
|
||||
using Xunit;
|
||||
|
||||
namespace Azaion.Missions.E2E.Tests;
|
||||
@@ -73,6 +74,19 @@ public sealed class InfrastructureSanity
|
||||
Assert.NotNull(rotateBody);
|
||||
Assert.False(beforeKids.Contains(rotateBody!.Kid), "rotation returned the same kid as before");
|
||||
Assert.Contains(rotateBody.Kid, afterKids);
|
||||
|
||||
// Cleanup — every test that hits /rotate-key MUST force a missions
|
||||
// JWKS refresh afterwards or every subsequent test in the suite gets
|
||||
// 401 (the new mock kid isn't in missions' cached JWKS). The
|
||||
// 5-minute MinimumAutomaticRefreshInterval floor in the library
|
||||
// means we cannot rely on the proactive refresh path.
|
||||
using var missions = new HttpClient
|
||||
{
|
||||
BaseAddress = new Uri(TestEnvironment.MissionsBaseUrl),
|
||||
Timeout = TimeSpan.FromSeconds(15),
|
||||
};
|
||||
var refreshedKids = await JwksRefreshHelper.ForceRefreshAsync(missions);
|
||||
Assert.Contains(rotateBody.Kid, refreshedKids);
|
||||
}
|
||||
|
||||
private sealed record JwksDocument(
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using Azaion.Missions.E2E.Fixtures;
|
||||
using Azaion.Missions.E2E.Helpers;
|
||||
using Xunit;
|
||||
|
||||
namespace Azaion.Missions.E2E.Tests.Missions;
|
||||
|
||||
/// <summary>
|
||||
/// FT-P-12 — mission cascade delete walks every dependency table.
|
||||
/// Owns its own xUnit collection (<c>CascadeF3</c>) because the F3 fixture
|
||||
/// is destructive and must run with a fresh DB per scenario.
|
||||
/// Compares per-table counts against
|
||||
/// <c>_docs/00_problem/input_data/expected_results/cascade_F3_walk.json</c>
|
||||
/// via deep JSON diff (results_report.md row 3.1).
|
||||
/// </summary>
|
||||
[Collection("CascadeF3")]
|
||||
[Trait("Category", "Blackbox")]
|
||||
[Trait("db_access", "seed-or-assert-only")]
|
||||
public sealed class CascadeF3Tests : TestBase, IClassFixture<CascadeF3Fixture>
|
||||
{
|
||||
public CascadeF3Tests(CascadeF3Fixture _) { /* fixture seeds the DB. */ }
|
||||
|
||||
[Fact]
|
||||
[Trait("Traces", "AC-3.1")]
|
||||
[Trait("max_ms", "10000")]
|
||||
public async Task FT_P_12_mission_cascade_walks_every_dependency_table()
|
||||
{
|
||||
// Arrange — load the canonical walk JSON to assert pre-state and post-state.
|
||||
// The expected_results directory is mounted directly at /app/fixtures
|
||||
// (see docker-compose.test.yml e2e-consumer volumes), so SQL fixtures
|
||||
// and JSON walks live side-by-side under the same root.
|
||||
var walkJson = JsonDocument.Parse(File.ReadAllText(
|
||||
Path.Combine(
|
||||
Environment.GetEnvironmentVariable("FIXTURE_SQL_DIR") ?? "/app/fixtures",
|
||||
"cascade_F3_walk.json")));
|
||||
var preState = walkJson.RootElement.GetProperty("expected_per_table_pre_state_for_safety_check");
|
||||
|
||||
// Refresh the F3 fixture into a known state — IClassFixture seeds once
|
||||
// per class, but we want a clean walk for this single scenario.
|
||||
DbResetFixture.ResetDatabase(TestEnvironment.DbSideChannel);
|
||||
StubSchema.EnsureCreated();
|
||||
Seeds.Apply(FixtureSql.Load("fixture_cascade_F3"));
|
||||
|
||||
// Sanity-check the pre-state — if the seed fixture failed silently, the
|
||||
// post-state assertions would trivially pass and mask the failure.
|
||||
Assert.Equal(preState.GetProperty("missions").GetInt32(),
|
||||
(int)DbAssertions.TableRowCount("missions"));
|
||||
Assert.Equal(preState.GetProperty("waypoints").GetInt32(),
|
||||
(int)DbAssertions.TableRowCount("waypoints"));
|
||||
Assert.Equal(preState.GetProperty("map_objects").GetInt32(),
|
||||
(int)DbAssertions.TableRowCount("map_objects"));
|
||||
Assert.Equal(preState.GetProperty("media").GetInt32(),
|
||||
(int)DbAssertions.TableRowCount("media"));
|
||||
Assert.Equal(preState.GetProperty("annotations").GetInt32(),
|
||||
(int)DbAssertions.TableRowCount("annotations"));
|
||||
Assert.Equal(preState.GetProperty("detection").GetInt32(),
|
||||
(int)DbAssertions.TableRowCount("detection"));
|
||||
|
||||
var token = await Tokens.MintDefaultAsync();
|
||||
|
||||
// Act
|
||||
using var http = new HttpRequestMessage(
|
||||
HttpMethod.Delete, $"/missions/{CascadeF3Fixture.MissionId}");
|
||||
http.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Jwt);
|
||||
using var response = await Missions.SendAsync(http);
|
||||
|
||||
// Assert
|
||||
await HttpAssertions.AssertStatusAsync(response, HttpStatusCode.NoContent);
|
||||
var bodyLength = (await response.Content.ReadAsByteArrayAsync()).Length;
|
||||
Assert.Equal(0, bodyLength);
|
||||
|
||||
// The walk JSON pins per-table post-state filters; assert each one.
|
||||
var postState = walkJson.RootElement.GetProperty("expected_per_table_post_state");
|
||||
AssertCount("missions", "id = '22222222-0000-0000-0000-000000000001'", 0);
|
||||
AssertCount("waypoints", "mission_id = '22222222-0000-0000-0000-000000000001'", 0);
|
||||
AssertCount("map_objects", "mission_id = '22222222-0000-0000-0000-000000000001'", 0);
|
||||
AssertCount("media", "id IN ('media-fixture-001', 'media-fixture-002')", 0);
|
||||
AssertCount("annotations", "id IN ('anno-fixture-001', 'anno-fixture-002')", 0);
|
||||
AssertCount("detection", "annotation_id IN ('anno-fixture-001', 'anno-fixture-002')", 0);
|
||||
|
||||
// Sanity: the walk JSON has the same expectations we just asserted — fail
|
||||
// loudly if the JSON is out of sync with the in-source filters.
|
||||
Assert.Equal(0, postState.GetProperty("missions").GetProperty("expected_count").GetInt32());
|
||||
}
|
||||
|
||||
private static void AssertCount(string table, string filterSql, long expected)
|
||||
{
|
||||
if (!table.All(c => char.IsLetterOrDigit(c) || c == '_'))
|
||||
throw new ArgumentException($"unsafe table identifier '{table}'", nameof(table));
|
||||
var actual = DbAssertions.ScalarCount($"SELECT COUNT(*) FROM {table} WHERE {filterSql}");
|
||||
Assert.Equal(expected, actual);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using Azaion.Missions.E2E.Fixtures;
|
||||
using Azaion.Missions.E2E.Helpers;
|
||||
using Npgsql;
|
||||
using Xunit;
|
||||
|
||||
namespace Azaion.Missions.E2E.Tests.Missions;
|
||||
|
||||
/// <summary>
|
||||
/// FT-N-06 — DELETE /missions/{missing_uuid} must short-circuit on the
|
||||
/// initial existence check (Step 1 of the cascade walk) and emit ZERO
|
||||
/// DELETE statements against any dependency table. The contract protects
|
||||
/// downstream consumers from typo'd UUIDs silently corrupting unrelated
|
||||
/// missions' data (results_report.md row 3.2 / AC-3.2).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The strict assertion uses two independent signals: (1) per-table row
|
||||
/// counts before and after must match, AND (2) when
|
||||
/// <c>pg_stat_statements</c> is available, the post-request query stats
|
||||
/// must contain ZERO <c>DELETE FROM map_objects/waypoints/media/...</c>
|
||||
/// rows attributable to this request window.
|
||||
/// Without pg_stat_statements (e.g. extension not preloaded in the
|
||||
/// postgres image), the test still asserts the row-count invariant and
|
||||
/// records a warning trait — silent passing is rejected by the
|
||||
/// row-count check.
|
||||
/// </remarks>
|
||||
[Collection("CascadeShortCircuit")]
|
||||
[Trait("Category", "Blackbox")]
|
||||
[Trait("db_access", "seed-or-assert-only")]
|
||||
public sealed class CascadeShortCircuitTests : TestBase
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Traces", "AC-3.2")]
|
||||
[Trait("max_ms", "5000")]
|
||||
public async Task FT_N_06_delete_missing_mission_emits_zero_dependency_table_deletes()
|
||||
{
|
||||
// Arrange — clean DB, F3 fixture for a populated cascade chain.
|
||||
DbResetFixture.ResetDatabase(TestEnvironment.DbSideChannel);
|
||||
StubSchema.EnsureCreated();
|
||||
Seeds.Apply(FixtureSql.Load("fixture_cascade_F3"));
|
||||
|
||||
// Try to attach pg_stat_statements; fall back gracefully if the
|
||||
// extension isn't preloaded.
|
||||
var pgssAvailable = TryEnablePgStatStatements();
|
||||
if (pgssAvailable) ResetPgStatStatements();
|
||||
|
||||
var token = await Tokens.MintDefaultAsync();
|
||||
var notInDb = Guid.NewGuid();
|
||||
|
||||
// Pre-state row counts — these must equal post-state counts iff the
|
||||
// cascade short-circuited correctly.
|
||||
var pre = SnapshotCounts();
|
||||
|
||||
// Act
|
||||
using var http = new HttpRequestMessage(HttpMethod.Delete, $"/missions/{notInDb}");
|
||||
http.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Jwt);
|
||||
using var response = await Missions.SendAsync(http);
|
||||
|
||||
// Assert
|
||||
await HttpAssertions.AssertProblemEnvelopeAsync(response, HttpStatusCode.NotFound)
|
||||
;
|
||||
|
||||
var post = SnapshotCounts();
|
||||
foreach (var table in pre.Keys)
|
||||
{
|
||||
Assert.True(pre[table] == post[table],
|
||||
$"row count for '{table}' changed after a 404 cascade: " +
|
||||
$"pre={pre[table]} post={post[table]} — short-circuit failed");
|
||||
}
|
||||
|
||||
if (pgssAvailable)
|
||||
{
|
||||
var deleteCount = ScalarCountSql("""
|
||||
SELECT COUNT(*) FROM pg_stat_statements
|
||||
WHERE query ILIKE '%DELETE FROM map_objects%'
|
||||
OR query ILIKE '%DELETE FROM waypoints%'
|
||||
OR query ILIKE '%DELETE FROM media%'
|
||||
OR query ILIKE '%DELETE FROM annotations%'
|
||||
OR query ILIKE '%DELETE FROM detection%'
|
||||
OR query ILIKE '%DELETE FROM missions%'
|
||||
""");
|
||||
Assert.True(deleteCount == 0,
|
||||
$"pg_stat_statements shows {deleteCount} DELETE statements against " +
|
||||
"cascade tables after a 404 — short-circuit failed at the SQL layer");
|
||||
}
|
||||
}
|
||||
|
||||
private static Dictionary<string, long> SnapshotCounts()
|
||||
{
|
||||
var tables = new[] { "missions", "waypoints", "map_objects",
|
||||
"media", "annotations", "detection" };
|
||||
return tables.ToDictionary(t => t, DbAssertions.TableRowCount);
|
||||
}
|
||||
|
||||
private static bool TryEnablePgStatStatements()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var conn = new NpgsqlConnection(TestEnvironment.DbSideChannel);
|
||||
conn.Open();
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "CREATE EXTENSION IF NOT EXISTS pg_stat_statements;";
|
||||
cmd.ExecuteNonQuery();
|
||||
return true;
|
||||
}
|
||||
catch (PostgresException ex)
|
||||
{
|
||||
// Most common cause: the extension is not in
|
||||
// shared_preload_libraries. Surface the reason — skipping
|
||||
// silently would defeat the purpose of this test.
|
||||
Console.WriteLine(
|
||||
$"[FT-N-06] pg_stat_statements unavailable ({ex.SqlState}: {ex.MessageText}); " +
|
||||
"falling back to row-count short-circuit assertion only.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static void ResetPgStatStatements()
|
||||
{
|
||||
using var conn = new NpgsqlConnection(TestEnvironment.DbSideChannel);
|
||||
conn.Open();
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT pg_stat_statements_reset();";
|
||||
cmd.ExecuteScalar();
|
||||
}
|
||||
|
||||
private static long ScalarCountSql(string sql)
|
||||
{
|
||||
using var conn = new NpgsqlConnection(TestEnvironment.DbSideChannel);
|
||||
conn.Open();
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = sql;
|
||||
var result = cmd.ExecuteScalar();
|
||||
if (result is null || result is DBNull)
|
||||
throw new InvalidOperationException($"scalar query returned NULL: {sql}");
|
||||
return Convert.ToInt64(result, System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using Azaion.Missions.E2E.Fixtures;
|
||||
using Azaion.Missions.E2E.Helpers;
|
||||
using Xunit;
|
||||
|
||||
namespace Azaion.Missions.E2E.Tests.Missions;
|
||||
|
||||
/// <summary>
|
||||
/// FT-N-04 (carry-forward 400 for bogus VehicleId) and FT-N-05 (GET 404).
|
||||
/// FT-N-06 (cascade short-circuit) lives in <see cref="CascadeShortCircuitTests"/>
|
||||
/// because it manipulates Postgres logging and owns its own collection.
|
||||
/// Traces: AC-2.2 (carry-forward), AC-2.4 / AC-8.2.
|
||||
/// </summary>
|
||||
[Collection("Missions")]
|
||||
[Trait("Category", "Blackbox")]
|
||||
[Trait("db_access", "seed-or-assert-only")]
|
||||
public sealed class NegativeTests : TestBase, IClassFixture<DbResetFixture>
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Traces", "AC-2.2")]
|
||||
[Trait("max_ms", "2000")]
|
||||
[Trait("carry_forward", "AC-2.2")]
|
||||
public async Task FT_N_04_create_mission_with_bogus_vehicle_id_returns_400_today()
|
||||
{
|
||||
// CARRY-FORWARD: spec wants 404 (results_report.md row 2.2 carry-forward).
|
||||
// Today the SUT throws ArgumentException → ErrorHandlingMiddleware maps
|
||||
// to 400. Flip to 404 expectation when the divergence is closed.
|
||||
|
||||
// Arrange
|
||||
DbResetFixture.ResetDatabase(TestEnvironment.DbSideChannel);
|
||||
var token = await Tokens.MintDefaultAsync();
|
||||
var bogusVehicleId = Guid.NewGuid();
|
||||
|
||||
// Act
|
||||
using var http = new HttpRequestMessage(HttpMethod.Post, "/missions")
|
||||
{
|
||||
Content = JsonContent.Create(new
|
||||
{
|
||||
Name = "x",
|
||||
VehicleId = bogusVehicleId,
|
||||
CreatedDate = (DateTime?)null
|
||||
})
|
||||
};
|
||||
http.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Jwt);
|
||||
using var response = await Missions.SendAsync(http);
|
||||
|
||||
// Assert
|
||||
await HttpAssertions.AssertProblemEnvelopeAsync(response, HttpStatusCode.BadRequest)
|
||||
;
|
||||
var missionsRows = DbAssertions.TableRowCount("missions");
|
||||
Assert.Equal(0L, missionsRows);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Traces", "AC-2.4,AC-8.2")]
|
||||
[Trait("max_ms", "2000")]
|
||||
public async Task FT_N_05_get_mission_returns_404_with_problem_envelope()
|
||||
{
|
||||
// Arrange
|
||||
DbResetFixture.ResetDatabase(TestEnvironment.DbSideChannel);
|
||||
var token = await Tokens.MintDefaultAsync();
|
||||
var randomId = Guid.NewGuid();
|
||||
|
||||
// Act
|
||||
using var http = new HttpRequestMessage(HttpMethod.Get, $"/missions/{randomId}");
|
||||
http.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Jwt);
|
||||
using var response = await Missions.SendAsync(http);
|
||||
|
||||
// Assert
|
||||
await HttpAssertions.AssertProblemEnvelopeAsync(response, HttpStatusCode.NotFound)
|
||||
;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Azaion.Missions.E2E.Fixtures;
|
||||
using Azaion.Missions.E2E.Helpers;
|
||||
using Xunit;
|
||||
|
||||
namespace Azaion.Missions.E2E.Tests.Missions;
|
||||
|
||||
/// <summary>
|
||||
/// FT-P-07..11 — mission happy-path scenarios from
|
||||
/// <c>_docs/02_document/tests/blackbox-tests.md § Positive</c>.
|
||||
/// FT-P-12 (cascade delete) lives in <see cref="CascadeF3Tests"/> because
|
||||
/// it owns its own xUnit collection (the F3 fixture is destructive).
|
||||
/// Traces: AC-2.1 / AC-2.3 / AC-2.4 / AC-2.5 / AC-2.7.
|
||||
/// </summary>
|
||||
[Collection("Missions")]
|
||||
[Trait("Category", "Blackbox")]
|
||||
[Trait("db_access", "seed-or-assert-only")]
|
||||
public sealed class PositiveTests : TestBase, IClassFixture<DbResetFixture>
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Traces", "AC-2.1")]
|
||||
[Trait("max_ms", "5000")]
|
||||
public async Task FT_P_07_create_mission_defaults_created_date_to_utc_now()
|
||||
{
|
||||
// Arrange
|
||||
DbResetFixture.ResetDatabase(TestEnvironment.DbSideChannel);
|
||||
Seeds.Apply(Seeds.OneDefaultVehicle.Sql);
|
||||
var vehicleId = Seeds.OneDefaultVehicle.Id;
|
||||
var token = await Tokens.MintDefaultAsync();
|
||||
|
||||
// Act
|
||||
var t0 = DateTime.UtcNow;
|
||||
using var http = new HttpRequestMessage(HttpMethod.Post, "/missions")
|
||||
{
|
||||
Content = JsonContent.Create(new
|
||||
{
|
||||
Name = "Recon-01",
|
||||
VehicleId = vehicleId,
|
||||
CreatedDate = (DateTime?)null
|
||||
})
|
||||
};
|
||||
http.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Jwt);
|
||||
using var response = await Missions.SendAsync(http);
|
||||
|
||||
// Assert
|
||||
await HttpAssertions.AssertStatusAsync(response, HttpStatusCode.Created);
|
||||
var mission = await response.Content.ReadFromJsonAsync<MissionDto>() ?? throw new InvalidOperationException("created mission body deserialized to null");
|
||||
|
||||
var drift = (mission.CreatedDate.ToUniversalTime() - t0).Duration();
|
||||
Assert.True(drift <= TimeSpan.FromSeconds(5),
|
||||
$"CreatedDate drift {drift.TotalSeconds:F2}s exceeds 5s tolerance ({mission.CreatedDate:o} vs {t0:o})");
|
||||
Assert.Equal("Recon-01", mission.Name);
|
||||
Assert.Equal(vehicleId, mission.VehicleId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Traces", "AC-2.3,AC-8.7")]
|
||||
[Trait("max_ms", "2000")]
|
||||
[Trait("carry_forward", "json-camelcase-vs-pascalcase")]
|
||||
public async Task FT_P_08_list_returns_paginated_response_in_desc_order_with_case_insensitive_filter()
|
||||
{
|
||||
// Arrange
|
||||
DbResetFixture.ResetDatabase(TestEnvironment.DbSideChannel);
|
||||
Seeds.Apply(Seeds.TwentyFiveMissions.Sql);
|
||||
var token = await Tokens.MintDefaultAsync();
|
||||
|
||||
// Act
|
||||
using var http = new HttpRequestMessage(HttpMethod.Get, "/missions");
|
||||
http.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Jwt);
|
||||
using var response = await Missions.SendAsync(http);
|
||||
var raw = await response.Content.ReadAsStringAsync();
|
||||
|
||||
using var http2 = new HttpRequestMessage(HttpMethod.Get, "/missions?name=re");
|
||||
http2.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Jwt);
|
||||
using var response2 = await Missions.SendAsync(http2);
|
||||
var page2Raw = await response2.Content.ReadAsStringAsync();
|
||||
|
||||
// Assert
|
||||
await HttpAssertions.AssertStatusAsync(response, HttpStatusCode.OK);
|
||||
|
||||
using var doc = JsonDocument.Parse(raw);
|
||||
var root = doc.RootElement;
|
||||
// CARRY-FORWARD (json-camelcase-vs-pascalcase): results_report.md row 2.3
|
||||
// pinned PascalCase but the SUT emits camelCase via default ASP.NET
|
||||
// Core JsonSerializerOptions. Test pins the observed shape.
|
||||
Assert.True(root.TryGetProperty("items", out var itemsEl), $"missing 'items': {raw}");
|
||||
Assert.True(root.TryGetProperty("totalCount", out var totalEl));
|
||||
Assert.True(root.TryGetProperty("page", out var pageEl));
|
||||
Assert.True(root.TryGetProperty("pageSize", out var pageSizeEl));
|
||||
Assert.False(root.TryGetProperty("Items", out _), "envelope unexpectedly PascalCase");
|
||||
|
||||
Assert.Equal(1, pageEl.GetInt32());
|
||||
Assert.Equal(20, pageSizeEl.GetInt32());
|
||||
Assert.Equal(25, totalEl.GetInt32());
|
||||
|
||||
var items = JsonSerializer.Deserialize<List<MissionDto>>(itemsEl.GetRawText())
|
||||
?? throw new InvalidOperationException("Items array deserialized to null");
|
||||
Assert.Equal(20, items.Count);
|
||||
for (var i = 0; i < items.Count - 1; i++)
|
||||
{
|
||||
Assert.True(items[i].CreatedDate >= items[i + 1].CreatedDate,
|
||||
$"DESC ordering broken at index {i}: {items[i].CreatedDate:o} < {items[i + 1].CreatedDate:o}");
|
||||
}
|
||||
|
||||
await HttpAssertions.AssertStatusAsync(response2, HttpStatusCode.OK);
|
||||
using var doc2 = JsonDocument.Parse(page2Raw);
|
||||
var totalCaseInsensitive = doc2.RootElement.GetProperty("totalCount").GetInt32();
|
||||
// The seed alternates names "Recon-NN" and "OPS-NN"; lowercase "re"
|
||||
// must match the "Recon-*" rows (>=12 of them).
|
||||
Assert.True(totalCaseInsensitive > 0,
|
||||
$"case-INSENSITIVE filter ?name=re returned 0; case-sensitive bug suspected ({page2Raw})");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Traces", "AC-2.3")]
|
||||
[Trait("max_ms", "2000")]
|
||||
public async Task FT_P_09_page_2_returns_remaining_5_disjoint_from_page_1()
|
||||
{
|
||||
// Arrange
|
||||
DbResetFixture.ResetDatabase(TestEnvironment.DbSideChannel);
|
||||
Seeds.Apply(Seeds.TwentyFiveMissions.Sql);
|
||||
var token = await Tokens.MintDefaultAsync();
|
||||
|
||||
async Task<PaginatedResponseDto<MissionDto>> FetchAsync(string query)
|
||||
{
|
||||
using var http = new HttpRequestMessage(HttpMethod.Get, "/missions?" + query);
|
||||
http.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Jwt);
|
||||
using var resp = await Missions.SendAsync(http);
|
||||
await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.OK);
|
||||
return await resp.Content.ReadFromJsonAsync<PaginatedResponseDto<MissionDto>>()
|
||||
?? throw new InvalidOperationException("paginated body deserialized to null");
|
||||
}
|
||||
|
||||
// Act
|
||||
var page1 = await FetchAsync("page=1&pageSize=20");
|
||||
var page2 = await FetchAsync("page=2&pageSize=20");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, page2.Page);
|
||||
Assert.Equal(20, page2.PageSize);
|
||||
Assert.Equal(25, page2.TotalCount);
|
||||
Assert.Equal(5, page2.Items.Count);
|
||||
|
||||
var page1Ids = page1.Items.Select(m => m.Id).ToHashSet();
|
||||
var page2Ids = page2.Items.Select(m => m.Id).ToHashSet();
|
||||
Assert.False(page1Ids.Overlaps(page2Ids),
|
||||
"page 1 and page 2 share IDs — pagination is broken");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Traces", "AC-2.3")]
|
||||
[Trait("max_ms", "2000")]
|
||||
public async Task FT_P_10_date_range_filter_is_inclusive_of_bounds()
|
||||
{
|
||||
// Arrange
|
||||
DbResetFixture.ResetDatabase(TestEnvironment.DbSideChannel);
|
||||
Seeds.Apply(Seeds.TwentyFiveMissions.Sql);
|
||||
var token = await Tokens.MintDefaultAsync();
|
||||
|
||||
// Act
|
||||
using var http = new HttpRequestMessage(
|
||||
HttpMethod.Get,
|
||||
"/missions?fromDate=2026-01-01T00:00:00Z&toDate=2026-01-31T23:59:59Z&pageSize=100");
|
||||
http.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Jwt);
|
||||
using var response = await Missions.SendAsync(http);
|
||||
|
||||
// Assert
|
||||
await HttpAssertions.AssertStatusAsync(response, HttpStatusCode.OK);
|
||||
var page = await response.Content.ReadFromJsonAsync<PaginatedResponseDto<MissionDto>>()
|
||||
?? throw new InvalidOperationException("paginated body deserialized to null");
|
||||
Assert.Equal(5, page.TotalCount);
|
||||
Assert.All(page.Items, m =>
|
||||
{
|
||||
var utc = m.CreatedDate.ToUniversalTime();
|
||||
Assert.True(utc >= new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
$"mission {m.Id} CreatedDate {utc:o} predates window");
|
||||
Assert.True(utc <= new DateTime(2026, 1, 31, 23, 59, 59, DateTimeKind.Utc),
|
||||
$"mission {m.Id} CreatedDate {utc:o} postdates window");
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Traces", "AC-2.5")]
|
||||
[Trait("max_ms", "2000")]
|
||||
public async Task FT_P_11_partial_update_preserves_null_vehicle_id()
|
||||
{
|
||||
// Arrange
|
||||
DbResetFixture.ResetDatabase(TestEnvironment.DbSideChannel);
|
||||
Seeds.Apply(Seeds.OneDefaultVehicle.Sql);
|
||||
var vehicleId = Seeds.OneDefaultVehicle.Id;
|
||||
var missionId = Guid.NewGuid();
|
||||
Seeds.Apply($"""
|
||||
INSERT INTO missions (id, created_date, name, vehicle_id)
|
||||
VALUES ('{missionId}', '2026-05-14T00:00:00Z', 'Original', '{vehicleId}');
|
||||
""");
|
||||
var token = await Tokens.MintDefaultAsync();
|
||||
|
||||
// Act
|
||||
using var http = new HttpRequestMessage(HttpMethod.Put, $"/missions/{missionId}")
|
||||
{
|
||||
Content = JsonContent.Create(new { Name = "Renamed", VehicleId = (Guid?)null })
|
||||
};
|
||||
http.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Jwt);
|
||||
using var response = await Missions.SendAsync(http);
|
||||
|
||||
// Assert
|
||||
await HttpAssertions.AssertStatusAsync(response, HttpStatusCode.OK);
|
||||
var mission = await response.Content.ReadFromJsonAsync<MissionDto>() ?? throw new InvalidOperationException("body deserialized to null");
|
||||
Assert.Equal("Renamed", mission.Name);
|
||||
Assert.Equal(vehicleId, mission.VehicleId);
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
using Xunit;
|
||||
|
||||
namespace Azaion.Missions.E2E.Tests.Missions;
|
||||
|
||||
/// <summary>
|
||||
/// Discovery-only smoke test for the Missions category. Real Missions
|
||||
/// scenarios (FT-P-07..12, FT-N-04..06) land in AZ-578.
|
||||
/// </summary>
|
||||
public sealed class Sanity
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Category", "Blackbox")]
|
||||
[Trait("Traces", "AC-3")]
|
||||
public void Discovery_smoke_test_runs()
|
||||
{
|
||||
// Arrange
|
||||
const int sentinel = 1;
|
||||
// Act
|
||||
var result = sentinel + 0;
|
||||
// Assert
|
||||
Assert.Equal(1, result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Net.Http.Headers;
|
||||
using Azaion.Missions.E2E.Fixtures;
|
||||
using Azaion.Missions.E2E.Helpers;
|
||||
using Npgsql;
|
||||
using Xunit;
|
||||
|
||||
namespace Azaion.Missions.E2E.Tests.Performance;
|
||||
|
||||
/// <summary>
|
||||
/// NFT-PERF-01..04 — wall-clock latency observations against the dockerised
|
||||
/// <c>missions</c> service. Excluded from the default CI gate via
|
||||
/// <c>--filter "Category!=Perf"</c> in <c>entrypoint.sh</c>; run via
|
||||
/// <c>scripts/run-performance-tests.sh</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Each scenario follows the same shape: seed deterministic data, warm-up
|
||||
/// 5 calls (excluded from the percentile), run N measured sequential calls
|
||||
/// recording <see cref="Stopwatch"/> wall-clock, compute P50 + P95, record
|
||||
/// them to the runtime CSV referenced by <c>PERF_RESULTS_FILE</c>, then
|
||||
/// assert against the documented gate. Sequential single-client execution
|
||||
/// keeps HTTP/1.1 connection-reuse and JIT warm-up deterministic.
|
||||
/// </remarks>
|
||||
[Collection("Perf")]
|
||||
[Trait("Category", "Perf")]
|
||||
public sealed class PerformanceTests : TestBase, IClassFixture<DbResetFixture>
|
||||
{
|
||||
private static readonly MetricCsvRecorder Csv = new("PERF_RESULTS_FILE");
|
||||
private const int WarmupCalls = 5;
|
||||
|
||||
[Fact(Timeout = 60_000)]
|
||||
[Trait("Traces", "AC-3.6")]
|
||||
[Trait("max_ms", "30000")]
|
||||
public async Task NFT_PERF_01_minimal_cascade_delete_p50_within_50ms()
|
||||
{
|
||||
// Arrange — 105 missions (100 measured + 5 warmup), each with one waypoint.
|
||||
DbResetFixture.ResetDatabase(TestEnvironment.DbSideChannel);
|
||||
Seeds.Apply(Seeds.OneDefaultVehicle.Sql);
|
||||
var (measured, warmup) = SeedSequentialMissions(105, waypointsPerMission: 1);
|
||||
await AttachAuthAsync();
|
||||
|
||||
await WarmupDeletesAsync(warmup);
|
||||
|
||||
// Act
|
||||
var latenciesMs = await MeasureSequentialDeletesAsync(measured);
|
||||
var p50 = LatencyPercentiles.P50(latenciesMs);
|
||||
var p95 = LatencyPercentiles.P95(latenciesMs);
|
||||
|
||||
Csv.Record(
|
||||
category: "Perf",
|
||||
scenario: "NFT-PERF-01",
|
||||
result: p50 <= 50.0 ? "pass" : "fail",
|
||||
traces: $"AC-3.6; P50_MS={p50.ToString("F2", CultureInfo.InvariantCulture)}; "
|
||||
+ $"P95_MS={p95.ToString("F2", CultureInfo.InvariantCulture)}");
|
||||
|
||||
// Assert
|
||||
Assert.True(p50 <= 50.0,
|
||||
$"NFT-PERF-01 P50 budget exceeded: P50={p50:F2}ms (gate=50ms), P95={p95:F2}ms");
|
||||
}
|
||||
|
||||
[Fact(Timeout = 120_000)]
|
||||
[Trait("Traces", "AC-3.1,AC-3.6")]
|
||||
[Trait("max_ms", "60000")]
|
||||
[Trait("provisional", "yes")]
|
||||
public async Task NFT_PERF_02_full_chain_cascade_delete_p50_within_200ms_provisional()
|
||||
{
|
||||
// PROVISIONAL — lock at measured + 50% on first green run.
|
||||
// Arrange — 55 F3-shaped missions (50 measured + 5 warmup).
|
||||
DbResetFixture.ResetDatabase(TestEnvironment.DbSideChannel);
|
||||
Seeds.Apply(Seeds.OneDefaultVehicle.Sql);
|
||||
StubSchema.EnsureCreated();
|
||||
var (measured, warmup) = SeedF3MissionsCascadeChains(55);
|
||||
await AttachAuthAsync();
|
||||
|
||||
await WarmupDeletesAsync(warmup);
|
||||
|
||||
// Act
|
||||
var latenciesMs = await MeasureSequentialDeletesAsync(measured);
|
||||
var p50 = LatencyPercentiles.P50(latenciesMs);
|
||||
var p95 = LatencyPercentiles.P95(latenciesMs);
|
||||
|
||||
Csv.Record(
|
||||
category: "Perf",
|
||||
scenario: "NFT-PERF-02",
|
||||
result: p50 <= 200.0 ? "pass" : "fail",
|
||||
traces: $"AC-3.1; P50_MS={p50.ToString("F2", CultureInfo.InvariantCulture)}; "
|
||||
+ $"P95_MS={p95.ToString("F2", CultureInfo.InvariantCulture)}");
|
||||
|
||||
// Assert
|
||||
Assert.True(p50 <= 200.0,
|
||||
$"NFT-PERF-02 P50 (provisional 200ms) exceeded: P50={p50:F2}ms, P95={p95:F2}ms");
|
||||
}
|
||||
|
||||
[Fact(Timeout = 30_000)]
|
||||
[Trait("Traces", "AC-7.3")]
|
||||
[Trait("max_ms", "5000")]
|
||||
public async Task NFT_PERF_03_health_p50_within_10ms()
|
||||
{
|
||||
// Arrange — no seed needed; /health is anonymous.
|
||||
for (int i = 0; i < WarmupCalls; i++)
|
||||
{
|
||||
using var resp = await Missions.GetAsync("/health");
|
||||
resp.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
// Act
|
||||
var latenciesMs = new List<double>(100);
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
using var resp = await Missions.GetAsync("/health");
|
||||
sw.Stop();
|
||||
resp.EnsureSuccessStatusCode();
|
||||
latenciesMs.Add(sw.Elapsed.TotalMilliseconds);
|
||||
}
|
||||
var p50 = LatencyPercentiles.P50(latenciesMs);
|
||||
var p95 = LatencyPercentiles.P95(latenciesMs);
|
||||
|
||||
Csv.Record(
|
||||
category: "Perf",
|
||||
scenario: "NFT-PERF-03",
|
||||
result: p50 <= 10.0 ? "pass" : "fail",
|
||||
traces: $"AC-7.3; P50_MS={p50.ToString("F2", CultureInfo.InvariantCulture)}; "
|
||||
+ $"P95_MS={p95.ToString("F2", CultureInfo.InvariantCulture)}");
|
||||
|
||||
// Assert
|
||||
Assert.True(p50 <= 10.0,
|
||||
$"NFT-PERF-03 P50 budget exceeded: P50={p50:F2}ms (gate=10ms), P95={p95:F2}ms");
|
||||
}
|
||||
|
||||
[Fact(Timeout = 90_000)]
|
||||
[Trait("Traces", "AC-2.3")]
|
||||
[Trait("max_ms", "30000")]
|
||||
[Trait("provisional", "yes")]
|
||||
public async Task NFT_PERF_04_missions_list_pagination_p95_within_100ms_provisional()
|
||||
{
|
||||
// PROVISIONAL — lock at measured + 50% on first green run.
|
||||
// Arrange — 1000 missions referencing seed_one_default_vehicle.
|
||||
DbResetFixture.ResetDatabase(TestEnvironment.DbSideChannel);
|
||||
Seeds.Apply(Seeds.OneDefaultVehicle.Sql);
|
||||
SeedSequentialMissionsNoWaypoints(1000);
|
||||
await AttachAuthAsync();
|
||||
|
||||
for (int i = 0; i < WarmupCalls; i++)
|
||||
{
|
||||
using var resp = await Missions.GetAsync("/missions?page=1&pageSize=20");
|
||||
resp.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
// Act
|
||||
var latenciesMs = new List<double>(100);
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
using var resp = await Missions.GetAsync("/missions?page=1&pageSize=20");
|
||||
sw.Stop();
|
||||
resp.EnsureSuccessStatusCode();
|
||||
latenciesMs.Add(sw.Elapsed.TotalMilliseconds);
|
||||
}
|
||||
var p50 = LatencyPercentiles.P50(latenciesMs);
|
||||
var p95 = LatencyPercentiles.P95(latenciesMs);
|
||||
|
||||
Csv.Record(
|
||||
category: "Perf",
|
||||
scenario: "NFT-PERF-04",
|
||||
result: p95 <= 100.0 ? "pass" : "fail",
|
||||
traces: $"AC-2.3; P50_MS={p50.ToString("F2", CultureInfo.InvariantCulture)}; "
|
||||
+ $"P95_MS={p95.ToString("F2", CultureInfo.InvariantCulture)}");
|
||||
|
||||
// Assert
|
||||
Assert.True(p95 <= 100.0,
|
||||
$"NFT-PERF-04 P95 (provisional 100ms) exceeded: P50={p50:F2}ms, P95={p95:F2}ms");
|
||||
}
|
||||
|
||||
private async Task AttachAuthAsync()
|
||||
{
|
||||
var t = await Tokens.MintDefaultAsync();
|
||||
Missions.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", t.Jwt);
|
||||
}
|
||||
|
||||
private async Task WarmupDeletesAsync(IReadOnlyList<Guid> warmupMissionIds)
|
||||
{
|
||||
foreach (var id in warmupMissionIds)
|
||||
{
|
||||
using var resp = await Missions.DeleteAsync($"/missions/{id}");
|
||||
// 200 or 204 are both acceptable; the cascade walks regardless.
|
||||
// 4xx would indicate a seed problem — fail loudly.
|
||||
if (!resp.IsSuccessStatusCode && (int)resp.StatusCode != 404)
|
||||
throw new InvalidOperationException(
|
||||
$"warmup DELETE /missions/{id} returned {(int)resp.StatusCode}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<List<double>> MeasureSequentialDeletesAsync(IReadOnlyList<Guid> missionIds)
|
||||
{
|
||||
var latencies = new List<double>(missionIds.Count);
|
||||
foreach (var id in missionIds)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
using var resp = await Missions.DeleteAsync($"/missions/{id}");
|
||||
sw.Stop();
|
||||
if (!resp.IsSuccessStatusCode && (int)resp.StatusCode != 404)
|
||||
throw new InvalidOperationException(
|
||||
$"measured DELETE /missions/{id} returned {(int)resp.StatusCode}");
|
||||
latencies.Add(sw.Elapsed.TotalMilliseconds);
|
||||
}
|
||||
return latencies;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns (measured, warmup) where the FIRST 5 IDs are the warmup set
|
||||
/// and the remaining (count-5) IDs are the measured set. Each mission
|
||||
/// gets the requested number of waypoints with deterministic IDs.
|
||||
/// </summary>
|
||||
private static (List<Guid> Measured, List<Guid> Warmup) SeedSequentialMissions(
|
||||
int count, int waypointsPerMission)
|
||||
{
|
||||
using var conn = new NpgsqlConnection(TestEnvironment.DbSideChannel);
|
||||
conn.Open();
|
||||
using var tx = conn.BeginTransaction();
|
||||
|
||||
var ids = new List<Guid>(count);
|
||||
var seed = new Random(98765);
|
||||
|
||||
using (var insertMission = conn.CreateCommand())
|
||||
{
|
||||
insertMission.Transaction = tx;
|
||||
insertMission.CommandText = """
|
||||
INSERT INTO missions (id, name, vehicle_id)
|
||||
VALUES (@id, @name, @vehicle_id);
|
||||
""";
|
||||
insertMission.Parameters.Add(new NpgsqlParameter("id", NpgsqlTypes.NpgsqlDbType.Uuid));
|
||||
insertMission.Parameters.Add(new NpgsqlParameter("name", NpgsqlTypes.NpgsqlDbType.Text));
|
||||
insertMission.Parameters.Add(new NpgsqlParameter("vehicle_id", NpgsqlTypes.NpgsqlDbType.Uuid));
|
||||
|
||||
using var insertWaypoint = conn.CreateCommand();
|
||||
insertWaypoint.Transaction = tx;
|
||||
insertWaypoint.CommandText = """
|
||||
INSERT INTO waypoints (id, mission_id, lat, lon, mgrs, order_num)
|
||||
VALUES (@id, @mission_id, 50.45, 30.52, '36UYA1234567', @order_num);
|
||||
""";
|
||||
insertWaypoint.Parameters.Add(new NpgsqlParameter("id", NpgsqlTypes.NpgsqlDbType.Uuid));
|
||||
insertWaypoint.Parameters.Add(new NpgsqlParameter("mission_id", NpgsqlTypes.NpgsqlDbType.Uuid));
|
||||
insertWaypoint.Parameters.Add(new NpgsqlParameter("order_num", NpgsqlTypes.NpgsqlDbType.Integer));
|
||||
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
var id = NewDeterministicGuid(seed);
|
||||
ids.Add(id);
|
||||
insertMission.Parameters["id"].Value = id;
|
||||
insertMission.Parameters["name"].Value = $"perf-mission-{i:D4}";
|
||||
insertMission.Parameters["vehicle_id"].Value = Seeds.OneDefaultVehicle.Id;
|
||||
insertMission.ExecuteNonQuery();
|
||||
|
||||
for (int w = 0; w < waypointsPerMission; w++)
|
||||
{
|
||||
insertWaypoint.Parameters["id"].Value = NewDeterministicGuid(seed);
|
||||
insertWaypoint.Parameters["mission_id"].Value = id;
|
||||
insertWaypoint.Parameters["order_num"].Value = w;
|
||||
insertWaypoint.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tx.Commit();
|
||||
var warmup = ids.Take(WarmupCalls).ToList();
|
||||
var measured = ids.Skip(WarmupCalls).ToList();
|
||||
return (measured, warmup);
|
||||
}
|
||||
|
||||
private static void SeedSequentialMissionsNoWaypoints(int count)
|
||||
{
|
||||
using var conn = new NpgsqlConnection(TestEnvironment.DbSideChannel);
|
||||
conn.Open();
|
||||
using var tx = conn.BeginTransaction();
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.Transaction = tx;
|
||||
cmd.CommandText = """
|
||||
INSERT INTO missions (id, name, vehicle_id)
|
||||
VALUES (@id, @name, @vehicle_id);
|
||||
""";
|
||||
cmd.Parameters.Add(new NpgsqlParameter("id", NpgsqlTypes.NpgsqlDbType.Uuid));
|
||||
cmd.Parameters.Add(new NpgsqlParameter("name", NpgsqlTypes.NpgsqlDbType.Text));
|
||||
cmd.Parameters.Add(new NpgsqlParameter("vehicle_id", NpgsqlTypes.NpgsqlDbType.Uuid));
|
||||
|
||||
var seed = new Random(13579);
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
cmd.Parameters["id"].Value = NewDeterministicGuid(seed);
|
||||
cmd.Parameters["name"].Value = $"list-perf-{i:D4}";
|
||||
cmd.Parameters["vehicle_id"].Value = Seeds.OneDefaultVehicle.Id;
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
tx.Commit();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seeds <paramref name="count"/> missions, each with the F3 cascade shape:
|
||||
/// 3 map_objects + 2 waypoints + (per waypoint: 2 media → 2 annotations → 2 detection).
|
||||
/// </summary>
|
||||
private static (List<Guid> Measured, List<Guid> Warmup) SeedF3MissionsCascadeChains(int count)
|
||||
{
|
||||
using var conn = new NpgsqlConnection(TestEnvironment.DbSideChannel);
|
||||
conn.Open();
|
||||
var ids = new List<Guid>(count);
|
||||
var seed = new Random(24680);
|
||||
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
using var tx = conn.BeginTransaction();
|
||||
var missionId = NewDeterministicGuid(seed);
|
||||
ids.Add(missionId);
|
||||
|
||||
ExecScalar(conn, tx, """
|
||||
INSERT INTO missions (id, name, vehicle_id) VALUES (@id, @name, @vid);
|
||||
""", ("id", missionId), ("name", $"f3-perf-{i:D4}"),
|
||||
("vid", Seeds.OneDefaultVehicle.Id));
|
||||
|
||||
for (int m = 0; m < 3; m++)
|
||||
ExecScalar(conn, tx, """
|
||||
INSERT INTO map_objects (id, mission_id, h3_index, mgrs)
|
||||
VALUES (@id, @mid, '8a2a1072b59ffff', '36UYA1234567');
|
||||
""", ("id", NewDeterministicGuid(seed)), ("mid", missionId));
|
||||
|
||||
for (int w = 0; w < 2; w++)
|
||||
{
|
||||
var wpId = NewDeterministicGuid(seed);
|
||||
ExecScalar(conn, tx, """
|
||||
INSERT INTO waypoints (id, mission_id, lat, lon, mgrs, order_num)
|
||||
VALUES (@id, @mid, 50.45, 30.52, '36UYA1234567', @ord);
|
||||
""", ("id", wpId), ("mid", missionId), ("ord", w));
|
||||
|
||||
for (int md = 0; md < 2; md++)
|
||||
{
|
||||
var mediaId = $"media-{Guid.NewGuid():N}";
|
||||
ExecScalar(conn, tx, """
|
||||
INSERT INTO media (id, waypoint_id) VALUES (@id, @wid);
|
||||
""", ("id", mediaId), ("wid", wpId));
|
||||
|
||||
var annId = $"ann-{Guid.NewGuid():N}";
|
||||
ExecScalar(conn, tx, """
|
||||
INSERT INTO annotations (id, media_id) VALUES (@id, @mid);
|
||||
""", ("id", annId), ("mid", mediaId));
|
||||
|
||||
ExecScalar(conn, tx, """
|
||||
INSERT INTO detection (id, annotation_id) VALUES (@id, @aid);
|
||||
""", ("id", NewDeterministicGuid(seed)), ("aid", annId));
|
||||
}
|
||||
}
|
||||
tx.Commit();
|
||||
}
|
||||
|
||||
var warmup = ids.Take(WarmupCalls).ToList();
|
||||
var measured = ids.Skip(WarmupCalls).ToList();
|
||||
return (measured, warmup);
|
||||
}
|
||||
|
||||
private static void ExecScalar(NpgsqlConnection conn, NpgsqlTransaction tx, string sql,
|
||||
params (string Name, object Value)[] args)
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.Transaction = tx;
|
||||
cmd.CommandText = sql;
|
||||
foreach (var (name, value) in args)
|
||||
cmd.Parameters.AddWithValue(name, value);
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
private static Guid NewDeterministicGuid(Random rng)
|
||||
{
|
||||
var bytes = new byte[16];
|
||||
rng.NextBytes(bytes);
|
||||
// Force version 4 + variant 1 so the value is a valid UUID Postgres accepts.
|
||||
bytes[7] = (byte)((bytes[7] & 0x0F) | 0x40);
|
||||
bytes[8] = (byte)((bytes[8] & 0x3F) | 0x80);
|
||||
return new Guid(bytes);
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
using Xunit;
|
||||
|
||||
namespace Azaion.Missions.E2E.Tests.Performance;
|
||||
|
||||
/// <summary>
|
||||
/// Discovery-only smoke test for the Performance category. Real Performance
|
||||
/// scenarios (NFT-PERF-01..04) land in AZ-586.
|
||||
/// </summary>
|
||||
public sealed class Sanity
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Category", "Perf")]
|
||||
[Trait("Traces", "AC-3")]
|
||||
public void Discovery_smoke_test_runs()
|
||||
{
|
||||
// Arrange
|
||||
const int sentinel = 1;
|
||||
// Act
|
||||
var result = sentinel + 0;
|
||||
// Assert
|
||||
Assert.Equal(1, result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using Azaion.Missions.E2E.Fixtures;
|
||||
using Azaion.Missions.E2E.Helpers;
|
||||
using Npgsql;
|
||||
using Xunit;
|
||||
|
||||
namespace Azaion.Missions.E2E.Tests.Resilience;
|
||||
|
||||
/// <summary>
|
||||
/// NFT-RES-01 — mission cascade is NOT transaction-wrapped. Dropping the
|
||||
/// borrowed-schema <c>media</c> table mid-walk leaves <c>map_objects</c>
|
||||
/// committed-deleted while <c>missions</c> stays uncommitted. The test pins
|
||||
/// the current behaviour (ADR-006 carry-forward) so a future transaction
|
||||
/// wrap flips the assertion loudly.
|
||||
/// Traces: AC-3.3, AC-10.2.
|
||||
/// </summary>
|
||||
[Collection("ResCascadeF3")]
|
||||
[Trait("Category", "Res")]
|
||||
[Trait("db_access", "seed-or-assert-only")]
|
||||
public sealed class CascadeF3Tests : TestBase, IClassFixture<ComposeRestartFixture>
|
||||
{
|
||||
private readonly ComposeRestartFixture _restart;
|
||||
|
||||
public CascadeF3Tests(ComposeRestartFixture restart) => _restart = restart;
|
||||
|
||||
[SkippableFact]
|
||||
[Trait("Traces", "AC-3.3,AC-10.2")]
|
||||
[Trait("max_ms", "10000")]
|
||||
[Trait("carry_forward", "ADR-006")]
|
||||
public async Task NFT_RES_01_mission_cascade_partial_state_survives_mid_walk_failure()
|
||||
{
|
||||
Skip.IfNot(_restart.Enabled,
|
||||
"ComposeRestartFixture disabled (COMPOSE_RESTART_ENABLED!=1). " +
|
||||
"NFT-RES-01 drops the media table and needs the full stack restart " +
|
||||
"in teardown.");
|
||||
|
||||
// CARRY-FORWARD: cascade is not transaction-wrapped today. When the
|
||||
// ADR-006 follow-up wraps the cascade in a transaction, both row
|
||||
// counts will flip (map_objects rolls back to its pre-state); the
|
||||
// test fails loudly at that point — which is the intended signal.
|
||||
|
||||
// Arrange — F3 fixture loaded by the IClassFixture<CascadeF3Fixture>
|
||||
// pattern; we apply directly here so the fixture is owned by this
|
||||
// class (its restart teardown is destructive).
|
||||
DbResetFixture.ResetDatabase(TestEnvironment.DbSideChannel);
|
||||
StubSchema.EnsureCreated();
|
||||
Seeds.Apply(FixtureSql.Load("fixture_cascade_F3"));
|
||||
var mid = CascadeF3Fixture.MissionId;
|
||||
|
||||
var preMapObjects = DbAssertions.ScalarCount(
|
||||
"SELECT COUNT(*) FROM map_objects WHERE mission_id = @mid", ("mid", mid));
|
||||
Assert.Equal(3, preMapObjects);
|
||||
var preMission = DbAssertions.ScalarCount(
|
||||
"SELECT COUNT(*) FROM missions WHERE id = @mid", ("mid", mid));
|
||||
Assert.Equal(1, preMission);
|
||||
|
||||
DropMediaTable();
|
||||
var requestStart = DateTime.UtcNow;
|
||||
var token = await Tokens.MintDefaultAsync();
|
||||
|
||||
try
|
||||
{
|
||||
// Act
|
||||
using var req = new HttpRequestMessage(HttpMethod.Delete, $"/missions/{mid}");
|
||||
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Jwt);
|
||||
using var response = await Missions.SendAsync(req);
|
||||
|
||||
// Assert
|
||||
await HttpAssertions.AssertProblemEnvelopeAsync(response, HttpStatusCode.InternalServerError);
|
||||
|
||||
var postMapObjects = DbAssertions.ScalarCount(
|
||||
"SELECT COUNT(*) FROM map_objects WHERE mission_id = @mid", ("mid", mid));
|
||||
Assert.Equal(0, postMapObjects); // committed before media-DROP exploded
|
||||
|
||||
var postMission = DbAssertions.ScalarCount(
|
||||
"SELECT COUNT(*) FROM missions WHERE id = @mid", ("mid", mid));
|
||||
Assert.Equal(1, postMission); // uncommitted — never deleted
|
||||
|
||||
// The unhandled exception must mention the missing media table.
|
||||
var deadline = DateTime.UtcNow.AddSeconds(2);
|
||||
var sawLog = false;
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
var logs = DockerLogs.Read("missions-sut", requestStart);
|
||||
if (logs.Contains("Unhandled exception", StringComparison.Ordinal)
|
||||
&& (logs.Contains("relation", StringComparison.OrdinalIgnoreCase)
|
||||
&& logs.Contains("media", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
sawLog = true;
|
||||
break;
|
||||
}
|
||||
await Task.Delay(100);
|
||||
}
|
||||
Assert.True(sawLog,
|
||||
"expected 'Unhandled exception' mentioning 'relation' + 'media' in logs within 2s");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_restart.RestartStack();
|
||||
}
|
||||
}
|
||||
|
||||
private static void DropMediaTable()
|
||||
{
|
||||
using var conn = new NpgsqlConnection(TestEnvironment.DbSideChannel);
|
||||
conn.Open();
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "DROP TABLE IF EXISTS media CASCADE;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using Azaion.Missions.E2E.Fixtures;
|
||||
using Azaion.Missions.E2E.Helpers;
|
||||
using Npgsql;
|
||||
using Xunit;
|
||||
|
||||
namespace Azaion.Missions.E2E.Tests.Resilience;
|
||||
|
||||
/// <summary>
|
||||
/// NFT-RES-02 — waypoint cascade NOT transaction-wrapped, mirror of
|
||||
/// NFT-RES-01. The spec expects a partial-state observation (detection=0,
|
||||
/// waypoint=1) but the actual <see cref="Services.WaypointService"/> walk
|
||||
/// makes the media SELECT the FIRST cross-table read after the waypoint
|
||||
/// lookup — so a pre-request <c>DROP TABLE media</c> aborts the cascade
|
||||
/// before any DELETE commits.
|
||||
/// Traces: AC-4.6, AC-3.3.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Carry-forward (spec-vs-code) marked with
|
||||
/// <c>[Trait("carry_forward","AC-4.6/walk-order")]</c>: if the production
|
||||
/// cascade is later refactored to commit detections/annotations BEFORE the
|
||||
/// media lookup, the second assertion flips and this test fails loudly —
|
||||
/// at which point the spec assertion should be restored.
|
||||
/// </remarks>
|
||||
[Collection("ResCascadeF4")]
|
||||
[Trait("Category", "Res")]
|
||||
[Trait("db_access", "seed-or-assert-only")]
|
||||
public sealed class CascadeF4Tests : TestBase, IClassFixture<ComposeRestartFixture>
|
||||
{
|
||||
private readonly ComposeRestartFixture _restart;
|
||||
|
||||
public CascadeF4Tests(ComposeRestartFixture restart) => _restart = restart;
|
||||
|
||||
[SkippableFact]
|
||||
[Trait("Traces", "AC-4.6,AC-3.3")]
|
||||
[Trait("max_ms", "10000")]
|
||||
[Trait("carry_forward", "AC-4.6/walk-order")]
|
||||
public async Task NFT_RES_02_waypoint_cascade_aborts_at_media_lookup_with_no_partial_state_today()
|
||||
{
|
||||
Skip.IfNot(_restart.Enabled,
|
||||
"ComposeRestartFixture disabled (COMPOSE_RESTART_ENABLED!=1). " +
|
||||
"NFT-RES-02 drops the media table and needs a full stack restart.");
|
||||
|
||||
// Arrange — fresh F4 fixture; capture target waypoint id + its
|
||||
// chained detection id so the post-state probe is deterministic.
|
||||
DbResetFixture.ResetDatabase(TestEnvironment.DbSideChannel);
|
||||
StubSchema.EnsureCreated();
|
||||
Seeds.Apply(FixtureSql.Load("fixture_cascade_F4"));
|
||||
|
||||
var missionId = CascadeF4Fixture.MissionId;
|
||||
var targetWaypointId = CascadeF4Fixture.TargetWaypointId;
|
||||
var targetAnnotationId = CascadeF4Fixture.TargetAnnotationId;
|
||||
|
||||
DropMediaTable();
|
||||
var requestStart = DateTime.UtcNow;
|
||||
var token = await Tokens.MintDefaultAsync();
|
||||
|
||||
try
|
||||
{
|
||||
// Act
|
||||
using var req = new HttpRequestMessage(
|
||||
HttpMethod.Delete, $"/missions/{missionId}/waypoints/{targetWaypointId}");
|
||||
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Jwt);
|
||||
using var response = await Missions.SendAsync(req);
|
||||
|
||||
// Assert — 500 (PostgresException 42P01 bubbles to generic catch).
|
||||
await HttpAssertions.AssertProblemEnvelopeAsync(
|
||||
response, HttpStatusCode.InternalServerError);
|
||||
|
||||
// Carry-forward: today the media SELECT fires BEFORE any DELETE,
|
||||
// so nothing commits. detection (target row) is unchanged.
|
||||
var targetDetectionCount = DbAssertions.ScalarCount(
|
||||
"SELECT COUNT(*) FROM detection WHERE annotation_id = @aid",
|
||||
("aid", targetAnnotationId));
|
||||
Assert.Equal(1, targetDetectionCount); // spec says 0 — flip when walk is reordered.
|
||||
|
||||
// The waypoint row is uncommitted (matches spec).
|
||||
var waypointCount = DbAssertions.ScalarCount(
|
||||
"SELECT COUNT(*) FROM waypoints WHERE id = @id",
|
||||
("id", targetWaypointId));
|
||||
Assert.Equal(1, waypointCount);
|
||||
|
||||
// Log line must still mention the missing media table.
|
||||
var deadline = DateTime.UtcNow.AddSeconds(2);
|
||||
var sawLog = false;
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
var logs = DockerLogs.Read("missions-sut", requestStart);
|
||||
if (logs.Contains("Unhandled exception", StringComparison.Ordinal)
|
||||
&& logs.Contains("media", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
sawLog = true;
|
||||
break;
|
||||
}
|
||||
await Task.Delay(100);
|
||||
}
|
||||
Assert.True(sawLog,
|
||||
"expected 'Unhandled exception' mentioning 'media' in logs within 2s");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_restart.RestartStack();
|
||||
}
|
||||
}
|
||||
|
||||
private static void DropMediaTable()
|
||||
{
|
||||
using var conn = new NpgsqlConnection(TestEnvironment.DbSideChannel);
|
||||
conn.Open();
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "DROP TABLE IF EXISTS media CASCADE;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
using System.Diagnostics;
|
||||
using Azaion.Missions.E2E.Helpers;
|
||||
using Npgsql;
|
||||
using Xunit;
|
||||
|
||||
namespace Azaion.Missions.E2E.Tests.Resilience;
|
||||
|
||||
/// <summary>
|
||||
/// NFT-RES-05 (config fail-fast + DB-down differentiator) and
|
||||
/// NFT-RES-06 (Npgsql 3D000 on missing database). The 4 missing-env rows
|
||||
/// overlap with NFT-SEC-12 in the security category — same docker-run
|
||||
/// primitive, separate Sec/Res CSV rows.
|
||||
/// Traces: AC-6.1, AC-6.2, AC-6.7, AC-6.8, E3, E4.
|
||||
/// </summary>
|
||||
[Collection("MigratorRestart")]
|
||||
[Trait("Category", "Res")]
|
||||
[Trait("db_access", "seed-or-assert-only")]
|
||||
public sealed class ConfigDbStartupTests
|
||||
{
|
||||
private const string PostgresUrl =
|
||||
"postgresql://postgres:postgres-test@missions-postgres-test:5432/azaion";
|
||||
private const string JwksUrlHttps =
|
||||
"https://jwks-mock:8443/.well-known/jwks.json";
|
||||
private const string Issuer = "https://admin-test.azaion.local";
|
||||
private const string Audience = "azaion-edge";
|
||||
|
||||
public static IEnumerable<object[]> FailFastCases() => new[]
|
||||
{
|
||||
new object[] { "all_missing", Array.Empty<string>() },
|
||||
new object[] { "db_url_missing", new[] { "DATABASE_URL" } },
|
||||
new object[] { "jwt_issuer_missing", new[] { "JWT_ISSUER" } },
|
||||
new object[] { "jwt_audience_missing", new[] { "JWT_AUDIENCE" } },
|
||||
new object[] { "jwks_url_missing", new[] { "JWT_JWKS_URL" } },
|
||||
};
|
||||
|
||||
[SkippableTheory]
|
||||
[MemberData(nameof(FailFastCases))]
|
||||
[Trait("Traces", "AC-6.1,AC-6.2,E3")]
|
||||
[Trait("max_ms", "30000")]
|
||||
public void NFT_RES_05_missing_required_env_var_throws_invalid_operation_exception(
|
||||
string caseName, string[] omittedVars)
|
||||
{
|
||||
Skip.IfNot(MissionsContainerHelper.Enabled,
|
||||
"MissionsContainerHelper requires COMPOSE_RESTART_ENABLED=1 and docker CLI access.");
|
||||
|
||||
// Arrange
|
||||
var env = BaseEnv();
|
||||
foreach (var v in omittedVars) env.Remove(v);
|
||||
if (omittedVars.Length == 0)
|
||||
{
|
||||
env.Remove("DATABASE_URL");
|
||||
env.Remove("JWT_ISSUER");
|
||||
env.Remove("JWT_AUDIENCE");
|
||||
env.Remove("JWT_JWKS_URL");
|
||||
}
|
||||
|
||||
// Act
|
||||
var result = MissionsContainerHelper.RunUntilExit(
|
||||
$"missions-res05-{caseName}", env, TimeSpan.FromSeconds(20));
|
||||
|
||||
// Assert
|
||||
Assert.NotEqual(0, result.ExitCode);
|
||||
Assert.Contains("InvalidOperationException", result.Logs, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Trait("Traces", "AC-6.1,E3")]
|
||||
[Trait("max_ms", "30000")]
|
||||
public void NFT_RES_05_whitespace_required_env_var_treated_as_missing()
|
||||
{
|
||||
Skip.IfNot(MissionsContainerHelper.Enabled,
|
||||
"MissionsContainerHelper requires COMPOSE_RESTART_ENABLED=1 and docker CLI access.");
|
||||
|
||||
// Arrange — whitespace-only value triggers the same fail-fast path
|
||||
// as an absent value (ResolveRequiredOrThrow uses IsNullOrWhiteSpace).
|
||||
var env = BaseEnv();
|
||||
env["JWT_ISSUER"] = " ";
|
||||
|
||||
// Act
|
||||
var result = MissionsContainerHelper.RunUntilExit(
|
||||
"missions-res05-whitespace-iss", env, TimeSpan.FromSeconds(20));
|
||||
|
||||
// Assert
|
||||
Assert.NotEqual(0, result.ExitCode);
|
||||
Assert.Contains("InvalidOperationException", result.Logs, StringComparison.Ordinal);
|
||||
var mentionsIssuer =
|
||||
result.Logs.Contains("JWT_ISSUER", StringComparison.Ordinal)
|
||||
|| result.Logs.Contains("Jwt:Issuer", StringComparison.Ordinal);
|
||||
Assert.True(mentionsIssuer,
|
||||
$"logs must mention JWT_ISSUER. Logs:\n{result.Logs}");
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Trait("Traces", "AC-6.7,E4")]
|
||||
[Trait("max_ms", "60000")]
|
||||
public void NFT_RES_05_db_down_after_config_resolution_logs_npgsql_connection_refused()
|
||||
{
|
||||
Skip.IfNot(MissionsContainerHelper.Enabled,
|
||||
"MissionsContainerHelper requires COMPOSE_RESTART_ENABLED=1 and docker CLI access.");
|
||||
|
||||
// Arrange — all 4 required vars set, but point DATABASE_URL at a
|
||||
// host that is not running. Config resolution succeeds; Npgsql
|
||||
// fails on the migrator's first connection attempt.
|
||||
var env = BaseEnv();
|
||||
env["DATABASE_URL"] =
|
||||
"postgresql://postgres:postgres-test@nonexistent-host-for-res05:5432/azaion";
|
||||
|
||||
// Act
|
||||
var result = MissionsContainerHelper.RunUntilExit(
|
||||
"missions-res05-db-down", env, TimeSpan.FromSeconds(45));
|
||||
|
||||
// Assert
|
||||
Assert.NotEqual(0, result.ExitCode);
|
||||
// Connection-refused / name-not-resolved / unreachable are the
|
||||
// acceptable Npgsql failure shapes; the differentiator is that
|
||||
// InvalidOperationException must NOT appear — proving config
|
||||
// resolution completed before the connection broke.
|
||||
Assert.DoesNotContain("InvalidOperationException", result.Logs, StringComparison.Ordinal);
|
||||
var connectionShape =
|
||||
result.Logs.Contains("Connection refused", StringComparison.OrdinalIgnoreCase)
|
||||
|| result.Logs.Contains("could not resolve", StringComparison.OrdinalIgnoreCase)
|
||||
|| result.Logs.Contains("could not connect", StringComparison.OrdinalIgnoreCase)
|
||||
|| result.Logs.Contains("Name or service not known", StringComparison.OrdinalIgnoreCase)
|
||||
|| result.Logs.Contains("Temporary failure in name resolution", StringComparison.OrdinalIgnoreCase);
|
||||
Assert.True(connectionShape,
|
||||
$"logs must show Npgsql connection failure (not InvalidOperationException). Logs:\n{result.Logs}");
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Trait("Traces", "AC-6.8")]
|
||||
[Trait("max_ms", "60000")]
|
||||
public void NFT_RES_06_dropping_target_database_causes_3D000_exit()
|
||||
{
|
||||
Skip.IfNot(MissionsContainerHelper.Enabled,
|
||||
"Requires docker CLI + COMPOSE_RESTART_ENABLED=1 + Postgres admin access.");
|
||||
|
||||
// Arrange — drop the azaion database via a side-channel that
|
||||
// connects to the `postgres` admin DB. Caller is responsible for
|
||||
// recreating the DB in teardown (handled by ComposeRestartFixture
|
||||
// in the surrounding collection).
|
||||
try
|
||||
{
|
||||
DropAzaionDatabase();
|
||||
}
|
||||
catch (PostgresException ex)
|
||||
{
|
||||
Skip.If(true,
|
||||
$"could not drop azaion database for NFT-RES-06 setup ({ex.SqlState}: {ex.MessageText}); " +
|
||||
"the test requires superuser admin access on the postgres-test container.");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Act
|
||||
var result = MissionsContainerHelper.RunUntilExit(
|
||||
"missions-res06-dropdb", BaseEnv(), TimeSpan.FromSeconds(45));
|
||||
|
||||
// Assert
|
||||
Assert.NotEqual(0, result.ExitCode);
|
||||
Assert.Contains("3D000", result.Logs, StringComparison.Ordinal);
|
||||
}
|
||||
finally
|
||||
{
|
||||
RestoreAzaionDatabase();
|
||||
}
|
||||
}
|
||||
|
||||
private static void DropAzaionDatabase()
|
||||
{
|
||||
var adminConn = TestEnvironment.DbSideChannel
|
||||
.Replace("Database=azaion", "Database=postgres", StringComparison.Ordinal);
|
||||
using var conn = new NpgsqlConnection(adminConn);
|
||||
conn.Open();
|
||||
using var cmd = conn.CreateCommand();
|
||||
// WITH (FORCE) terminates any other backends still on azaion.
|
||||
cmd.CommandText = "DROP DATABASE IF EXISTS azaion WITH (FORCE);";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
private static void RestoreAzaionDatabase()
|
||||
{
|
||||
var adminConn = TestEnvironment.DbSideChannel
|
||||
.Replace("Database=azaion", "Database=postgres", StringComparison.Ordinal);
|
||||
using var conn = new NpgsqlConnection(adminConn);
|
||||
conn.Open();
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "CREATE DATABASE azaion;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> BaseEnv() => new(StringComparer.Ordinal)
|
||||
{
|
||||
{ "DATABASE_URL", PostgresUrl },
|
||||
{ "JWT_ISSUER", Issuer },
|
||||
{ "JWT_AUDIENCE", Audience },
|
||||
{ "JWT_JWKS_URL", JwksUrlHttps },
|
||||
{ "ASPNETCORE_URLS", "http://+:8080" },
|
||||
{ "ASPNETCORE_ENVIRONMENT","Test" },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using Azaion.Missions.E2E.Fixtures;
|
||||
using Azaion.Missions.E2E.Helpers;
|
||||
using Npgsql;
|
||||
using Xunit;
|
||||
|
||||
namespace Azaion.Missions.E2E.Tests.Resilience;
|
||||
|
||||
/// <summary>
|
||||
/// NFT-RES-08 — TOCTOU race on <c>vehicles.is_default</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Spec AC-1.4 expects the race to be OBSERVABLE — i.e. at least one of 100
|
||||
/// concurrent iterations leaves two rows with <c>is_default=true</c>. The
|
||||
/// current migrator ships
|
||||
/// <c>ux_vehicles_one_default ON vehicles (is_default) WHERE is_default = TRUE</c>,
|
||||
/// which closes the race at the storage layer: the second writer always
|
||||
/// fails with <c>23505</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Following <c>CascadeF4Tests</c> precedent we pin the CURRENT behaviour
|
||||
/// (max-one default after the race) and mark the divergence with the
|
||||
/// <c>carry_forward</c> trait. If the index is ever removed without an
|
||||
/// application-level guard replacing it, this test fails loudly — that
|
||||
/// failure is the signal to revisit the AC-1.4 carry-forward in the
|
||||
/// traceability matrix.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Collection("MigratorRestart")]
|
||||
[Trait("Category", "Res")]
|
||||
[Trait("carry_forward", "AC-1.4/index-closes-race")]
|
||||
[Trait("db_access", "seed-or-assert-only")]
|
||||
public sealed class DefaultVehicleRaceTests : TestBase, IClassFixture<DbResetFixture>
|
||||
{
|
||||
private const int Iterations = 100;
|
||||
|
||||
[Fact]
|
||||
[Trait("Traces", "AC-1.4")]
|
||||
[Trait("max_ms", "30000")]
|
||||
public async Task NFT_RES_08_concurrent_default_writes_converge_on_one_default_today()
|
||||
{
|
||||
// Arrange — fresh DB and a valid token reused across iterations.
|
||||
DbResetFixture.ResetDatabase(TestEnvironment.DbSideChannel);
|
||||
var token = await Tokens.MintDefaultAsync();
|
||||
Missions.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", token.Jwt);
|
||||
|
||||
var observations = new int[Iterations];
|
||||
|
||||
// Act
|
||||
for (int i = 0; i < Iterations; i++)
|
||||
{
|
||||
ResetVehiclesAndSeedOneDefault();
|
||||
|
||||
// Each writer carries a unique id so PK collisions never mask
|
||||
// the race that AC-1.4 is interested in.
|
||||
var postTask = TryPostVehicleAsync(Guid.NewGuid());
|
||||
var insertTask = TrySideChannelInsertAsync(Guid.NewGuid());
|
||||
|
||||
await Task.WhenAll(postTask, insertTask);
|
||||
observations[i] = CountDefaultVehicles();
|
||||
}
|
||||
|
||||
var maxObserved = observations.Max();
|
||||
|
||||
// Assert — CURRENT behaviour: the partial unique index forces
|
||||
// every iteration to converge on a single default vehicle.
|
||||
// If this assertion ever fails (max >= 2), the index has been
|
||||
// removed/relaxed and AC-1.4 carry-forward should be revisited.
|
||||
Assert.True(maxObserved <= 1,
|
||||
$"observed >= 2 defaults in some iteration (max={maxObserved}). " +
|
||||
"Index ux_vehicles_one_default appears removed/relaxed — revisit " +
|
||||
"AC-1.4 carry-forward in traceability_matrix.csv.");
|
||||
}
|
||||
|
||||
private async Task<HttpRequestState> TryPostVehicleAsync(Guid vehicleId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var body = new
|
||||
{
|
||||
Id = vehicleId,
|
||||
Name = $"race-api-{vehicleId:N}",
|
||||
IsDefault = true,
|
||||
};
|
||||
using var resp = await Missions.PostAsJsonAsync("/vehicles", body);
|
||||
return new HttpRequestState((int)resp.StatusCode, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new HttpRequestState(-1, ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<SideChannelState> TrySideChannelInsertAsync(Guid vehicleId)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var conn = new NpgsqlConnection(TestEnvironment.DbSideChannel);
|
||||
await conn.OpenAsync();
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
INSERT INTO vehicles (id, model, name, is_default)
|
||||
VALUES (@id, @model, @name, TRUE);
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("id", vehicleId);
|
||||
cmd.Parameters.AddWithValue("model", "race-model");
|
||||
cmd.Parameters.AddWithValue("name", $"race-side-{vehicleId:N}");
|
||||
await cmd.ExecuteNonQueryAsync();
|
||||
return new SideChannelState(true, null);
|
||||
}
|
||||
catch (PostgresException ex)
|
||||
{
|
||||
return new SideChannelState(false, ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ResetVehiclesAndSeedOneDefault()
|
||||
{
|
||||
using var conn = new NpgsqlConnection(TestEnvironment.DbSideChannel);
|
||||
conn.Open();
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
TRUNCATE vehicles RESTART IDENTITY CASCADE;
|
||||
INSERT INTO vehicles (id, model, name, is_default)
|
||||
VALUES (gen_random_uuid(), 'seed-model', 'seed-default', TRUE);
|
||||
""";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
private static int CountDefaultVehicles()
|
||||
{
|
||||
using var conn = new NpgsqlConnection(TestEnvironment.DbSideChannel);
|
||||
conn.Open();
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT COUNT(*) FROM vehicles WHERE is_default = TRUE;";
|
||||
return Convert.ToInt32(cmd.ExecuteScalar());
|
||||
}
|
||||
|
||||
private sealed record HttpRequestState(int StatusCode, Exception? Error);
|
||||
private sealed record SideChannelState(bool Inserted, Exception? Error);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Azaion.Missions.E2E.Fixtures;
|
||||
using Azaion.Missions.E2E.Helpers;
|
||||
using Xunit;
|
||||
|
||||
namespace Azaion.Missions.E2E.Tests.Resilience;
|
||||
|
||||
/// <summary>
|
||||
/// NFT-RES-07 — operational counterpart of NFT-SEC-11. Verifies that a JWKS
|
||||
/// rotation propagates through the SUT WITHOUT a process restart. The
|
||||
/// security-shaped variant lives in <c>Tests/Security/JwksRotationTests.cs</c>;
|
||||
/// here the assertion focuses on
|
||||
/// <c>docker inspect --format '{{.State.StartedAt}}' missions-sut</c>
|
||||
/// returning the SAME ISO-8601 timestamp before and after the rotation flow.
|
||||
/// Traces: AC-5.7.
|
||||
/// </summary>
|
||||
[Collection("JwksRotation")]
|
||||
[Trait("Category", "Res")]
|
||||
[Trait("db_access", "seed-or-assert-only")]
|
||||
public sealed class JwksRotationNoRestartTests : TestBase, IClassFixture<DbResetFixture>
|
||||
{
|
||||
[SkippableFact(Timeout = 200_000)]
|
||||
[Trait("Traces", "AC-5.7")]
|
||||
[Trait("max_ms", "180000")]
|
||||
public async Task NFT_RES_07_jwks_rotation_propagates_without_missions_restart()
|
||||
{
|
||||
Skip.IfNot(MissionsContainerHelper.Enabled,
|
||||
"Requires docker CLI access (COMPOSE_RESTART_ENABLED=1) to read StartedAt.");
|
||||
|
||||
// Arrange — capture StartedAt before any rotation activity so the
|
||||
// post-flow comparison is anchored to "before this test started".
|
||||
DbResetFixture.ResetDatabase(TestEnvironment.DbSideChannel);
|
||||
Seeds.Apply(Seeds.OneDefaultVehicle.Sql);
|
||||
|
||||
var startedAtBefore = MissionsContainerHelper.GetStartedAt("missions-sut");
|
||||
|
||||
var t1 = await Tokens.MintDefaultAsync();
|
||||
var kidV1 = t1.Kid;
|
||||
using (var resp = await CallVehiclesAsync(t1.Jwt))
|
||||
await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.OK);
|
||||
|
||||
// Act 1 — rotate; mint a token with the new kid; assert pre-refresh 401.
|
||||
var kidV2 = await RotateMockAsync();
|
||||
Assert.NotEqual(kidV1, kidV2);
|
||||
|
||||
var t2 = await Tokens.MintDefaultAsync();
|
||||
Assert.Equal(kidV2, t2.Kid);
|
||||
|
||||
using (var resp = await CallVehiclesAsync(t2.Jwt))
|
||||
await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.Unauthorized);
|
||||
|
||||
// Act 2 — force JWKS refresh via the test-only hook (the library's
|
||||
// 5-minute floor on AutomaticRefreshInterval forbids the proactive
|
||||
// path and our custom IssuerSigningKeyResolver bypasses the JwtBearer
|
||||
// signature-failure refresh path; see Helpers/JwksRefreshHelper.cs).
|
||||
await JwksRefreshHelper.ForceRefreshAsync(Missions);
|
||||
using (var resp = await CallVehiclesAsync(t2.Jwt))
|
||||
await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.OK);
|
||||
|
||||
// Assert — service did NOT restart.
|
||||
var startedAtAfter = MissionsContainerHelper.GetStartedAt("missions-sut");
|
||||
Assert.Equal(startedAtBefore, startedAtAfter);
|
||||
}
|
||||
|
||||
private async Task<HttpResponseMessage> CallVehiclesAsync(string jwt)
|
||||
{
|
||||
var req = new HttpRequestMessage(HttpMethod.Get, "/vehicles");
|
||||
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt);
|
||||
return await Missions.SendAsync(req);
|
||||
}
|
||||
|
||||
private static async Task<string> RotateMockAsync()
|
||||
{
|
||||
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
|
||||
var rotateUrl = new Uri(new Uri(TestEnvironment.JwksMockBaseUrl), "/rotate-key");
|
||||
using var resp = await http.PostAsync(rotateUrl, content: null);
|
||||
resp.EnsureSuccessStatusCode();
|
||||
var body = await resp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
return body.GetProperty("kid").GetString()
|
||||
?? throw new InvalidOperationException("mock /rotate-key returned no kid");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using Azaion.Missions.E2E.Fixtures;
|
||||
using Azaion.Missions.E2E.Helpers;
|
||||
using Npgsql;
|
||||
using Xunit;
|
||||
|
||||
namespace Azaion.Missions.E2E.Tests.Resilience;
|
||||
|
||||
/// <summary>
|
||||
/// NFT-RES-03 and NFT-RES-04 — migrator behaviour across container restarts.
|
||||
/// Both scenarios drive the SUT via docker compose and rely on the
|
||||
/// <see cref="ComposeRestartFixture"/> harness; they share one xUnit
|
||||
/// collection so a failed teardown of NFT-RES-03 does not leak state into
|
||||
/// NFT-RES-04.
|
||||
/// Traces: AC-6.4, AC-6.5, AC-6.6, AC-10.5.
|
||||
/// </summary>
|
||||
[Collection("MigratorRestart")]
|
||||
[Trait("Category", "Res")]
|
||||
[Trait("db_access", "seed-or-assert-only")]
|
||||
public sealed class MigratorRestartTests : TestBase, IClassFixture<ComposeRestartFixture>
|
||||
{
|
||||
private readonly ComposeRestartFixture _restart;
|
||||
|
||||
public MigratorRestartTests(ComposeRestartFixture restart) => _restart = restart;
|
||||
|
||||
[SkippableFact]
|
||||
[Trait("Traces", "AC-6.6,AC-6.4")]
|
||||
[Trait("max_ms", "60000")]
|
||||
public async Task NFT_RES_03_migrator_is_idempotent_on_container_restart()
|
||||
{
|
||||
Skip.IfNot(_restart.Enabled,
|
||||
"ComposeRestartFixture disabled (COMPOSE_RESTART_ENABLED!=1). " +
|
||||
"NFT-RES-03 needs `docker compose restart` access.");
|
||||
|
||||
// Arrange — clean DB so the migrator is not racing with stale data.
|
||||
DbResetFixture.ResetDatabase(TestEnvironment.DbSideChannel);
|
||||
var schemaBefore = SnapshotPublicSchema();
|
||||
|
||||
// Capture the wall-clock just before the restart so the log slice
|
||||
// does not include pre-existing warnings from the first start.
|
||||
var restartUtc = DateTime.UtcNow;
|
||||
|
||||
// Act
|
||||
Compose("restart missions");
|
||||
await WaitForHealthyAsync(TimeSpan.FromSeconds(30));
|
||||
|
||||
// Assert — no NEW errors AT ALL in the restart slice.
|
||||
var logs = DockerLogs.Read("missions-sut", restartUtc);
|
||||
AssertNoNewErrorLines(logs);
|
||||
|
||||
var schemaAfter = SnapshotPublicSchema();
|
||||
Assert.Equal(schemaBefore, schemaAfter);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Trait("Traces", "AC-6.5,AC-10.5")]
|
||||
[Trait("max_ms", "120000")]
|
||||
public async Task NFT_RES_04_legacy_gps_tables_dropped_on_first_start_and_subsequent_restart_is_noop()
|
||||
{
|
||||
Skip.IfNot(_restart.Enabled,
|
||||
"ComposeRestartFixture disabled (COMPOSE_RESTART_ENABLED!=1). " +
|
||||
"NFT-RES-04 needs `docker compose stop|start|restart` access.");
|
||||
|
||||
// Build-time gate — the migrator must contain the post-B9 DROP block.
|
||||
// We probe empirically: seed the legacy tables, restart missions,
|
||||
// verify they are gone. If they survive, the build pre-dates B9 and
|
||||
// we skip with a clear reason.
|
||||
|
||||
// Arrange — stop missions, seed the legacy tables.
|
||||
Compose("stop missions");
|
||||
ResetAllAndSeedLegacyTables();
|
||||
var legacyPresent = LegacyTablesExist();
|
||||
Assert.True(legacyPresent, "seed_legacy_gps_tables did not actually create the legacy tables");
|
||||
|
||||
// Act 1 — first start should drop the legacy tables.
|
||||
Compose("up -d missions");
|
||||
await WaitForHealthyAsync(TimeSpan.FromSeconds(45));
|
||||
|
||||
var legacyAfterFirstStart = LegacyTablesExist();
|
||||
Skip.If(legacyAfterFirstStart,
|
||||
"Legacy orthophotos/gps_corrections tables still present after first start; " +
|
||||
"this build appears to pre-date B9. NFT-RES-04 is a no-op on pre-B9 builds.");
|
||||
|
||||
// Act 2 — restart should be a no-op (no 'does not exist' errors).
|
||||
var restartUtc = DateTime.UtcNow;
|
||||
Compose("restart missions");
|
||||
await WaitForHealthyAsync(TimeSpan.FromSeconds(30));
|
||||
|
||||
// Assert
|
||||
Assert.False(LegacyTablesExist(), "legacy tables reappeared after restart");
|
||||
var logs = DockerLogs.Read("missions-sut", restartUtc);
|
||||
Assert.DoesNotContain("does not exist", logs, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static void ResetAllAndSeedLegacyTables()
|
||||
{
|
||||
using var conn = new NpgsqlConnection(TestEnvironment.DbSideChannel);
|
||||
conn.Open();
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
DROP TABLE IF EXISTS orthophotos;
|
||||
DROP TABLE IF EXISTS gps_corrections;
|
||||
CREATE TABLE orthophotos (
|
||||
id UUID PRIMARY KEY,
|
||||
payload TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE TABLE gps_corrections (
|
||||
id UUID PRIMARY KEY,
|
||||
payload TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
""";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
private static bool LegacyTablesExist()
|
||||
{
|
||||
using var conn = new NpgsqlConnection(TestEnvironment.DbSideChannel);
|
||||
conn.Open();
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
SELECT to_regclass('orthophotos')::TEXT, to_regclass('gps_corrections')::TEXT;
|
||||
""";
|
||||
using var reader = cmd.ExecuteReader();
|
||||
reader.Read();
|
||||
var ortho = reader.IsDBNull(0) ? null : reader.GetString(0);
|
||||
var gpsCorr = reader.IsDBNull(1) ? null : reader.GetString(1);
|
||||
return ortho is not null || gpsCorr is not null;
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> SnapshotPublicSchema()
|
||||
{
|
||||
var rows = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
using var conn = new NpgsqlConnection(TestEnvironment.DbSideChannel);
|
||||
conn.Open();
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
SELECT table_name || '.' || column_name AS key,
|
||||
data_type
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
ORDER BY table_name, column_name;
|
||||
""";
|
||||
using var reader = cmd.ExecuteReader();
|
||||
while (reader.Read())
|
||||
rows[reader.GetString(0)] = reader.GetString(1);
|
||||
return rows;
|
||||
}
|
||||
|
||||
private static void AssertNoNewErrorLines(string logs)
|
||||
{
|
||||
// Each line is independently checked — a stack-trace dump
|
||||
// contains exception keywords; an actual ERROR log line does too.
|
||||
var bad = logs.Split('\n')
|
||||
.Where(line =>
|
||||
line.Contains("error", StringComparison.OrdinalIgnoreCase)
|
||||
|| line.Contains("exception", StringComparison.OrdinalIgnoreCase))
|
||||
.ToArray();
|
||||
Assert.True(bad.Length == 0,
|
||||
$"expected NO new error/exception lines in restart slice; saw {bad.Length}:\n{string.Join("\n", bad)}");
|
||||
}
|
||||
|
||||
private async Task WaitForHealthyAsync(TimeSpan timeout)
|
||||
{
|
||||
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(2) };
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var resp = await http.GetAsync(new Uri(TestEnvironment.MissionsBaseUrl + "/health"));
|
||||
if (resp.StatusCode == HttpStatusCode.OK) return;
|
||||
}
|
||||
catch (HttpRequestException) { /* not yet listening */ }
|
||||
catch (TaskCanceledException) { /* slow first request */ }
|
||||
await Task.Delay(500);
|
||||
}
|
||||
throw new TimeoutException(
|
||||
$"missions did not become healthy within {timeout.TotalSeconds:F0}s");
|
||||
}
|
||||
|
||||
private void Compose(string subcommand)
|
||||
{
|
||||
var psi = new ProcessStartInfo("docker",
|
||||
$"compose -f {_restart.ComposeFile} {subcommand}")
|
||||
{
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false
|
||||
};
|
||||
using var p = Process.Start(psi)
|
||||
?? throw new InvalidOperationException("docker CLI not available");
|
||||
var stdout = p.StandardOutput.ReadToEnd();
|
||||
var stderr = p.StandardError.ReadToEnd();
|
||||
p.WaitForExit();
|
||||
if (p.ExitCode != 0)
|
||||
throw new InvalidOperationException(
|
||||
$"`docker compose {subcommand}` exited {p.ExitCode}:\nstdout: {stdout}\nstderr: {stderr}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Net;
|
||||
using Azaion.Missions.E2E.Helpers;
|
||||
using Xunit;
|
||||
|
||||
namespace Azaion.Missions.E2E.Tests.ResourceLimits;
|
||||
|
||||
/// <summary>
|
||||
/// NFT-RES-LIM-04 — cold-start RSS. Driven independently from the
|
||||
/// steady-state window because it requires a fresh container start; lives
|
||||
/// in the <c>MigratorRestart</c> collection so it serialises with the
|
||||
/// other docker-compose-restarting tests rather than racing them.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The 30-second wait between health-OK and the measurement is the spec's
|
||||
/// way of letting the JIT and the JWKS prefetch settle without doing any
|
||||
/// real work — measuring at health-OK alone would conflate the genuine cold
|
||||
/// baseline with bootstrap noise.
|
||||
/// </remarks>
|
||||
[Collection("MigratorRestart")]
|
||||
[Trait("Category", "ResLim")]
|
||||
public sealed class ColdStartRssTests
|
||||
{
|
||||
private static readonly MetricCsvRecorder Csv = new("RESLIM_RESULTS_FILE");
|
||||
private const long ProvisionalColdRssCapMiB = 200;
|
||||
private const string ComposeFile = "/workspace/docker-compose.test.yml";
|
||||
|
||||
[SkippableFact]
|
||||
[Trait("Traces", "H1|H3")]
|
||||
[Trait("max_ms", "120000")]
|
||||
public async Task NFT_RES_LIM_04_cold_start_rss_within_provisional_200_MiB()
|
||||
{
|
||||
Skip.IfNot(Environment.GetEnvironmentVariable("COMPOSE_RESTART_ENABLED") == "1",
|
||||
"COMPOSE_RESTART_ENABLED!=1 — docker compose restart unavailable in this consumer image");
|
||||
Skip.IfNot(MissionsContainerHelper.Enabled,
|
||||
"MissionsContainerHelper disabled — docker CLI unavailable");
|
||||
|
||||
// Arrange — bring missions down hard and start it fresh. The
|
||||
// surrounding "MigratorRestart" collection serialises us against
|
||||
// any other test that touches the SUT.
|
||||
DockerCompose("stop missions");
|
||||
DockerCompose("rm -f missions");
|
||||
DockerCompose("up -d missions");
|
||||
|
||||
await WaitForHealthOkAsync(TimeSpan.FromSeconds(60));
|
||||
|
||||
// Act — wait 30s after health-OK so JIT/JWKS settle, then measure.
|
||||
await Task.Delay(TimeSpan.FromSeconds(30));
|
||||
var rssBytes = ReadRssBytes("missions-sut");
|
||||
var rssMiB = rssBytes / (double)(1024 * 1024);
|
||||
|
||||
var pass = rssMiB <= ProvisionalColdRssCapMiB;
|
||||
Csv.Record(
|
||||
category: "ResLim",
|
||||
scenario: "NFT-RES-LIM-04",
|
||||
result: pass ? "pass" : "fail",
|
||||
traces: $"H1|H3; COLD_RSS_MiB={rssMiB.ToString("F1", CultureInfo.InvariantCulture)}");
|
||||
|
||||
// Assert — provisional gate.
|
||||
Assert.True(pass,
|
||||
$"cold-start RSS {rssMiB:F1} MiB exceeds provisional {ProvisionalColdRssCapMiB} MiB gate");
|
||||
}
|
||||
|
||||
private static async Task WaitForHealthOkAsync(TimeSpan timeout)
|
||||
{
|
||||
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(2) };
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
var healthUrl = new Uri(TestEnvironment.MissionsBaseUrl + "/health");
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var resp = await http.GetAsync(healthUrl);
|
||||
if (resp.StatusCode == HttpStatusCode.OK) return;
|
||||
}
|
||||
catch (HttpRequestException) { /* not yet listening */ }
|
||||
catch (TaskCanceledException) { /* slow first response */ }
|
||||
await Task.Delay(500);
|
||||
}
|
||||
throw new TimeoutException(
|
||||
$"missions did not become healthy within {timeout.TotalSeconds:F0}s of cold start");
|
||||
}
|
||||
|
||||
private static long ReadRssBytes(string containerName)
|
||||
{
|
||||
var raw = Run("docker",
|
||||
$"stats --no-stream --format '{{{{.MemUsage}}}}' {containerName}");
|
||||
var lhs = raw.Split('/')[0].Trim().Trim('\'');
|
||||
return ParseHumanBytes(lhs);
|
||||
}
|
||||
|
||||
private static long ParseHumanBytes(string text)
|
||||
{
|
||||
var unitIx = text.IndexOfAny(new[] { 'K', 'M', 'G', 'T', 'B' });
|
||||
if (unitIx < 0) return long.Parse(text, CultureInfo.InvariantCulture);
|
||||
var num = double.Parse(text.Substring(0, unitIx), CultureInfo.InvariantCulture);
|
||||
var unit = text.Substring(unitIx);
|
||||
return unit switch
|
||||
{
|
||||
"B" => (long)num,
|
||||
"KiB" or "KB" or "K" => (long)(num * 1024),
|
||||
"MiB" or "MB" or "M" => (long)(num * 1024 * 1024),
|
||||
"GiB" or "GB" or "G" => (long)(num * 1024 * 1024 * 1024),
|
||||
"TiB" or "TB" or "T" => (long)(num * 1024L * 1024 * 1024 * 1024),
|
||||
_ => throw new FormatException($"unknown human-bytes unit in '{text}'")
|
||||
};
|
||||
}
|
||||
|
||||
private static void DockerCompose(string subcommand) =>
|
||||
Run("docker", $"compose -f {ComposeFile} {subcommand}");
|
||||
|
||||
private static string Run(string file, string args)
|
||||
{
|
||||
var psi = new ProcessStartInfo(file, args)
|
||||
{
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false
|
||||
};
|
||||
using var p = Process.Start(psi)
|
||||
?? throw new InvalidOperationException($"failed to launch `{file} {args}`");
|
||||
var stdout = p.StandardOutput.ReadToEnd();
|
||||
var stderr = p.StandardError.ReadToEnd();
|
||||
p.WaitForExit();
|
||||
if (p.ExitCode != 0)
|
||||
throw new InvalidOperationException(
|
||||
$"`{file} {args}` exited {p.ExitCode}: {stderr}");
|
||||
return stdout;
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
using Xunit;
|
||||
|
||||
namespace Azaion.Missions.E2E.Tests.ResourceLimits;
|
||||
|
||||
/// <summary>
|
||||
/// Discovery-only smoke test for the ResourceLimits category. Real
|
||||
/// ResourceLimits scenarios (NFT-RES-LIM-01..04) land in AZ-585.
|
||||
/// </summary>
|
||||
public sealed class Sanity
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Category", "ResLim")]
|
||||
[Trait("Traces", "AC-3")]
|
||||
public void Discovery_smoke_test_runs()
|
||||
{
|
||||
// Arrange
|
||||
const int sentinel = 1;
|
||||
// Act
|
||||
var result = sentinel + 0;
|
||||
// Assert
|
||||
Assert.Equal(1, result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
using System.Globalization;
|
||||
using Azaion.Missions.E2E.Fixtures;
|
||||
using Azaion.Missions.E2E.Helpers;
|
||||
using Xunit;
|
||||
|
||||
namespace Azaion.Missions.E2E.Tests.ResourceLimits;
|
||||
|
||||
/// <summary>
|
||||
/// NFT-RES-LIM-01..03 — three observations on a SINGLE 5-minute sustained
|
||||
/// load window. The window itself lives in
|
||||
/// <see cref="SteadyStateLoadFixture"/> (class-scoped, runs once); each
|
||||
/// test asserts one metric against its provisional gate.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The fixture skips itself when docker primitives are unavailable; the
|
||||
/// tests detect that via <see cref="SteadyStateLoadFixture.SkipReason"/>
|
||||
/// and surface the same reason through <c>Skip.IfNot</c>. The fixture
|
||||
/// also flips <see cref="SteadyStateLoadFixture.SutExitedDuringWindow"/>
|
||||
/// if the SUT crashes mid-window — every test fails fast with a clear
|
||||
/// message rather than reporting a misleading metric.
|
||||
/// </remarks>
|
||||
[Collection("ResLimSteadyState")]
|
||||
[Trait("Category", "ResLim")]
|
||||
public sealed class SteadyStateLoadTests : TestBase, IClassFixture<SteadyStateLoadFixture>
|
||||
{
|
||||
private static readonly MetricCsvRecorder Csv = new("RESLIM_RESULTS_FILE");
|
||||
private const long ProvisionalRssCapMiB = 250;
|
||||
private const int ProvisionalConnectionCap = 100;
|
||||
private const int ProvisionalFdCap = 1024;
|
||||
|
||||
private readonly SteadyStateLoadFixture _load;
|
||||
|
||||
public SteadyStateLoadTests(SteadyStateLoadFixture load) => _load = load;
|
||||
|
||||
[SkippableFact]
|
||||
[Trait("Traces", "H1|H6|O10")]
|
||||
[Trait("max_ms", "360000")]
|
||||
public void NFT_RES_LIM_01_steady_state_rss_within_provisional_gate_and_no_leak()
|
||||
{
|
||||
Skip.If(_load.SkipReason is not null, _load.SkipReason);
|
||||
Skip.IfNot(_load.LoadGeneratorMetTargetRps,
|
||||
"runner cannot sustain target load (NFR Reliability — not a SUT defect)");
|
||||
Assert.False(_load.SutExitedDuringWindow, "SUT exited during measurement window");
|
||||
|
||||
// Arrange
|
||||
var samplesMiB = _load.RssBytesSamples.Select(b => b / (double)(1024 * 1024)).ToList();
|
||||
Assert.True(samplesMiB.Count >= 30,
|
||||
$"expected ≥ 30 RSS samples over 5-min window, got {samplesMiB.Count}");
|
||||
|
||||
// Act
|
||||
var p95 = LatencyPercentiles.P95(samplesMiB);
|
||||
var finalMiB = samplesMiB[^1];
|
||||
|
||||
var leakRatio = Math.Abs(finalMiB - p95) / Math.Max(p95, 1.0);
|
||||
var withinCap = p95 <= ProvisionalRssCapMiB;
|
||||
var noLeak = leakRatio <= 0.20;
|
||||
var pass = withinCap && noLeak;
|
||||
|
||||
Csv.Record(
|
||||
category: "ResLim",
|
||||
scenario: "NFT-RES-LIM-01",
|
||||
result: pass ? "pass" : "fail",
|
||||
traces: $"H1|H6|O10; "
|
||||
+ $"P95_RSS_MiB={p95.ToString("F1", CultureInfo.InvariantCulture)}; "
|
||||
+ $"FINAL_RSS_MiB={finalMiB.ToString("F1", CultureInfo.InvariantCulture)}; "
|
||||
+ $"LEAK_RATIO={leakRatio.ToString("F2", CultureInfo.InvariantCulture)}");
|
||||
|
||||
// Assert — provisional gate; lock at measured + 50% after first green run.
|
||||
Assert.True(withinCap,
|
||||
$"P95 RSS {p95:F1} MiB exceeds provisional {ProvisionalRssCapMiB} MiB gate");
|
||||
Assert.True(noLeak,
|
||||
$"final RSS {finalMiB:F1} MiB diverges {leakRatio:P0} from P95 {p95:F1} MiB (gate 20%)");
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Trait("Traces", "O10")]
|
||||
[Trait("max_ms", "360000")]
|
||||
public void NFT_RES_LIM_02_npgsql_connection_pool_within_100_no_unbounded_growth()
|
||||
{
|
||||
Skip.If(_load.SkipReason is not null, _load.SkipReason);
|
||||
Skip.IfNot(_load.LoadGeneratorMetTargetRps,
|
||||
"runner cannot sustain target load (NFR Reliability — not a SUT defect)");
|
||||
Assert.False(_load.SutExitedDuringWindow, "SUT exited during measurement window");
|
||||
|
||||
var samples = _load.NpgsqlConnectionSamples;
|
||||
Assert.True(samples.Count >= 30,
|
||||
$"expected ≥ 30 connection samples over 5-min window, got {samples.Count}");
|
||||
|
||||
// Act
|
||||
var max = samples.Max();
|
||||
var firstMinuteSampleCount = 60 / SteadyStateLoadFixture.SampleIntervalSeconds;
|
||||
var firstMinute = samples.Take(firstMinuteSampleCount).ToList();
|
||||
var firstMinuteMean = firstMinute.Average();
|
||||
var finalCount = samples[^1];
|
||||
|
||||
var withinCap = max <= ProvisionalConnectionCap;
|
||||
var noUnboundedGrowth = finalCount <= 1.3 * Math.Max(firstMinuteMean, 1.0);
|
||||
var pass = withinCap && noUnboundedGrowth;
|
||||
|
||||
Csv.Record(
|
||||
category: "ResLim",
|
||||
scenario: "NFT-RES-LIM-02",
|
||||
result: pass ? "pass" : "fail",
|
||||
traces: $"O10; MAX_NPGSQL_CONNS={max}; "
|
||||
+ $"FINAL_CONNS={finalCount}; "
|
||||
+ $"MINUTE1_MEAN={firstMinuteMean.ToString("F1", CultureInfo.InvariantCulture)}");
|
||||
|
||||
// Assert
|
||||
Assert.True(withinCap,
|
||||
$"max Npgsql connections {max} exceeds provisional cap {ProvisionalConnectionCap}");
|
||||
Assert.True(noUnboundedGrowth,
|
||||
$"final connection count {finalCount} > 1.3 × first-minute mean {firstMinuteMean:F1}");
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Trait("Traces", "H6|O10")]
|
||||
[Trait("max_ms", "360000")]
|
||||
public void NFT_RES_LIM_03_file_descriptors_within_1024_no_leak()
|
||||
{
|
||||
Skip.If(_load.SkipReason is not null, _load.SkipReason);
|
||||
Skip.IfNot(_load.LoadGeneratorMetTargetRps,
|
||||
"runner cannot sustain target load (NFR Reliability — not a SUT defect)");
|
||||
Assert.False(_load.SutExitedDuringWindow, "SUT exited during measurement window");
|
||||
|
||||
var samples = _load.FileDescriptorSamples;
|
||||
Assert.True(samples.Count >= 30,
|
||||
$"expected ≥ 30 FD samples over 5-min window, got {samples.Count}");
|
||||
|
||||
// Act
|
||||
var max = samples.Max();
|
||||
var minuteOneSampleCount = 60 / SteadyStateLoadFixture.SampleIntervalSeconds;
|
||||
// The spec calls out "count at t=1min" — anchor on the sample whose
|
||||
// timestamp is closest to (start + 60s).
|
||||
var minuteOneIx = Math.Min(minuteOneSampleCount - 1, samples.Count - 1);
|
||||
var minuteOneCount = samples[minuteOneIx];
|
||||
var finalCount = samples[^1];
|
||||
|
||||
var withinCap = max <= ProvisionalFdCap;
|
||||
var noLeak = finalCount <= 1.3 * Math.Max(minuteOneCount, 1);
|
||||
var pass = withinCap && noLeak;
|
||||
|
||||
Csv.Record(
|
||||
category: "ResLim",
|
||||
scenario: "NFT-RES-LIM-03",
|
||||
result: pass ? "pass" : "fail",
|
||||
traces: $"H6|O10; MAX_FD={max}; "
|
||||
+ $"FINAL_FD={finalCount}; MINUTE1_FD={minuteOneCount}");
|
||||
|
||||
// Assert
|
||||
Assert.True(withinCap,
|
||||
$"max FD count {max} exceeds provisional cap {ProvisionalFdCap}");
|
||||
Assert.True(noLeak,
|
||||
$"final FD count {finalCount} > 1.3 × minute-1 count {minuteOneCount}");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using Azaion.Missions.E2E.Fixtures;
|
||||
using Azaion.Missions.E2E.Helpers;
|
||||
using Xunit;
|
||||
|
||||
namespace Azaion.Missions.E2E.Tests.Security;
|
||||
|
||||
/// <summary>
|
||||
/// NFT-SEC-01..06 + 04b — JWT authn/authz scenarios from
|
||||
/// <c>_docs/02_document/tests/security-tests.md</c>.
|
||||
/// Traces: AC-5.2..AC-5.6, AC-5.8, AC-5.11, AC-5.12, AC-9.1, AC-9.2.
|
||||
/// </summary>
|
||||
[Collection("SecurityAuthClaims")]
|
||||
[Trait("Category", "Sec")]
|
||||
[Trait("db_access", "seed-or-assert-only")]
|
||||
public sealed class AuthClaimsTests : TestBase, IClassFixture<DbResetFixture>
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Traces", "AC-5.4")]
|
||||
[Trait("max_ms", "5000")]
|
||||
public async Task NFT_SEC_01_missing_authorization_header_rejects_protected_endpoints_with_401_and_no_db_write()
|
||||
{
|
||||
// Arrange
|
||||
DbResetFixture.ResetDatabase(TestEnvironment.DbSideChannel);
|
||||
Seeds.Apply(Seeds.OneDefaultVehicle.Sql);
|
||||
var anyMissionId = Guid.NewGuid();
|
||||
var preCount = DbAssertions.TableRowCount("vehicles");
|
||||
|
||||
// Act
|
||||
// Assert — GET /vehicles
|
||||
using (var resp = await Missions.GetAsync("/vehicles"))
|
||||
await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.Unauthorized);
|
||||
|
||||
using (var resp = await Missions.GetAsync("/missions"))
|
||||
await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.Unauthorized);
|
||||
|
||||
using (var resp = await Missions.GetAsync($"/missions/{anyMissionId}/waypoints"))
|
||||
await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.Unauthorized);
|
||||
|
||||
var postBody = new
|
||||
{
|
||||
Type = 0,
|
||||
Model = "Bayraktar",
|
||||
Name = "BR-noauth",
|
||||
FuelType = 1,
|
||||
BatteryCapacity = 0,
|
||||
EngineConsumption = 5,
|
||||
EngineConsumptionIdle = 1,
|
||||
IsDefault = false
|
||||
};
|
||||
using (var post = new HttpRequestMessage(HttpMethod.Post, "/vehicles")
|
||||
{
|
||||
Content = JsonContent.Create(postBody)
|
||||
})
|
||||
using (var resp = await Missions.SendAsync(post))
|
||||
await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.Unauthorized);
|
||||
|
||||
// Assert — POST 401 did not write a row.
|
||||
var postCount = DbAssertions.TableRowCount("vehicles");
|
||||
Assert.Equal(preCount, postCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Traces", "AC-5.5")]
|
||||
[Trait("max_ms", "5000")]
|
||||
public async Task NFT_SEC_02_invalid_signature_rejects_byte_flip_and_foreign_keypair_with_401()
|
||||
{
|
||||
// Arrange — single-byte-flip uses a mock-signed token; foreign-keypair
|
||||
// uses a local ECDSA P-256 (the one in-test signing path the task
|
||||
// spec permits).
|
||||
var good = await Tokens.MintDefaultAsync();
|
||||
var flipped = FlipFirstSignatureChar(good.Jwt);
|
||||
|
||||
using var foreign = new ForeignKeypair();
|
||||
var foreignJwt = foreign.Mint(
|
||||
TestEnvironment.JwtIssuer, TestEnvironment.JwtAudience, "FL");
|
||||
|
||||
// Act
|
||||
// Assert — flipped signature
|
||||
using (var req = new HttpRequestMessage(HttpMethod.Get, "/vehicles"))
|
||||
{
|
||||
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", flipped);
|
||||
using var resp = await Missions.SendAsync(req);
|
||||
await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.Unauthorized);
|
||||
}
|
||||
|
||||
// (Act+Assert — foreign keypair token (kid not in JWKS).)
|
||||
using (var req = new HttpRequestMessage(HttpMethod.Get, "/vehicles"))
|
||||
{
|
||||
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", foreignJwt);
|
||||
using var resp = await Missions.SendAsync(req);
|
||||
await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.Unauthorized);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Traces", "AC-5.2,AC-5.6")]
|
||||
[Trait("max_ms", "5000")]
|
||||
public async Task NFT_SEC_03_clock_skew_30s_rejects_minus_60_and_accepts_minus_15()
|
||||
{
|
||||
// Arrange — both tokens are otherwise identical; only exp differs.
|
||||
var expiredBeyondSkew = await Tokens.MintAsync(
|
||||
new SignRequest(Permissions: "FL", ExpOffsetSeconds: -60));
|
||||
var expiredWithinSkew = await Tokens.MintAsync(
|
||||
new SignRequest(Permissions: "FL", ExpOffsetSeconds: -15));
|
||||
|
||||
// Act
|
||||
// Assert — outside the 30s skew window.
|
||||
using (var req = new HttpRequestMessage(HttpMethod.Get, "/vehicles"))
|
||||
{
|
||||
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", expiredBeyondSkew.Jwt);
|
||||
using var resp = await Missions.SendAsync(req);
|
||||
await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.Unauthorized);
|
||||
}
|
||||
|
||||
// Inside the 30s skew window.
|
||||
using (var req = new HttpRequestMessage(HttpMethod.Get, "/vehicles"))
|
||||
{
|
||||
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", expiredWithinSkew.Jwt);
|
||||
using var resp = await Missions.SendAsync(req);
|
||||
await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.OK);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Traces", "AC-5.3,AC-5.11")]
|
||||
[Trait("max_ms", "5000")]
|
||||
public async Task NFT_SEC_04_wrong_iss_rejected_default_iss_accepted()
|
||||
{
|
||||
// Arrange
|
||||
var wrongIss = await Tokens.MintAsync(
|
||||
new SignRequest(Iss: "https://attacker.example.com", Permissions: "FL"));
|
||||
var defaultIss = await Tokens.MintDefaultAsync();
|
||||
|
||||
// Act
|
||||
// Assert
|
||||
using (var req = new HttpRequestMessage(HttpMethod.Get, "/vehicles"))
|
||||
{
|
||||
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", wrongIss.Jwt);
|
||||
using var resp = await Missions.SendAsync(req);
|
||||
await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.Unauthorized);
|
||||
}
|
||||
|
||||
using (var req = new HttpRequestMessage(HttpMethod.Get, "/vehicles"))
|
||||
{
|
||||
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", defaultIss.Jwt);
|
||||
using var resp = await Missions.SendAsync(req);
|
||||
await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.OK);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Traces", "AC-5.3,AC-5.12")]
|
||||
[Trait("max_ms", "5000")]
|
||||
public async Task NFT_SEC_04b_wrong_aud_rejected()
|
||||
{
|
||||
// Arrange
|
||||
var wrongAud = await Tokens.MintAsync(
|
||||
new SignRequest(Aud: "wrong-audience", Permissions: "FL"));
|
||||
|
||||
// Act
|
||||
// Assert
|
||||
using var req = new HttpRequestMessage(HttpMethod.Get, "/vehicles");
|
||||
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", wrongAud.Jwt);
|
||||
using var resp = await Missions.SendAsync(req);
|
||||
await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.Unauthorized);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Traces", "AC-5.8,AC-9.1")]
|
||||
[Trait("max_ms", "5000")]
|
||||
public async Task NFT_SEC_05_missing_permissions_claim_returns_403()
|
||||
{
|
||||
// Arrange — Permissions=null + PermissionsArray=null omits the claim.
|
||||
var noPermissions = await Tokens.MintAsync(new SignRequest());
|
||||
|
||||
// Act
|
||||
using var req = new HttpRequestMessage(HttpMethod.Get, "/vehicles");
|
||||
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", noPermissions.Jwt);
|
||||
using var resp = await Missions.SendAsync(req);
|
||||
|
||||
// Assert — authentication succeeds, authorization fails → 403 (NOT 401).
|
||||
await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.Forbidden);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("ADMIN")]
|
||||
[InlineData("fl")]
|
||||
[InlineData("FLight")]
|
||||
[Trait("Traces", "AC-9.1,AC-9.2")]
|
||||
[Trait("max_ms", "5000")]
|
||||
public async Task NFT_SEC_06_wrong_single_permission_value_returns_403(string permissions)
|
||||
{
|
||||
// Arrange
|
||||
var token = await Tokens.MintAsync(new SignRequest(Permissions: permissions));
|
||||
|
||||
// Act
|
||||
using var req = new HttpRequestMessage(HttpMethod.Get, "/vehicles");
|
||||
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Jwt);
|
||||
using var resp = await Missions.SendAsync(req);
|
||||
|
||||
// Assert — RequireClaim("permissions","FL") is case-sensitive exact match.
|
||||
await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.Forbidden);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Traces", "AC-9.1,AC-9.2")]
|
||||
[Trait("max_ms", "5000")]
|
||||
public async Task NFT_SEC_06_multi_value_permissions_array_accepts_when_FL_is_present()
|
||||
{
|
||||
// Arrange — array permissions claim; ASP.NET's JWT handler flattens
|
||||
// an array claim into multiple per-value claims, so RequireClaim
|
||||
// matches if ANY value equals "FL".
|
||||
var token = await Tokens.MintAsync(
|
||||
new SignRequest(PermissionsArray: new[] { "FL", "ADMIN" }));
|
||||
|
||||
// Act
|
||||
using var req = new HttpRequestMessage(HttpMethod.Get, "/vehicles");
|
||||
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Jwt);
|
||||
using var resp = await Missions.SendAsync(req);
|
||||
|
||||
// Assert
|
||||
await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
private static string FlipFirstSignatureChar(string jwt)
|
||||
{
|
||||
var parts = jwt.Split('.');
|
||||
if (parts.Length != 3)
|
||||
throw new InvalidOperationException(
|
||||
"expected a JWS-compact JWT with exactly 3 segments");
|
||||
var sig = parts[2].ToCharArray();
|
||||
// Toggle the first char between two base64url-valid letters so the
|
||||
// result is still parseable but signature verification fails.
|
||||
sig[0] = sig[0] == 'A' ? 'B' : 'A';
|
||||
return $"{parts[0]}.{parts[1]}.{new string(sig)}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
using System.Net;
|
||||
using Azaion.Missions.E2E.Helpers;
|
||||
using Xunit;
|
||||
|
||||
namespace Azaion.Missions.E2E.Tests.Security;
|
||||
|
||||
/// <summary>
|
||||
/// NFT-SEC-13 — CORS posture across environments. The Production-gate
|
||||
/// rejects an empty allow-list (CorsConfigurationValidator); the
|
||||
/// Test/Development environment logs a PermissiveDefaultWarning when the
|
||||
/// same shape is observed. Each scenario spawns its own missions container
|
||||
/// via <c>docker run</c>. Traces: AC-6.11, E9.
|
||||
/// </summary>
|
||||
[Collection("SecurityCors")]
|
||||
[Trait("Category", "Sec")]
|
||||
[Trait("db_access", "seed-or-assert-only")]
|
||||
public sealed class CorsConfigTests
|
||||
{
|
||||
private const string PostgresUrl =
|
||||
"postgresql://postgres:postgres-test@missions-postgres-test:5432/azaion";
|
||||
private const string JwksUrlHttps =
|
||||
"https://jwks-mock:8443/.well-known/jwks.json";
|
||||
|
||||
[SkippableFact]
|
||||
[Trait("Traces", "AC-6.11,E9")]
|
||||
[Trait("max_ms", "15000")]
|
||||
public void NFT_SEC_13_production_empty_origins_exits_non_zero_with_invalid_operation_exception()
|
||||
{
|
||||
Skip.IfNot(MissionsContainerHelper.Enabled,
|
||||
"MissionsContainerHelper requires COMPOSE_RESTART_ENABLED=1 and docker CLI access.");
|
||||
|
||||
// Arrange
|
||||
var env = BaseEnv();
|
||||
env["ASPNETCORE_ENVIRONMENT"] = "Production";
|
||||
|
||||
// Act
|
||||
var result = MissionsContainerHelper.RunUntilExit(
|
||||
"missions-sec13-prod-empty", env, TimeSpan.FromSeconds(15));
|
||||
|
||||
// Assert
|
||||
Assert.NotEqual(0, result.ExitCode);
|
||||
Assert.Contains("InvalidOperationException", result.Logs, StringComparison.Ordinal);
|
||||
var mentionsCors =
|
||||
result.Logs.Contains("CorsConfig", StringComparison.Ordinal)
|
||||
|| result.Logs.Contains("AllowedOrigins", StringComparison.Ordinal)
|
||||
|| result.Logs.Contains("Production", StringComparison.Ordinal);
|
||||
Assert.True(mentionsCors,
|
||||
$"logs must mention CorsConfig/AllowedOrigins/Production. Logs:\n{result.Logs}");
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Trait("Traces", "AC-6.11")]
|
||||
[Trait("max_ms", "15000")]
|
||||
public async Task NFT_SEC_13_production_allow_any_origin_starts_with_warning_log()
|
||||
{
|
||||
Skip.IfNot(MissionsContainerHelper.Enabled,
|
||||
"MissionsContainerHelper requires COMPOSE_RESTART_ENABLED=1 and docker CLI access.");
|
||||
|
||||
// Arrange
|
||||
var env = BaseEnv();
|
||||
env["ASPNETCORE_ENVIRONMENT"] = "Production";
|
||||
env["CorsConfig__AllowAnyOrigin"] = "true";
|
||||
|
||||
// Act
|
||||
using var c = await MissionsContainerHelper.StartAndWaitForHealthAsync(
|
||||
"missions-sec13-prod-anyorigin", env, TimeSpan.FromSeconds(20));
|
||||
|
||||
// Assert — container is up AND a warning sits in the log slice.
|
||||
var logs = c.ReadLogs();
|
||||
var mentionsWarning =
|
||||
logs.Contains("permissive", StringComparison.OrdinalIgnoreCase)
|
||||
|| logs.Contains("AllowAnyOrigin", StringComparison.Ordinal)
|
||||
|| logs.Contains("warn", StringComparison.OrdinalIgnoreCase);
|
||||
Assert.True(mentionsWarning,
|
||||
$"logs must include a permissive-CORS warning. Logs:\n{logs}");
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Trait("Traces", "AC-6.11")]
|
||||
[Trait("max_ms", "20000")]
|
||||
public async Task NFT_SEC_13_production_explicit_origin_preflight_allowed_and_other_origins_rejected()
|
||||
{
|
||||
Skip.IfNot(MissionsContainerHelper.Enabled,
|
||||
"MissionsContainerHelper requires COMPOSE_RESTART_ENABLED=1 and docker CLI access.");
|
||||
|
||||
// Arrange
|
||||
const string allowedOrigin = "https://operator.example.com";
|
||||
const string disallowedOrigin = "https://attacker.example.com";
|
||||
|
||||
var env = BaseEnv();
|
||||
env["ASPNETCORE_ENVIRONMENT"] = "Production";
|
||||
env["CorsConfig__AllowedOrigins__0"] = allowedOrigin;
|
||||
|
||||
var containerName = "missions-sec13-prod-origins";
|
||||
using var c = await MissionsContainerHelper.StartAndWaitForHealthAsync(
|
||||
containerName, env, TimeSpan.FromSeconds(20));
|
||||
|
||||
// Act — allowed origin preflight.
|
||||
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
|
||||
var url = new Uri($"http://{containerName}:8080/vehicles");
|
||||
|
||||
using (var preflight = new HttpRequestMessage(HttpMethod.Options, url))
|
||||
{
|
||||
preflight.Headers.Add("Origin", allowedOrigin);
|
||||
preflight.Headers.Add("Access-Control-Request-Method", "GET");
|
||||
using var resp = await http.SendAsync(preflight);
|
||||
Assert.True(resp.IsSuccessStatusCode,
|
||||
$"preflight from allowed origin should succeed; got {(int)resp.StatusCode}");
|
||||
Assert.True(resp.Headers.TryGetValues("Access-Control-Allow-Origin", out var allowVals),
|
||||
"preflight from allowed origin must echo Access-Control-Allow-Origin");
|
||||
Assert.Contains(allowedOrigin, allowVals);
|
||||
}
|
||||
|
||||
// Disallowed origin preflight — middleware responds without echoing the header.
|
||||
using (var preflight = new HttpRequestMessage(HttpMethod.Options, url))
|
||||
{
|
||||
preflight.Headers.Add("Origin", disallowedOrigin);
|
||||
preflight.Headers.Add("Access-Control-Request-Method", "GET");
|
||||
using var resp = await http.SendAsync(preflight);
|
||||
// ASP.NET Core CORS middleware returns 204 even when origin is
|
||||
// disallowed, but does NOT emit Access-Control-Allow-Origin —
|
||||
// the missing header is the signal browsers act on.
|
||||
Assert.False(resp.Headers.TryGetValues("Access-Control-Allow-Origin", out _),
|
||||
"preflight from disallowed origin must NOT echo Access-Control-Allow-Origin");
|
||||
}
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Trait("Traces", "AC-6.11")]
|
||||
[Trait("max_ms", "15000")]
|
||||
public async Task NFT_SEC_13_test_environment_permissive_default_emits_warning_log()
|
||||
{
|
||||
Skip.IfNot(MissionsContainerHelper.Enabled,
|
||||
"MissionsContainerHelper requires COMPOSE_RESTART_ENABLED=1 and docker CLI access.");
|
||||
|
||||
// Arrange — Test env with no CorsConfig. EnsureSafeForEnvironment
|
||||
// is a no-op; permissive policy is applied with a warning log.
|
||||
var env = BaseEnv();
|
||||
env["ASPNETCORE_ENVIRONMENT"] = "Test";
|
||||
|
||||
// Act
|
||||
using var c = await MissionsContainerHelper.StartAndWaitForHealthAsync(
|
||||
"missions-sec13-test-permissive", env, TimeSpan.FromSeconds(20));
|
||||
|
||||
// Assert
|
||||
var logs = c.ReadLogs();
|
||||
Assert.Contains("Permissive", logs, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> BaseEnv() => new(StringComparer.Ordinal)
|
||||
{
|
||||
{ "DATABASE_URL", PostgresUrl },
|
||||
{ "JWT_ISSUER", "https://admin-test.azaion.local" },
|
||||
{ "JWT_AUDIENCE", "azaion-edge" },
|
||||
{ "JWT_JWKS_URL", JwksUrlHttps },
|
||||
{ "ASPNETCORE_URLS", "http://+:8080" },
|
||||
{ "ASPNETCORE_ENVIRONMENT","Test" },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Azaion.Missions.E2E.Fixtures;
|
||||
using Azaion.Missions.E2E.Helpers;
|
||||
using Npgsql;
|
||||
using Xunit;
|
||||
|
||||
namespace Azaion.Missions.E2E.Tests.Security;
|
||||
|
||||
/// <summary>
|
||||
/// NFT-SEC-07 (health anonymous), NFT-SEC-09 (SQL-injection guard),
|
||||
/// NFT-SEC-10 (alg-pin) — fast cross-cutting security checks that share a
|
||||
/// happy-path stack and need no destructive teardown.
|
||||
/// Traces: AC-7.1, AC-9.4, AC-1.6, AC-2.3 (defensive), AC-5.1, AC-5.10.
|
||||
/// </summary>
|
||||
[Collection("SecurityCrossCutting")]
|
||||
[Trait("Category", "Sec")]
|
||||
[Trait("db_access", "seed-or-assert-only")]
|
||||
public sealed class CrossCuttingTests : TestBase, IClassFixture<DbResetFixture>
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Traces", "AC-7.1,AC-9.4")]
|
||||
[Trait("max_ms", "5000")]
|
||||
public async Task NFT_SEC_07_health_is_anonymous_and_accepts_expired_token()
|
||||
{
|
||||
// Arrange — anonymous case + expired-token case prove the auth
|
||||
// pipeline does NOT run for /health (an expired token would otherwise
|
||||
// 401 long before reaching the endpoint).
|
||||
var expired = await Tokens.MintAsync(new SignRequest(Permissions: "FL", ExpOffsetSeconds: -3600));
|
||||
|
||||
// Act
|
||||
// Assert — anonymous
|
||||
using (var resp = await Missions.GetAsync("/health"))
|
||||
{
|
||||
await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.OK);
|
||||
var body = await resp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
Assert.Equal("healthy", body.GetProperty("status").GetString());
|
||||
}
|
||||
|
||||
// Expired token
|
||||
using (var req = new HttpRequestMessage(HttpMethod.Get, "/health"))
|
||||
{
|
||||
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", expired.Jwt);
|
||||
using var resp = await Missions.SendAsync(req);
|
||||
await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.OK);
|
||||
var body = await resp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
Assert.Equal("healthy", body.GetProperty("status").GetString());
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Traces", "AC-1.6,AC-2.3")]
|
||||
[Trait("max_ms", "5000")]
|
||||
public async Task NFT_SEC_09_sql_injection_payloads_are_treated_as_literal_strings()
|
||||
{
|
||||
// Arrange
|
||||
DbResetFixture.ResetDatabase(TestEnvironment.DbSideChannel);
|
||||
Seeds.Apply(Seeds.Three_BR01_BR02_MQ9.Sql);
|
||||
var token = await Tokens.MintDefaultAsync();
|
||||
|
||||
// Act
|
||||
// Assert — OR '1'='1 should NOT short-circuit to "all rows".
|
||||
// EscapeDataString must wrap ONLY the value, not the "name=" key
|
||||
// (escaping the '=' produces a single oddly-named key, defeating
|
||||
// the filter and returning the unfiltered list).
|
||||
using (var req = new HttpRequestMessage(
|
||||
HttpMethod.Get,
|
||||
"/vehicles?name=" + Uri.EscapeDataString("' OR '1'='1")))
|
||||
{
|
||||
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Jwt);
|
||||
using var resp = await Missions.SendAsync(req);
|
||||
await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.OK);
|
||||
var raw = await resp.Content.ReadAsStringAsync();
|
||||
using var doc = JsonDocument.Parse(raw);
|
||||
Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind);
|
||||
// The literal "'OR'1'='1" never matches any vehicle name.
|
||||
Assert.Equal(0, doc.RootElement.GetArrayLength());
|
||||
}
|
||||
|
||||
// Drop-table payload should NOT execute as SQL.
|
||||
using (var req = new HttpRequestMessage(
|
||||
HttpMethod.Get,
|
||||
"/missions?name=" + Uri.EscapeDataString("; DROP TABLE vehicles; --")))
|
||||
{
|
||||
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Jwt);
|
||||
using var resp = await Missions.SendAsync(req);
|
||||
await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.OK);
|
||||
var raw = await resp.Content.ReadAsStringAsync();
|
||||
using var doc = JsonDocument.Parse(raw);
|
||||
// CARRY-FORWARD (json-camelcase-vs-pascalcase): envelope is camelCase.
|
||||
Assert.True(doc.RootElement.TryGetProperty("totalCount", out var totalEl));
|
||||
Assert.Equal(0, totalEl.GetInt32());
|
||||
}
|
||||
|
||||
// Side-channel: vehicles table still exists.
|
||||
var oid = ScalarToRegclass("vehicles");
|
||||
Assert.NotNull(oid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Traces", "AC-5.1,AC-5.10")]
|
||||
[Trait("max_ms", "5000")]
|
||||
public async Task NFT_SEC_10_alg_pin_rejects_HS256_confusion_and_unsigned_tokens()
|
||||
{
|
||||
// Arrange — both attack shapes carry valid claims; only `alg` differs.
|
||||
var hs256 = await Tokens.MintAsync(
|
||||
new SignRequest(Permissions: "FL", AlgOverride: "HS256"));
|
||||
var unsigned = await Tokens.MintAsync(
|
||||
new SignRequest(Permissions: "FL", AlgOverride: "none"));
|
||||
|
||||
// Act
|
||||
// Assert — HS256 confusion attack rejected.
|
||||
using (var req = new HttpRequestMessage(HttpMethod.Get, "/vehicles"))
|
||||
{
|
||||
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", hs256.Jwt);
|
||||
using var resp = await Missions.SendAsync(req);
|
||||
await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.Unauthorized);
|
||||
}
|
||||
|
||||
// alg:none unsigned token rejected.
|
||||
using (var req = new HttpRequestMessage(HttpMethod.Get, "/vehicles"))
|
||||
{
|
||||
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", unsigned.Jwt);
|
||||
using var resp = await Missions.SendAsync(req);
|
||||
await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.Unauthorized);
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ScalarToRegclass(string table)
|
||||
{
|
||||
using var conn = new NpgsqlConnection(TestEnvironment.DbSideChannel);
|
||||
conn.Open();
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT to_regclass(@t)::TEXT";
|
||||
cmd.Parameters.AddWithValue("t", table);
|
||||
return cmd.ExecuteScalar() as string;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using Azaion.Missions.E2E.Fixtures;
|
||||
using Azaion.Missions.E2E.Helpers;
|
||||
using Npgsql;
|
||||
using Xunit;
|
||||
|
||||
namespace Azaion.Missions.E2E.Tests.Security;
|
||||
|
||||
/// <summary>
|
||||
/// NFT-SEC-08 — security-category variant of FT-N-08. Same destructive
|
||||
/// fixture (DROP TABLE vehicles CASCADE) but emphasises the redaction
|
||||
/// assertions and the matching log-line presence. Lives in the
|
||||
/// <c>ErrorEnvelope500</c> collection so xUnit serialises against FT-N-08
|
||||
/// and the consumer image still uses one round of compose restart for both.
|
||||
/// Traces: AC-8.6, AC-10.3.
|
||||
/// </summary>
|
||||
[Collection("ErrorEnvelope500")]
|
||||
[Trait("Category", "Sec")]
|
||||
[Trait("db_access", "seed-or-assert-only")]
|
||||
public sealed class ErrorRedactionTests : TestBase, IClassFixture<ComposeRestartFixture>
|
||||
{
|
||||
private readonly ComposeRestartFixture _restart;
|
||||
|
||||
public ErrorRedactionTests(ComposeRestartFixture restart) => _restart = restart;
|
||||
|
||||
[SkippableFact]
|
||||
[Trait("Traces", "AC-8.6,AC-10.3")]
|
||||
[Trait("max_ms", "5000")]
|
||||
public async Task NFT_SEC_08_500_body_redacts_internals_and_log_records_exception_type()
|
||||
{
|
||||
Skip.IfNot(_restart.Enabled,
|
||||
"ComposeRestartFixture disabled (COMPOSE_RESTART_ENABLED!=1). " +
|
||||
"NFT-SEC-08 drops the vehicles table and needs the full stack restart " +
|
||||
"in teardown.");
|
||||
|
||||
// Arrange — DROP TABLE vehicles forces the SUT into the generic
|
||||
// catch path on /vehicles/{any}.
|
||||
DbResetFixture.ResetDatabase(TestEnvironment.DbSideChannel);
|
||||
DropVehiclesTable();
|
||||
var requestStart = DateTime.UtcNow;
|
||||
var token = await Tokens.MintDefaultAsync();
|
||||
|
||||
try
|
||||
{
|
||||
// Act
|
||||
using var req = new HttpRequestMessage(HttpMethod.Get, $"/vehicles/{Guid.NewGuid()}");
|
||||
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Jwt);
|
||||
using var response = await Missions.SendAsync(req);
|
||||
|
||||
// Assert — wire-shape is EXACTLY { statusCode, message }; no extra
|
||||
// keys, no stack-leak keywords anywhere in the JSON DOM.
|
||||
var problem = await HttpAssertions.AssertProblemEnvelopeAsync(
|
||||
response, HttpStatusCode.InternalServerError);
|
||||
Assert.Equal(500, problem.StatusCode);
|
||||
Assert.Equal("Internal server error", problem.Message);
|
||||
|
||||
// The unhandled exception MUST still be logged. The log line
|
||||
// includes the exception type (Npgsql.PostgresException) so an
|
||||
// operator can diagnose without the response leaking it.
|
||||
var deadline = DateTime.UtcNow.AddSeconds(2);
|
||||
var sawLog = false;
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
if (DockerLogs.Contains("missions-sut", "Unhandled exception", requestStart))
|
||||
{
|
||||
sawLog = true;
|
||||
break;
|
||||
}
|
||||
await Task.Delay(100);
|
||||
}
|
||||
Assert.True(sawLog,
|
||||
"expected 'Unhandled exception' in missions-sut docker logs within 2s of request");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_restart.RestartStack();
|
||||
}
|
||||
}
|
||||
|
||||
private static void DropVehiclesTable()
|
||||
{
|
||||
using var conn = new NpgsqlConnection(TestEnvironment.DbSideChannel);
|
||||
conn.Open();
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "DROP TABLE IF EXISTS vehicles CASCADE;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Azaion.Missions.E2E.Fixtures;
|
||||
using Azaion.Missions.E2E.Helpers;
|
||||
using Xunit;
|
||||
|
||||
namespace Azaion.Missions.E2E.Tests.Security;
|
||||
|
||||
/// <summary>
|
||||
/// NFT-SEC-11 — security-shaped view of JWKS rotation. Verifies the kid-cache
|
||||
/// mechanics + grace-window timing; the resilience-shaped variant
|
||||
/// (no-restart) lives in <c>Tests/Resilience/JwksRotationTests.cs</c>.
|
||||
/// Traces: AC-5.7.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Owns the <c>JwksRotation</c> xUnit collection because rotating the mock
|
||||
/// changes the active kid for every subsequent test that holds a stale
|
||||
/// token. After running, the next test class in any collection mints a
|
||||
/// fresh token, so it picks up the new kid on its next JWKS refresh.
|
||||
/// </remarks>
|
||||
[Collection("JwksRotation")]
|
||||
[Trait("Category", "Sec")]
|
||||
[Trait("db_access", "seed-or-assert-only")]
|
||||
public sealed class JwksRotationTests : TestBase, IClassFixture<DbResetFixture>
|
||||
{
|
||||
[Fact(Timeout = 130_000)]
|
||||
[Trait("Traces", "AC-5.7")]
|
||||
[Trait("max_ms", "120000")]
|
||||
public async Task NFT_SEC_11_unknown_kid_rotation_completes_within_120s_honouring_grace()
|
||||
{
|
||||
// Arrange — warm up: confirm the active key works before rotation.
|
||||
DbResetFixture.ResetDatabase(TestEnvironment.DbSideChannel);
|
||||
Seeds.Apply(Seeds.OneDefaultVehicle.Sql);
|
||||
|
||||
var t1 = await Tokens.MintDefaultAsync();
|
||||
var kidV1 = t1.Kid;
|
||||
using (var resp = await CallVehiclesAsync(t1.Jwt))
|
||||
await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.OK);
|
||||
|
||||
var rotationStart = DateTime.UtcNow;
|
||||
|
||||
// Act 1: Rotate the mock. After this call, kid_v2 is active and
|
||||
// kid_v1 is retained for OLD_KEY_GRACE_SECONDS=5.
|
||||
var kidV2 = await RotateMockAsync();
|
||||
Assert.NotEqual(kidV1, kidV2);
|
||||
|
||||
// Mint T2 with the brand-new active key.
|
||||
var t2 = await Tokens.MintDefaultAsync();
|
||||
Assert.Equal(kidV2, t2.Kid);
|
||||
|
||||
// Assert AC-5.7.1 — T2 is rejected BEFORE missions refreshes its JWKS
|
||||
// cache (the new kid is not yet in the cache). We probe immediately
|
||||
// and require at least one 401 — once missions refreshes, subsequent
|
||||
// calls should succeed.
|
||||
using (var resp = await CallVehiclesAsync(t2.Jwt))
|
||||
await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.Unauthorized);
|
||||
|
||||
// Assert AC-5.7.3 — during the 5s grace window, the OLD-kid token T1
|
||||
// is still accepted (missions' cache still contains kid_v1 from the
|
||||
// initial bootstrap fetch; the cache hasn't refreshed yet).
|
||||
using (var resp = await CallVehiclesAsync(t1.Jwt))
|
||||
await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.OK);
|
||||
|
||||
// Act 2: Force JWKS refresh. The library's 5-minute floor on
|
||||
// AutomaticRefreshInterval makes proactive refresh impossible inside
|
||||
// the CI window, and the JwtBearer signature-failure refresh path is
|
||||
// bypassed by our custom IssuerSigningKeyResolver. The test-only
|
||||
// /test/refresh-jwks endpoint is the explicit substitute. Tracks the
|
||||
// wall-clock cost so the assertion still reflects the operational
|
||||
// budget (well under the 120s ceiling in AC-5.7).
|
||||
var refreshSw = System.Diagnostics.Stopwatch.StartNew();
|
||||
var kids = await JwksRefreshHelper.ForceRefreshAsync(Missions);
|
||||
refreshSw.Stop();
|
||||
Assert.Contains(kidV2, kids);
|
||||
Assert.True(refreshSw.Elapsed.TotalSeconds < 90,
|
||||
$"JWKS refresh took {refreshSw.Elapsed.TotalSeconds:F1}s; budget is 90s");
|
||||
|
||||
using (var resp = await CallVehiclesAsync(t2.Jwt))
|
||||
await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.OK);
|
||||
|
||||
// Assert AC-5.7.4 — after the 5s grace window, the mock refuses to
|
||||
// sign with the old kid. Wait until grace certainly expired.
|
||||
var graceExpiry = rotationStart.AddSeconds(7);
|
||||
var until = graceExpiry - DateTime.UtcNow;
|
||||
if (until > TimeSpan.Zero)
|
||||
await Task.Delay(until);
|
||||
|
||||
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
|
||||
var signUrl = new Uri(TestEnvironment.JwksMockSignUrl);
|
||||
using var signResponse = await http.PostAsJsonAsync(
|
||||
signUrl,
|
||||
new { kid_override = kidV1, permissions = "FL" });
|
||||
Assert.Equal(HttpStatusCode.BadRequest, signResponse.StatusCode);
|
||||
var body = await signResponse.Content.ReadFromJsonAsync<JsonElement>();
|
||||
Assert.True(body.TryGetProperty("error", out _),
|
||||
"mock refusal must include 'error' field");
|
||||
}
|
||||
|
||||
private async Task<HttpResponseMessage> CallVehiclesAsync(string jwt)
|
||||
{
|
||||
var req = new HttpRequestMessage(HttpMethod.Get, "/vehicles");
|
||||
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt);
|
||||
return await Missions.SendAsync(req);
|
||||
}
|
||||
|
||||
private static async Task<string> RotateMockAsync()
|
||||
{
|
||||
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
|
||||
var rotateUrl = new Uri(new Uri(TestEnvironment.JwksMockBaseUrl), "/rotate-key");
|
||||
using var resp = await http.PostAsync(rotateUrl, content: null);
|
||||
resp.EnsureSuccessStatusCode();
|
||||
var body = await resp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
return body.GetProperty("kid").GetString()
|
||||
?? throw new InvalidOperationException("mock /rotate-key returned no kid");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using Azaion.Missions.E2E.Helpers;
|
||||
using Xunit;
|
||||
|
||||
namespace Azaion.Missions.E2E.Tests.Security;
|
||||
|
||||
/// <summary>
|
||||
/// NFT-SEC-12 — security-shaped startup config posture. The 4 missing-env
|
||||
/// rows are also exercised by NFT-RES-05 row 1–4 in
|
||||
/// <c>Tests/Resilience/ConfigDbStartupTests.cs</c>; here they fall under the
|
||||
/// <c>Sec</c> category so the CSV report carries both rows. Traces: AC-6.1,
|
||||
/// AC-6.2, E1, E3.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Each scenario spawns its own missions container via <c>docker run</c>
|
||||
/// (independent of the long-running compose stack) so the test can probe
|
||||
/// startup behaviour without taking the shared SUT down. The helper bails
|
||||
/// with <see cref="Skip.IfNot(bool, string)"/> when docker access is not
|
||||
/// available (developer inner-loop with <c>COMPOSE_RESTART_ENABLED=0</c>).
|
||||
/// </remarks>
|
||||
[Collection("SecurityStartupConfig")]
|
||||
[Trait("Category", "Sec")]
|
||||
[Trait("db_access", "seed-or-assert-only")]
|
||||
public sealed class StartupConfigTests
|
||||
{
|
||||
private const string PostgresUrl =
|
||||
"postgresql://postgres:postgres-test@missions-postgres-test:5432/azaion";
|
||||
private const string JwksUrlHttps =
|
||||
"https://jwks-mock:8443/.well-known/jwks.json";
|
||||
private const string Issuer = "https://admin-test.azaion.local";
|
||||
private const string Audience = "azaion-edge";
|
||||
|
||||
public static IEnumerable<object[]> MissingEnvCases() => new[]
|
||||
{
|
||||
new object[] { "missing_db_url", "DATABASE_URL", "Database:Url" },
|
||||
new object[] { "missing_jwt_issuer", "JWT_ISSUER", "Jwt:Issuer" },
|
||||
new object[] { "missing_jwt_aud", "JWT_AUDIENCE", "Jwt:Audience" },
|
||||
new object[] { "missing_jwks_url", "JWT_JWKS_URL", "Jwt:JwksUrl" },
|
||||
};
|
||||
|
||||
[SkippableTheory]
|
||||
[MemberData(nameof(MissingEnvCases))]
|
||||
[Trait("Traces", "AC-6.1,AC-6.2")]
|
||||
[Trait("max_ms", "5000")]
|
||||
public void NFT_SEC_12_missing_required_env_var_exits_non_zero_with_invalid_operation_exception(
|
||||
string caseName, string omittedVar, string configAlias)
|
||||
{
|
||||
Skip.IfNot(MissionsContainerHelper.Enabled,
|
||||
"MissionsContainerHelper requires COMPOSE_RESTART_ENABLED=1 and docker CLI access.");
|
||||
|
||||
// Arrange
|
||||
var env = BaseEnv();
|
||||
env.Remove(omittedVar);
|
||||
var container = $"missions-sec12-{caseName}";
|
||||
|
||||
// Act
|
||||
var result = MissionsContainerHelper.RunUntilExit(
|
||||
container, env, TimeSpan.FromSeconds(15));
|
||||
|
||||
// Assert
|
||||
Assert.NotEqual(0, result.ExitCode);
|
||||
Assert.Contains("InvalidOperationException", result.Logs, StringComparison.Ordinal);
|
||||
var mentionsVar = result.Logs.Contains(omittedVar, StringComparison.Ordinal)
|
||||
|| result.Logs.Contains(configAlias, StringComparison.Ordinal);
|
||||
Assert.True(mentionsVar,
|
||||
$"logs must mention '{omittedVar}' or '{configAlias}'. Logs:\n{result.Logs}");
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Trait("Traces", "E1,E3")]
|
||||
[Trait("max_ms", "30000")]
|
||||
public async Task NFT_SEC_12_http_jwks_url_starts_then_fails_protected_request_with_RequireHttps_log()
|
||||
{
|
||||
Skip.IfNot(MissionsContainerHelper.Enabled,
|
||||
"MissionsContainerHelper requires COMPOSE_RESTART_ENABLED=1 and docker CLI access.");
|
||||
|
||||
// Arrange — config resolution succeeds (HTTP URL is a well-formed
|
||||
// string), so the container starts. The first protected request
|
||||
// triggers a JWKS fetch which the HttpDocumentRetriever rejects
|
||||
// because RequireHttps=true.
|
||||
var env = BaseEnv();
|
||||
env["JWT_JWKS_URL"] = "http://jwks-mock:8443/.well-known/jwks.json";
|
||||
|
||||
var container = "missions-sec12-http-jwks";
|
||||
using var c = await MissionsContainerHelper.StartAndWaitForHealthAsync(
|
||||
container, env, TimeSpan.FromSeconds(20));
|
||||
|
||||
// Mint a normal token from the mock — the SUT will reject it not
|
||||
// because the token is bad, but because it cannot fetch JWKS at all.
|
||||
var minter = new TokenMinter(TestEnvironment.JwksMockSignUrl);
|
||||
var token = await minter.MintDefaultAsync();
|
||||
|
||||
// Act — send /vehicles to the new SUT container directly.
|
||||
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
|
||||
var url = new Uri($"http://{container}:8080/vehicles");
|
||||
using var req = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Jwt);
|
||||
using var resp = await http.SendAsync(req);
|
||||
|
||||
// Assert — either 500 (RequireHttps exception bubbles to error
|
||||
// middleware) or 401 (auth handler swallows the inner exception).
|
||||
Assert.True(
|
||||
resp.StatusCode is HttpStatusCode.InternalServerError or HttpStatusCode.Unauthorized,
|
||||
$"expected 500 or 401, got {(int)resp.StatusCode}");
|
||||
|
||||
var logs = c.ReadLogs();
|
||||
var mentionsHttps = logs.Contains("RequireHttps", StringComparison.OrdinalIgnoreCase)
|
||||
|| logs.Contains("HTTPS", StringComparison.OrdinalIgnoreCase)
|
||||
|| logs.Contains("requires https", StringComparison.OrdinalIgnoreCase);
|
||||
Assert.True(mentionsHttps,
|
||||
$"logs must mention HTTPS / RequireHttps. Logs:\n{logs}");
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> BaseEnv() => new(StringComparer.Ordinal)
|
||||
{
|
||||
{ "DATABASE_URL", PostgresUrl },
|
||||
{ "JWT_ISSUER", Issuer },
|
||||
{ "JWT_AUDIENCE", Audience },
|
||||
{ "JWT_JWKS_URL", JwksUrlHttps },
|
||||
{ "ASPNETCORE_URLS", "http://+:8080" },
|
||||
{ "ASPNETCORE_ENVIRONMENT","Test" },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using Azaion.Missions.E2E.Fixtures;
|
||||
using Azaion.Missions.E2E.Helpers;
|
||||
using Xunit;
|
||||
|
||||
namespace Azaion.Missions.E2E.Tests.Vehicles;
|
||||
|
||||
/// <summary>
|
||||
/// FT-N-01..03 — vehicle negative scenarios from
|
||||
/// <c>_docs/02_document/tests/blackbox-tests.md § Negative</c>.
|
||||
/// FT-N-08 (generic 500 redacted body) lives in Tests/Errors because it
|
||||
/// owns its own destructive xUnit collection.
|
||||
/// Traces: AC-1.6 (no-match) / AC-1.7 (404) / AC-1.8 (409 in-use).
|
||||
/// </summary>
|
||||
[Collection("Vehicles")]
|
||||
[Trait("Category", "Blackbox")]
|
||||
[Trait("db_access", "seed-or-assert-only")]
|
||||
public sealed class NegativeTests : TestBase, IClassFixture<DbResetFixture>
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Traces", "AC-1.6")]
|
||||
[Trait("max_ms", "2000")]
|
||||
public async Task FT_N_01_filter_no_match_returns_empty_array_for_both_casings()
|
||||
{
|
||||
// Arrange
|
||||
DbResetFixture.ResetDatabase(TestEnvironment.DbSideChannel);
|
||||
Seeds.Apply(Seeds.Three_BR01_BR02_MQ9.Sql);
|
||||
var token = await Tokens.MintDefaultAsync();
|
||||
|
||||
async Task<List<VehicleDto>> FetchAsync(string query)
|
||||
{
|
||||
using var http = new HttpRequestMessage(HttpMethod.Get, "/vehicles?" + query);
|
||||
http.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Jwt);
|
||||
using var resp = await Missions.SendAsync(http);
|
||||
await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.OK);
|
||||
return await resp.Content.ReadFromJsonAsync<List<VehicleDto>>() ?? throw new InvalidOperationException("body deserialized to null");
|
||||
}
|
||||
|
||||
// Act
|
||||
var upper = await FetchAsync("name=ZZ");
|
||||
var lower = await FetchAsync("name=zz");
|
||||
|
||||
// Assert
|
||||
Assert.Empty(upper);
|
||||
Assert.Empty(lower);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Traces", "AC-1.7,AC-8.2")]
|
||||
[Trait("max_ms", "2000")]
|
||||
public async Task FT_N_02_get_vehicle_returns_404_with_problem_envelope()
|
||||
{
|
||||
// Arrange
|
||||
DbResetFixture.ResetDatabase(TestEnvironment.DbSideChannel);
|
||||
var token = await Tokens.MintDefaultAsync();
|
||||
var randomId = Guid.NewGuid();
|
||||
|
||||
// Act
|
||||
using var http = new HttpRequestMessage(HttpMethod.Get, $"/vehicles/{randomId}");
|
||||
http.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Jwt);
|
||||
using var response = await Missions.SendAsync(http);
|
||||
|
||||
// Assert
|
||||
await HttpAssertions.AssertProblemEnvelopeAsync(response, HttpStatusCode.NotFound)
|
||||
;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Traces", "AC-1.8,AC-8.5")]
|
||||
[Trait("max_ms", "2000")]
|
||||
public async Task FT_N_03_delete_in_use_vehicle_returns_409_and_row_remains()
|
||||
{
|
||||
// Arrange
|
||||
DbResetFixture.ResetDatabase(TestEnvironment.DbSideChannel);
|
||||
Seeds.Apply(Seeds.OneDefaultVehicle.Sql);
|
||||
var vehicleId = Seeds.OneDefaultVehicle.Id;
|
||||
var missionId = Guid.NewGuid();
|
||||
Seeds.Apply($"""
|
||||
INSERT INTO missions (id, created_date, name, vehicle_id)
|
||||
VALUES ('{missionId}', '2026-05-14T00:00:00Z', 'in-use', '{vehicleId}');
|
||||
""");
|
||||
var token = await Tokens.MintDefaultAsync();
|
||||
|
||||
// Act
|
||||
using var http = new HttpRequestMessage(HttpMethod.Delete, $"/vehicles/{vehicleId}");
|
||||
http.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Jwt);
|
||||
using var response = await Missions.SendAsync(http);
|
||||
|
||||
// Assert
|
||||
await HttpAssertions.AssertProblemEnvelopeAsync(response, HttpStatusCode.Conflict)
|
||||
;
|
||||
var remaining = DbAssertions.ScalarCount(
|
||||
"SELECT COUNT(*) FROM vehicles WHERE id = @id",
|
||||
("id", vehicleId));
|
||||
Assert.Equal(1L, remaining);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Azaion.Missions.E2E.Fixtures;
|
||||
using Azaion.Missions.E2E.Helpers;
|
||||
using Xunit;
|
||||
|
||||
namespace Azaion.Missions.E2E.Tests.Vehicles;
|
||||
|
||||
/// <summary>
|
||||
/// FT-P-01..06 — vehicle happy-path scenarios from
|
||||
/// <c>_docs/02_document/tests/blackbox-tests.md § Positive</c>.
|
||||
/// Traces: AC-1.1 / AC-1.2 / AC-1.4 / AC-1.5 / AC-1.6 / AC-1.10.
|
||||
/// </summary>
|
||||
[Collection("Vehicles")]
|
||||
[Trait("Category", "Blackbox")]
|
||||
[Trait("db_access", "seed-or-assert-only")]
|
||||
public sealed class PositiveTests : TestBase, IClassFixture<DbResetFixture>
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Traces", "AC-1.1")]
|
||||
[Trait("max_ms", "5000")]
|
||||
[Trait("carry_forward", "json-camelcase-vs-pascalcase")]
|
||||
public async Task FT_P_01_create_non_default_returns_201_with_camel_case_body()
|
||||
{
|
||||
// CARRY-FORWARD: results_report.md row 1.1 + AC-8.1 specified
|
||||
// PascalCase response bodies. The actual SUT relies on ASP.NET Core
|
||||
// default JsonSerializerOptions (camelCase) — no JsonNamingPolicy
|
||||
// override is configured in Program.cs. Per /autodev batch 3 we
|
||||
// pin the CODE shape (camelCase). Flip when the spec/code
|
||||
// divergence is closed.
|
||||
|
||||
// Arrange
|
||||
DbResetFixture.ResetDatabase(TestEnvironment.DbSideChannel);
|
||||
var token = await Tokens.MintDefaultAsync();
|
||||
var request = new
|
||||
{
|
||||
Type = 0,
|
||||
Model = "Bayraktar",
|
||||
Name = "BR-01",
|
||||
FuelType = 1,
|
||||
BatteryCapacity = 0,
|
||||
EngineConsumption = 5,
|
||||
EngineConsumptionIdle = 1,
|
||||
IsDefault = false
|
||||
};
|
||||
|
||||
// Act
|
||||
using var http = new HttpRequestMessage(HttpMethod.Post, "/vehicles")
|
||||
{
|
||||
Content = JsonContent.Create(request)
|
||||
};
|
||||
http.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Jwt);
|
||||
using var response = await Missions.SendAsync(http);
|
||||
|
||||
// Assert
|
||||
await HttpAssertions.AssertStatusAsync(response, HttpStatusCode.Created);
|
||||
|
||||
var raw = await response.Content.ReadAsStringAsync();
|
||||
using var doc = JsonDocument.Parse(raw);
|
||||
var root = doc.RootElement;
|
||||
Assert.True(root.TryGetProperty("id", out var idEl), $"body missing camelCase 'id': {raw}");
|
||||
Assert.True(root.TryGetProperty("name", out var nameEl));
|
||||
Assert.True(root.TryGetProperty("isDefault", out var defEl));
|
||||
Assert.False(root.TryGetProperty("Id", out _), "body unexpectedly PascalCase");
|
||||
|
||||
var id = idEl.GetGuid();
|
||||
Assert.Equal("BR-01", nameEl.GetString());
|
||||
Assert.False(defEl.GetBoolean());
|
||||
|
||||
var count = DbAssertions.ScalarCount(
|
||||
"SELECT COUNT(*) FROM vehicles WHERE id = @id",
|
||||
("id", id));
|
||||
Assert.Equal(1, count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Traces", "AC-1.2")]
|
||||
[Trait("max_ms", "5000")]
|
||||
public async Task FT_P_02_create_default_demotes_prior_default()
|
||||
{
|
||||
// Arrange
|
||||
DbResetFixture.ResetDatabase(TestEnvironment.DbSideChannel);
|
||||
Seeds.Apply(Seeds.OneDefaultVehicle.Sql);
|
||||
var priorDefaultId = Seeds.OneDefaultVehicle.Id;
|
||||
var token = await Tokens.MintDefaultAsync();
|
||||
var request = new
|
||||
{
|
||||
Type = 0,
|
||||
Model = "Bayraktar",
|
||||
Name = "BR-02-default",
|
||||
FuelType = 1,
|
||||
BatteryCapacity = 0,
|
||||
EngineConsumption = 5,
|
||||
EngineConsumptionIdle = 1,
|
||||
IsDefault = true
|
||||
};
|
||||
|
||||
// Act
|
||||
using var http = new HttpRequestMessage(HttpMethod.Post, "/vehicles")
|
||||
{
|
||||
Content = JsonContent.Create(request)
|
||||
};
|
||||
http.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Jwt);
|
||||
using var response = await Missions.SendAsync(http);
|
||||
|
||||
// Assert
|
||||
await HttpAssertions.AssertStatusAsync(response, HttpStatusCode.Created);
|
||||
|
||||
var newVehicle = await response.Content.ReadFromJsonAsync<VehicleDto>()
|
||||
?? throw new InvalidOperationException("response body deserialized to null");
|
||||
Assert.True(newVehicle.IsDefault, "newly-created vehicle must be default");
|
||||
|
||||
var totalDefaults = DbAssertions.ScalarCount(
|
||||
"SELECT COUNT(*) FROM vehicles WHERE is_default = TRUE");
|
||||
Assert.Equal(1, totalDefaults);
|
||||
|
||||
var priorIsDefault = DbAssertions.ScalarCount(
|
||||
"SELECT COUNT(*) FROM vehicles WHERE id = @id AND is_default = TRUE",
|
||||
("id", priorDefaultId));
|
||||
Assert.Equal(0, priorIsDefault);
|
||||
|
||||
var newIsDefault = DbAssertions.ScalarCount(
|
||||
"SELECT COUNT(*) FROM vehicles WHERE id = @id AND is_default = TRUE",
|
||||
("id", newVehicle.Id));
|
||||
Assert.Equal(1, newIsDefault);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Traces", "AC-1.4")]
|
||||
[Trait("max_ms", "5000")]
|
||||
[Trait("carry_forward", "setDefault-route-method-return")]
|
||||
public async Task FT_P_03_setDefault_promotes_existing_vehicle()
|
||||
{
|
||||
// CARRY-FORWARD: the canonical task spec + results_report.md row 1.4 say
|
||||
// "POST /vehicles/{id}/setDefault" returning "200 with body {Vehicle}",
|
||||
// but the actual code (Controllers/VehiclesController.cs:48) is
|
||||
// "[HttpPatch("{id:guid}/default")]" returning "204 NoContent" (no body).
|
||||
// Per /autodev batch 2 user choice, this test asserts the CODE shape.
|
||||
// When the spec/code divergence is closed, flip method+status here.
|
||||
|
||||
// Arrange
|
||||
DbResetFixture.ResetDatabase(TestEnvironment.DbSideChannel);
|
||||
Seeds.Apply(Seeds.OneDefaultVehicle.Sql);
|
||||
var priorDefaultId = Seeds.OneDefaultVehicle.Id;
|
||||
|
||||
var p2Id = Guid.NewGuid();
|
||||
Seeds.Apply($"""
|
||||
INSERT INTO vehicles
|
||||
(id, type, model, name, fuel_type, battery_capacity,
|
||||
engine_consumption, engine_consumption_idle, is_default)
|
||||
VALUES
|
||||
('{p2Id}', 0, 'Bayraktar', 'BR-promote', 1, 0, 5, 1, false);
|
||||
""");
|
||||
|
||||
var token = await Tokens.MintDefaultAsync();
|
||||
|
||||
// Act
|
||||
using var http = new HttpRequestMessage(HttpMethod.Patch, $"/vehicles/{p2Id}/default")
|
||||
{
|
||||
Content = JsonContent.Create(new { IsDefault = true })
|
||||
};
|
||||
http.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Jwt);
|
||||
using var response = await Missions.SendAsync(http);
|
||||
|
||||
// Assert
|
||||
await HttpAssertions.AssertStatusAsync(response, HttpStatusCode.NoContent);
|
||||
|
||||
var promoted = DbAssertions.ScalarCount(
|
||||
"SELECT COUNT(*) FROM vehicles WHERE id = @id AND is_default = TRUE",
|
||||
("id", p2Id));
|
||||
Assert.Equal(1, promoted);
|
||||
|
||||
var demoted = DbAssertions.ScalarCount(
|
||||
"SELECT COUNT(*) FROM vehicles WHERE id = @id AND is_default = TRUE",
|
||||
("id", priorDefaultId));
|
||||
Assert.Equal(0, demoted);
|
||||
|
||||
DbAssertions.AssertExactlyOneDefaultVehicle();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Traces", "AC-1.5")]
|
||||
[Trait("max_ms", "2000")]
|
||||
public async Task FT_P_04_list_is_unpaginated_array_ordered_by_name()
|
||||
{
|
||||
// Arrange
|
||||
DbResetFixture.ResetDatabase(TestEnvironment.DbSideChannel);
|
||||
Seeds.Apply(Seeds.Three_BR01_BR02_MQ9.Sql);
|
||||
var token = await Tokens.MintDefaultAsync();
|
||||
|
||||
// Act
|
||||
using var http = new HttpRequestMessage(HttpMethod.Get, "/vehicles");
|
||||
http.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Jwt);
|
||||
using var response = await Missions.SendAsync(http);
|
||||
|
||||
// Assert
|
||||
await HttpAssertions.AssertStatusAsync(response, HttpStatusCode.OK);
|
||||
|
||||
var raw = await response.Content.ReadAsStringAsync();
|
||||
using var doc = JsonDocument.Parse(raw);
|
||||
Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind);
|
||||
|
||||
var vehicles = JsonSerializer.Deserialize<List<VehicleDto>>(raw)
|
||||
?? throw new InvalidOperationException($"could not deserialize array: {raw}");
|
||||
Assert.Equal(3, vehicles.Count);
|
||||
Assert.Equal(new[] { "BR-01", "BR-02", "MQ-9" },
|
||||
vehicles.Select(v => v.Name).ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Traces", "AC-1.6")]
|
||||
[Trait("max_ms", "2000")]
|
||||
public async Task FT_P_05_filter_is_case_insensitive_for_both_casings()
|
||||
{
|
||||
// Arrange
|
||||
DbResetFixture.ResetDatabase(TestEnvironment.DbSideChannel);
|
||||
Seeds.Apply(Seeds.Three_BR01_BR02_MQ9.Sql);
|
||||
var token = await Tokens.MintDefaultAsync();
|
||||
|
||||
async Task<List<VehicleDto>> FetchAsync(string query)
|
||||
{
|
||||
using var http = new HttpRequestMessage(HttpMethod.Get, "/vehicles?" + query);
|
||||
http.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Jwt);
|
||||
using var resp = await Missions.SendAsync(http);
|
||||
await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.OK);
|
||||
return await resp.Content.ReadFromJsonAsync<List<VehicleDto>>() ?? throw new InvalidOperationException("null body for /vehicles filter");
|
||||
}
|
||||
|
||||
// Act
|
||||
var upper = await FetchAsync("name=BR&isDefault=true");
|
||||
var lower = await FetchAsync("name=br&isDefault=true");
|
||||
|
||||
// Assert
|
||||
Assert.Single(upper);
|
||||
Assert.Equal("BR-01", upper[0].Name);
|
||||
Assert.Single(lower);
|
||||
Assert.Equal("BR-01", lower[0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Traces", "AC-1.10")]
|
||||
[Trait("max_ms", "2000")]
|
||||
public async Task FT_P_06_delete_with_no_references_returns_204_and_row_is_gone()
|
||||
{
|
||||
// Arrange
|
||||
DbResetFixture.ResetDatabase(TestEnvironment.DbSideChannel);
|
||||
Seeds.Apply(Seeds.OneDefaultVehicle.Sql);
|
||||
var id = Seeds.OneDefaultVehicle.Id;
|
||||
var token = await Tokens.MintDefaultAsync();
|
||||
|
||||
// Act
|
||||
using var http = new HttpRequestMessage(HttpMethod.Delete, $"/vehicles/{id}");
|
||||
http.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Jwt);
|
||||
using var response = await Missions.SendAsync(http);
|
||||
|
||||
// Assert
|
||||
await HttpAssertions.AssertStatusAsync(response, HttpStatusCode.NoContent);
|
||||
var bodyLength = (await response.Content.ReadAsByteArrayAsync()).Length;
|
||||
Assert.Equal(0, bodyLength);
|
||||
|
||||
var remaining = DbAssertions.ScalarCount(
|
||||
"SELECT COUNT(*) FROM vehicles WHERE id = @id",
|
||||
("id", id));
|
||||
Assert.Equal(0, remaining);
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
using Xunit;
|
||||
|
||||
namespace Azaion.Missions.E2E.Tests.Vehicles;
|
||||
|
||||
/// <summary>
|
||||
/// Discovery-only smoke test for the Vehicles category. AC-3 of AZ-576
|
||||
/// requires every test folder to expose ≥ 1 test so the runner can confirm
|
||||
/// the test harness is wired correctly. The real Vehicles scenarios
|
||||
/// (FT-P-01..06, FT-N-01..03) land in AZ-577.
|
||||
/// </summary>
|
||||
public sealed class Sanity
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Category", "Blackbox")]
|
||||
[Trait("Traces", "AC-3")]
|
||||
public void Discovery_smoke_test_runs()
|
||||
{
|
||||
// Arrange
|
||||
const int sentinel = 1;
|
||||
// Act
|
||||
var result = sentinel + 0;
|
||||
// Assert
|
||||
Assert.Equal(1, result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using Azaion.Missions.E2E.Fixtures;
|
||||
using Azaion.Missions.E2E.Helpers;
|
||||
using Xunit;
|
||||
|
||||
namespace Azaion.Missions.E2E.Tests.Waypoints;
|
||||
|
||||
/// <summary>
|
||||
/// FT-P-18 — waypoint cascade delete is scoped to one waypoint; the sibling
|
||||
/// waypoint's chain remains intact. Owns its own xUnit collection because
|
||||
/// the F4 fixture is destructive.
|
||||
/// Traces: AC-4.5.
|
||||
/// </summary>
|
||||
[Collection("CascadeF4")]
|
||||
[Trait("Category", "Blackbox")]
|
||||
[Trait("db_access", "seed-or-assert-only")]
|
||||
public sealed class CascadeF4Tests : TestBase, IClassFixture<CascadeF4Fixture>
|
||||
{
|
||||
public CascadeF4Tests(CascadeF4Fixture _) { /* fixture seeds the DB. */ }
|
||||
|
||||
[Fact]
|
||||
[Trait("Traces", "AC-4.5")]
|
||||
[Trait("max_ms", "10000")]
|
||||
public async Task FT_P_18_waypoint_cascade_scoped_to_one_waypoint_sibling_intact()
|
||||
{
|
||||
// Arrange — refresh the F4 fixture into a deterministic state.
|
||||
DbResetFixture.ResetDatabase(TestEnvironment.DbSideChannel);
|
||||
StubSchema.EnsureCreated();
|
||||
Seeds.Apply(FixtureSql.Load("fixture_cascade_F4"));
|
||||
|
||||
// Pre-state safety check (cascade_F4_walk.json
|
||||
// expected_per_table_pre_state_for_safety_check).
|
||||
Assert.Equal(2, DbAssertions.TableRowCount("waypoints"));
|
||||
Assert.Equal(2, DbAssertions.TableRowCount("media"));
|
||||
Assert.Equal(2, DbAssertions.TableRowCount("annotations"));
|
||||
Assert.Equal(2, DbAssertions.TableRowCount("detection"));
|
||||
|
||||
var token = await Tokens.MintDefaultAsync();
|
||||
|
||||
// Act
|
||||
using var http = new HttpRequestMessage(
|
||||
HttpMethod.Delete,
|
||||
$"/missions/{CascadeF4Fixture.MissionId}/waypoints/{CascadeF4Fixture.TargetWaypointId}");
|
||||
http.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Jwt);
|
||||
using var response = await Missions.SendAsync(http);
|
||||
|
||||
// Assert — target chain gone.
|
||||
await HttpAssertions.AssertStatusAsync(response, HttpStatusCode.NoContent);
|
||||
Assert.Equal(0L, DbAssertions.ScalarCount(
|
||||
"SELECT COUNT(*) FROM waypoints WHERE id = @id",
|
||||
("id", CascadeF4Fixture.TargetWaypointId)));
|
||||
Assert.Equal(0L, DbAssertions.ScalarCount(
|
||||
"SELECT COUNT(*) FROM media WHERE id = @id",
|
||||
("id", CascadeF4Fixture.TargetMediaId)));
|
||||
Assert.Equal(0L, DbAssertions.ScalarCount(
|
||||
"SELECT COUNT(*) FROM annotations WHERE id = @id",
|
||||
("id", CascadeF4Fixture.TargetAnnotationId)));
|
||||
Assert.Equal(0L, DbAssertions.ScalarCount(
|
||||
"SELECT COUNT(*) FROM detection WHERE annotation_id = @id",
|
||||
("id", CascadeF4Fixture.TargetAnnotationId)));
|
||||
|
||||
// Sibling chain intact.
|
||||
Assert.Equal(1L, DbAssertions.ScalarCount(
|
||||
"SELECT COUNT(*) FROM waypoints WHERE id = @id",
|
||||
("id", CascadeF4Fixture.SiblingWaypointId)));
|
||||
Assert.Equal(1L, DbAssertions.ScalarCount(
|
||||
"SELECT COUNT(*) FROM media WHERE id = @id",
|
||||
("id", CascadeF4Fixture.SiblingMediaId)));
|
||||
Assert.Equal(1L, DbAssertions.ScalarCount(
|
||||
"SELECT COUNT(*) FROM annotations WHERE id = @id",
|
||||
("id", CascadeF4Fixture.SiblingAnnotationId)));
|
||||
Assert.Equal(1L, DbAssertions.ScalarCount(
|
||||
"SELECT COUNT(*) FROM detection WHERE annotation_id = @id",
|
||||
("id", CascadeF4Fixture.SiblingAnnotationId)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using Azaion.Missions.E2E.Fixtures;
|
||||
using Azaion.Missions.E2E.Helpers;
|
||||
using Xunit;
|
||||
|
||||
namespace Azaion.Missions.E2E.Tests.Waypoints;
|
||||
|
||||
/// <summary>
|
||||
/// FT-N-07 — waypoint operation against a missing mission must surface as
|
||||
/// a 404 with the standard envelope (results_report.md row 4.1 / AC-4.2).
|
||||
/// </summary>
|
||||
[Collection("Waypoints")]
|
||||
[Trait("Category", "Blackbox")]
|
||||
[Trait("db_access", "seed-or-assert-only")]
|
||||
public sealed class NegativeTests : TestBase, IClassFixture<DbResetFixture>
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Traces", "AC-4.2")]
|
||||
[Trait("max_ms", "2000")]
|
||||
[Trait("carry_forward", "AC-4.2")]
|
||||
public async Task FT_N_07_waypoint_list_against_missing_mission_returns_empty_array_today()
|
||||
{
|
||||
// CARRY-FORWARD: spec says 404 with problem envelope (AZ-580 AC-7
|
||||
// and results_report.md row 4.1). Today the SUT
|
||||
// (WaypointService.GetWaypoints) does NOT validate parent existence
|
||||
// — it returns an empty list which the controller wraps as 200 []. Per
|
||||
// /autodev batch 2 user choice, this test asserts the CODE shape.
|
||||
// Flip to 404+envelope expectation when the divergence is closed.
|
||||
|
||||
// Arrange
|
||||
DbResetFixture.ResetDatabase(TestEnvironment.DbSideChannel);
|
||||
var token = await Tokens.MintDefaultAsync();
|
||||
var randomMissionId = Guid.NewGuid();
|
||||
|
||||
// Act
|
||||
using var http = new HttpRequestMessage(
|
||||
HttpMethod.Get, $"/missions/{randomMissionId}/waypoints");
|
||||
http.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Jwt);
|
||||
using var response = await Missions.SendAsync(http);
|
||||
|
||||
// Assert
|
||||
await HttpAssertions.AssertStatusAsync(response, HttpStatusCode.OK);
|
||||
var raw = await response.Content.ReadAsStringAsync();
|
||||
Assert.Equal("[]", raw.Trim());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Azaion.Missions.E2E.Fixtures;
|
||||
using Azaion.Missions.E2E.Helpers;
|
||||
using Xunit;
|
||||
|
||||
namespace Azaion.Missions.E2E.Tests.Waypoints;
|
||||
|
||||
/// <summary>
|
||||
/// FT-P-13..15 — waypoint happy-path scenarios. FT-P-18 (cascade delete) is
|
||||
/// in <see cref="CascadeF4Tests"/>, FT-P-16/17 (health) are in
|
||||
/// <c>Tests/Health/HealthTests.cs</c>.
|
||||
/// Traces: AC-4.3 / AC-4 (data_parameters § 2.3) / AC-4.4.
|
||||
/// </summary>
|
||||
[Collection("Waypoints")]
|
||||
[Trait("Category", "Blackbox")]
|
||||
[Trait("db_access", "seed-or-assert-only")]
|
||||
public sealed class PositiveTests : TestBase, IClassFixture<DbResetFixture>
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Traces", "AC-4.3")]
|
||||
[Trait("max_ms", "2000")]
|
||||
public async Task FT_P_13_waypoint_list_is_ordered_by_order_num_asc()
|
||||
{
|
||||
// Arrange
|
||||
DbResetFixture.ResetDatabase(TestEnvironment.DbSideChannel);
|
||||
Seeds.Apply(Seeds.FiveWaypointsUnordered.Sql);
|
||||
var token = await Tokens.MintDefaultAsync();
|
||||
|
||||
// Act
|
||||
using var http = new HttpRequestMessage(
|
||||
HttpMethod.Get, $"/missions/{Seeds.FiveWaypointsUnordered.MissionId}/waypoints");
|
||||
http.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Jwt);
|
||||
using var response = await Missions.SendAsync(http);
|
||||
|
||||
// Assert
|
||||
await HttpAssertions.AssertStatusAsync(response, HttpStatusCode.OK);
|
||||
var raw = await response.Content.ReadAsStringAsync();
|
||||
using var doc = JsonDocument.Parse(raw);
|
||||
Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind);
|
||||
|
||||
var waypoints = JsonSerializer.Deserialize<List<WaypointDto>>(raw)
|
||||
?? throw new InvalidOperationException($"could not deserialize array: {raw}");
|
||||
Assert.Equal(5, waypoints.Count);
|
||||
Assert.Equal(new[] { 1, 2, 3, 4, 5 },
|
||||
waypoints.Select(w => w.OrderNum).ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Traces", "AC-4")]
|
||||
[Trait("max_ms", "2000")]
|
||||
[Trait("carry_forward", "waypoint-response-flat-vs-nested-geo")]
|
||||
public async Task FT_P_14_create_waypoint_echoes_lat_lon_and_does_not_auto_convert_to_mgrs()
|
||||
{
|
||||
// CARRY-FORWARD: the canonical task spec (AZ-579 AC-2) says the
|
||||
// response body has nested "GeoPoint:{Lat,Lon,Mgrs}". The actual SUT
|
||||
// (Database/Entities/Waypoint.cs + Controllers/MissionsController.cs)
|
||||
// returns the LinqToDB entity directly, which has flat Lat/Lon/Mgrs
|
||||
// columns — there is no GeoPoint object in the response. Per /autodev
|
||||
// batch 2 user choice we assert the CODE shape (flat) here. Flip when
|
||||
// the spec/code divergence is closed.
|
||||
|
||||
// Arrange
|
||||
DbResetFixture.ResetDatabase(TestEnvironment.DbSideChannel);
|
||||
Seeds.Apply(Seeds.FiveWaypointsUnordered.Sql);
|
||||
var missionId = Seeds.FiveWaypointsUnordered.MissionId;
|
||||
var token = await Tokens.MintDefaultAsync();
|
||||
|
||||
// Act
|
||||
using var http = new HttpRequestMessage(HttpMethod.Post, $"/missions/{missionId}/waypoints")
|
||||
{
|
||||
Content = JsonContent.Create(new
|
||||
{
|
||||
GeoPoint = new { Lat = 50.45m, Lon = 30.52m, Mgrs = (string?)null },
|
||||
WaypointSource = 0,
|
||||
WaypointObjective = 0,
|
||||
OrderNum = 99,
|
||||
Height = 120m
|
||||
})
|
||||
};
|
||||
http.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Jwt);
|
||||
using var response = await Missions.SendAsync(http);
|
||||
|
||||
// Assert
|
||||
await HttpAssertions.AssertStatusAsync(response, HttpStatusCode.Created);
|
||||
var waypoint = await response.Content.ReadFromJsonAsync<WaypointDto>() ?? throw new InvalidOperationException("waypoint body deserialized to null");
|
||||
Assert.Equal(50.45m, waypoint.Lat);
|
||||
Assert.Equal(30.52m, waypoint.Lon);
|
||||
Assert.Null(waypoint.Mgrs);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Traces", "AC-4.4")]
|
||||
[Trait("max_ms", "2000")]
|
||||
[Trait("carry_forward", "waypoint-response-flat-vs-nested-geo")]
|
||||
public async Task FT_P_15_waypoint_update_is_full_overwrite_height_zero_geofields_cleared()
|
||||
{
|
||||
// CARRY-FORWARD: same flat-vs-nested divergence as FT-P-14. The "full
|
||||
// overwrite" semantic IS pinned: send Height:0 and assert the prior
|
||||
// Height:120 is replaced; send geo nullable fields and assert they
|
||||
// become null.
|
||||
|
||||
// Arrange
|
||||
DbResetFixture.ResetDatabase(TestEnvironment.DbSideChannel);
|
||||
Seeds.Apply(Seeds.FiveWaypointsUnordered.Sql);
|
||||
var missionId = Seeds.FiveWaypointsUnordered.MissionId;
|
||||
|
||||
var targetWaypoint = await GetSeededWaypointAsync(missionId);
|
||||
// Sanity check the seed shape — the original Height for a seed row
|
||||
// is 100/110/120/130/140; pick whichever waypoint has Height==120.
|
||||
var token = await Tokens.MintDefaultAsync();
|
||||
|
||||
// Act
|
||||
using var http = new HttpRequestMessage(
|
||||
HttpMethod.Put,
|
||||
$"/missions/{missionId}/waypoints/{targetWaypoint.Id}")
|
||||
{
|
||||
Content = JsonContent.Create(new
|
||||
{
|
||||
GeoPoint = (object?)null,
|
||||
WaypointSource = 1,
|
||||
WaypointObjective = 1,
|
||||
OrderNum = targetWaypoint.OrderNum + 100,
|
||||
Height = 0m
|
||||
})
|
||||
};
|
||||
http.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Jwt);
|
||||
using var response = await Missions.SendAsync(http);
|
||||
|
||||
// Assert
|
||||
await HttpAssertions.AssertStatusAsync(response, HttpStatusCode.OK);
|
||||
var updated = await response.Content.ReadFromJsonAsync<WaypointDto>() ?? throw new InvalidOperationException("waypoint body deserialized to null");
|
||||
Assert.Equal(0m, updated.Height);
|
||||
Assert.Equal(targetWaypoint.OrderNum + 100, updated.OrderNum);
|
||||
Assert.Null(updated.Lat);
|
||||
Assert.Null(updated.Lon);
|
||||
Assert.Null(updated.Mgrs);
|
||||
Assert.Equal(1, updated.WaypointSource);
|
||||
Assert.Equal(1, updated.WaypointObjective);
|
||||
}
|
||||
|
||||
private async Task<WaypointDto> GetSeededWaypointAsync(Guid missionId)
|
||||
{
|
||||
var token = await Tokens.MintDefaultAsync();
|
||||
using var http = new HttpRequestMessage(HttpMethod.Get, $"/missions/{missionId}/waypoints");
|
||||
http.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Jwt);
|
||||
using var resp = await Missions.SendAsync(http);
|
||||
resp.EnsureSuccessStatusCode();
|
||||
var list = await resp.Content.ReadFromJsonAsync<List<WaypointDto>>() ?? throw new InvalidOperationException("waypoints list deserialized to null");
|
||||
return list.First(w => w.OrderNum == 1);
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
using Xunit;
|
||||
|
||||
namespace Azaion.Missions.E2E.Tests.Waypoints;
|
||||
|
||||
/// <summary>
|
||||
/// Discovery-only smoke test for the Waypoints category. Real Waypoints
|
||||
/// scenarios (FT-P-13..15, FT-P-18, FT-N-07) land in AZ-579.
|
||||
/// </summary>
|
||||
public sealed class Sanity
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Category", "Blackbox")]
|
||||
[Trait("Traces", "AC-3")]
|
||||
public void Discovery_smoke_test_runs()
|
||||
{
|
||||
// Arrange
|
||||
const int sentinel = 1;
|
||||
// Act
|
||||
var result = sentinel + 0;
|
||||
// Assert
|
||||
Assert.Equal(1, result);
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,7 @@ public sealed record SignRequest(
|
||||
[property: JsonPropertyName("sub")] string? Sub = null,
|
||||
[property: JsonPropertyName("exp_offset_seconds")] int? ExpOffsetSeconds = null,
|
||||
[property: JsonPropertyName("permissions")] string? Permissions = null,
|
||||
[property: JsonPropertyName("permissions_array")] string[]? PermissionsArray = null,
|
||||
[property: JsonPropertyName("alg_override")] string? AlgOverride = null,
|
||||
[property: JsonPropertyName("kid_override")] string? KidOverride = null);
|
||||
|
||||
|
||||
@@ -11,12 +11,28 @@
|
||||
## but do NOT mask test failures).
|
||||
set -eu
|
||||
|
||||
# Register any CA certificates mounted into /usr/local/share/ca-certificates/
|
||||
# with the system trust store. The compose file mounts jwks-mock's self-signed
|
||||
# CA so the test client (HttpClient inside dotnet test) can validate the mock's
|
||||
# TLS chain when calling https://jwks-mock:8443/sign or /rotate-key.
|
||||
# Mirrors docker-entrypoint.sh in the missions service image.
|
||||
if command -v update-ca-certificates >/dev/null 2>&1; then
|
||||
update-ca-certificates --fresh >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
mkdir -p "$RESULTS_DIR"
|
||||
|
||||
set +e
|
||||
## Performance scenarios (Category=Perf) are excluded from the default gate
|
||||
## per AZ-586. They are invoked from scripts/run-performance-tests.sh which
|
||||
## passes its own --filter Category=Perf. ResLim tests (Category=ResLim) stay
|
||||
## in the default gate because their docker-CLI gate causes them to skip
|
||||
## with an explicit reason when COMPOSE_RESTART_ENABLED is not set.
|
||||
TEST_FILTER="${TEST_FILTER:-Category!=Perf}"
|
||||
dotnet test /src/Azaion.Missions.E2E.Tests.csproj \
|
||||
--no-build \
|
||||
--configuration Release \
|
||||
--filter "$TEST_FILTER" \
|
||||
--logger "trx;LogFileName=results.trx" \
|
||||
--logger "console;verbosity=normal" \
|
||||
--results-directory "$RESULTS_DIR"
|
||||
|
||||
@@ -8,5 +8,10 @@ RUN arch=$([ "$TARGETARCH" = "amd64" ] && echo "x64" || echo "$TARGETARCH") && \
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0
|
||||
WORKDIR /app
|
||||
COPY --from=build /app .
|
||||
# wget is required by docker-compose.test.yml's healthcheck. The aspnet base
|
||||
# image does not ship it; install with apt before stripping the cache.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends wget \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
EXPOSE 8443
|
||||
ENTRYPOINT ["dotnet", "Azaion.Missions.JwksMock.dll"]
|
||||
|
||||
@@ -34,6 +34,7 @@ public static class SignEndpoint
|
||||
Audience: body.Aud,
|
||||
ExpOffsetSeconds: body.ExpOffsetSeconds,
|
||||
Permissions: body.Permissions,
|
||||
PermissionsArray: body.PermissionsArray,
|
||||
Subject: body.Sub,
|
||||
AlgOverride: body.AlgOverride,
|
||||
KidOverride: body.KidOverride));
|
||||
@@ -46,12 +47,18 @@ public static class SignEndpoint
|
||||
}
|
||||
}
|
||||
|
||||
// permissions vs permissions_array: NFT-SEC-06 multi-value (AC-7) requires the
|
||||
// mock to emit a JSON-array `permissions` claim. Splitting the field on the
|
||||
// wire keeps SignBody compatible with System.Text.Json source generation
|
||||
// (a single JsonElement field would defeat the AOT-friendly SignBodyContext).
|
||||
// At most one of the two fields may be set per request.
|
||||
public sealed record SignBody(
|
||||
[property: JsonPropertyName("iss")] string? Iss = null,
|
||||
[property: JsonPropertyName("aud")] string? Aud = null,
|
||||
[property: JsonPropertyName("sub")] string? Sub = null,
|
||||
[property: JsonPropertyName("exp_offset_seconds")] int? ExpOffsetSeconds = null,
|
||||
[property: JsonPropertyName("permissions")] string? Permissions = null,
|
||||
[property: JsonPropertyName("permissions_array")] string[]? PermissionsArray = null,
|
||||
[property: JsonPropertyName("alg_override")] string? AlgOverride = null,
|
||||
[property: JsonPropertyName("kid_override")] string? KidOverride = null);
|
||||
|
||||
|
||||
@@ -31,6 +31,25 @@ public sealed class TokenSigner
|
||||
var kid = request.KidOverride ?? active.Kid;
|
||||
var alg = request.AlgOverride ?? "ES256";
|
||||
|
||||
if (request.Permissions is not null && request.PermissionsArray is not null)
|
||||
throw new ArgumentException(
|
||||
"permissions and permissions_array are mutually exclusive — set at most one.",
|
||||
nameof(request));
|
||||
|
||||
// NFT-SEC-11 AC-5.4: the mock refuses kid_override values that don't
|
||||
// correspond to a currently-published kid (active or in-grace retired).
|
||||
// Without this guard, a tester could mint a token with any kid string
|
||||
// and the SUT would simply 401 on JWKS lookup — defeating the
|
||||
// "post-grace mock refuses old kid" assertion.
|
||||
if (request.KidOverride is not null)
|
||||
{
|
||||
var known = _keys.PublishedKeys().Select(k => k.Kid).ToHashSet(StringComparer.Ordinal);
|
||||
if (!known.Contains(request.KidOverride))
|
||||
throw new ArgumentException(
|
||||
$"kid_override '{request.KidOverride}' is not a currently-published kid.",
|
||||
nameof(request));
|
||||
}
|
||||
|
||||
var nowUnix = _clock.GetUtcNow().ToUnixTimeSeconds();
|
||||
var expUnix = nowUnix + (request.ExpOffsetSeconds ?? 3600);
|
||||
|
||||
@@ -50,6 +69,13 @@ public sealed class TokenSigner
|
||||
};
|
||||
if (request.Permissions is not null)
|
||||
payload["permissions"] = request.Permissions;
|
||||
if (request.PermissionsArray is not null)
|
||||
{
|
||||
var arr = new JsonArray();
|
||||
foreach (var p in request.PermissionsArray)
|
||||
arr.Add(p);
|
||||
payload["permissions"] = arr;
|
||||
}
|
||||
if (request.Subject is not null)
|
||||
payload["sub"] = request.Subject;
|
||||
|
||||
@@ -95,6 +121,7 @@ public sealed record SignRequest(
|
||||
string? Audience,
|
||||
int? ExpOffsetSeconds,
|
||||
string? Permissions,
|
||||
string[]? PermissionsArray,
|
||||
string? Subject,
|
||||
string? AlgOverride,
|
||||
string? KidOverride);
|
||||
|
||||
Reference in New Issue
Block a user