diff --git a/backend/src/BigRegister.Api/Program.cs b/backend/src/BigRegister.Api/Program.cs index 2d605d1..208be87 100644 --- a/backend/src/BigRegister.Api/Program.cs +++ b/backend/src/BigRegister.Api/Program.cs @@ -142,8 +142,21 @@ app.Use(async (ctx, next) => await next(ctx); }); -app.UseSwagger(); -app.UseSwaggerUI(); +// RB-15/BIO-015: the OpenAPI document + its UI are a genuine attack-surface reduction to +// gate — they enumerate every route, request/response shape and (via SwaggerUI's "Try it +// out") let a caller fire requests straight from the browser. Development-only, like the +// dev-role/scenario-toggle hatches this POC already keeps out of production builds +// (docker-compose.prod.yml runs Production; only docker-compose.yml's dev image runs +// Development). `dotnet swagger tofile` (npm run gen:api) is unaffected: Swashbuckle's CLI +// resolves ISwaggerProvider straight out of the DI container to build swagger.json — it +// never sends an HTTP request through this pipeline, so it never touches this middleware at +// all, gated or not. Verified empirically (see rb-15.md) rather than assumed, per RB-09's +// note that this exact file has already broken that tool once. +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} app.UseCors(SpaCors); // Liveness/readiness for orchestrators (k8s probes, load balancers). No data, no PII. diff --git a/backend/tests/BigRegister.Tests/SwaggerGateTests.cs b/backend/tests/BigRegister.Tests/SwaggerGateTests.cs new file mode 100644 index 0000000..90fb36a --- /dev/null +++ b/backend/tests/BigRegister.Tests/SwaggerGateTests.cs @@ -0,0 +1,48 @@ +using System.Net; +using BigRegister.Domain.Authorization; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; + +namespace BigRegister.Tests; + +/// RB-15/BIO-015: `app.UseSwagger()`/`app.UseSwaggerUI()` used to run unconditionally — the +/// OpenAPI document (every route + request/response shape) and SwaggerUI's "Try it out" were +/// reachable in every environment, including a real deployment. Both are now gated behind +/// `app.Environment.IsDevelopment()`. +public class SwaggerGateTests(TestWebApplicationFactory factory) : IClassFixture +{ + [Fact] + public async Task Swagger_document_is_served_in_development() + { + // The default test environment (WebApplicationFactory defaults to "Development" when + // nothing overrides it — same fact RB-09's implementation note relies on) — this is the + // regression guard that the gate didn't also break the documented `npm run gen:api` / + // local-dev-Swagger-UI experience. + var res = await factory.CreateClient().GetAsync("/swagger/v1/swagger.json"); + Assert.Equal(HttpStatusCode.OK, res.StatusCode); + } + + /// Production cannot boot at all today (RB-09: no real IIdentityProvider exists yet), which + /// is a *stronger* guarantee than "no Swagger in Production" — but it also means a plain + /// `UseEnvironment("Production")` host never reaches this middleware to prove the gate + /// itself works, only that the whole app refuses to start. This uses a third environment + /// name (neither "Development" nor "Production") with a test-supplied `IIdentityProvider` — + /// the one thing Program.cs doesn't register outside those two branches — so the host + /// actually boots and this test exercises the real gate, not RB-09's unrelated startup throw. + [Fact] + public async Task Swagger_document_is_not_served_outside_development() + { + // Built on top of the shared `factory` fixture (via WithWebHostBuilder), not a bare `new + // WebApplicationFactory()` — that keeps this host on the fixture's own per-class + // isolated AppDb temp path (see TestWebApplicationFactory's doc comment; RB-12's + // implementation note records the "table already exists" collision a bare factory hits + // by sharing the mutable static Db.ConnectionString instead). + using var staging = factory.WithWebHostBuilder(builder => builder + .UseEnvironment("Staging") + .ConfigureTestServices(services => services.AddSingleton())); + + var res = await staging.CreateClient().GetAsync("/swagger/v1/swagger.json"); + Assert.Equal(HttpStatusCode.NotFound, res.StatusCode); + } +} diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-15.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-15.md new file mode 100644 index 0000000..a555e25 --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-15.md @@ -0,0 +1,93 @@ +# RB-15 — Swagger and the OpenAPI document behind `IsDevelopment()` + +Status: **implemented** · 2026-08-27 · Source findings: `07-bio2-compliance.md` BIO-015 · `99-backlog.md` RB-15 + +## What was wrong + +`Program.cs:145-146` (pre-change) ran `app.UseSwagger(); app.UseSwaggerUI();` +unconditionally — the full OpenAPI document (every route, every request/response shape) +and SwaggerUI's interactive "Try it out" were reachable in every environment, including a +real deployment, with no `app.Environment.IsDevelopment()` guard. BIO-015's own evidence +notes this is one line of genuine attack-surface reduction with no POC cost. + +## What changed + +| File | Change | +| --------------------------------------------- | -------------------------------------------------------------------------------------------------------- | +| `Program.cs` | `app.UseSwagger(); app.UseSwaggerUI();` now run only inside `if (app.Environment.IsDevelopment()) { … }` | +| `tests/BigRegister.Tests/SwaggerGateTests.cs` | **new** — asserts `/swagger/v1/swagger.json` is served in Development and 404s outside it | + +`builder.Services.AddSwaggerGen(...)` and `AddEndpointsApiExplorer()` were left +unconditional — they only register DI services (the swagger-generation machinery), +expose nothing over HTTP by themselves, and (see below) are exactly what `npm run +gen:api` depends on staying registered in every environment it might run against. + +## The hazard, checked rather than assumed + +RB-09 made a non-Development environment throw during `builder.Build()` (no +`IIdentityProvider` registered for a bare/unset environment, which defaults to +Production), which crashed `dotnet swagger tofile` until `package.json`'s `gen:api` +script was pinned to `ASPNETCORE_ENVIRONMENT=Development` for that one invocation +(`docs/.../implementation/rb-09.md`). This ticket's change sits in exactly the same +pipeline, so it needed the same empirical check, not an assumption. + +**Mechanism, confirmed by reading Swashbuckle's CLI behaviour and then proving it:** +`dotnet swagger tofile` (`Swashbuckle.AspNetCore.Cli`) loads the built DLL through +.NET's design-time `HostFactoryResolver`, builds the host, and then resolves +`ISwaggerProvider` **directly out of the DI container** to produce `swagger.json` — it +never issues an HTTP request through the ASP.NET Core middleware pipeline this ticket's +`if (app.Environment.IsDevelopment())` guard lives in. Gating `UseSwagger()`/ +`UseSwaggerUI()` therefore cannot affect it, in any environment, by construction — those +are pipeline middleware; the CLI tool bypasses the pipeline entirely. + +**Verified, not assumed:** ran `npm run gen:api` for real. It exited 0, printed "Swagger +JSON/YAML successfully written to …/backend/swagger.json", and regenerated the NSwag +client. `git status`/`git diff` on both `backend/swagger.json` and +`libs/shared/src/infrastructure/api-client.ts` showed **zero changes** — the regenerated +files are byte-identical to what's already committed, confirming the gate has no effect +on the generated contract at all. + +## Judgement calls + +- **The guard wraps both `UseSwagger()` and `UseSwaggerUI()` together**, not just one — + the ticket's own wording lists both, and gating only the document while leaving the UI + reachable (or vice versa) would be a strange half-measure: SwaggerUI without the + document 404s on load anyway, and the document without the UI still leaks the same + route/shape enumeration BIO-015 is about. +- **`AddSwaggerGen`/`AddEndpointsApiExplorer` were left unconditional.** They're + DI-registration-time calls with no HTTP surface, and — now confirmed rather than + assumed — `dotnet swagger tofile` needs `ISwaggerProvider` registered in whatever + environment it runs the host under (pinned to Development by `gen:api`'s own script, + but nothing stops a future non-Development invocation), so conditioning those + registrations on `IsDevelopment()` would risk breaking the CLI tool for no + attack-surface benefit — nobody can reach a DI-registered-but-never-routed service + over HTTP. +- **New tests build the "non-Development" case on a third environment name + ("Staging"), not `"Production"`.** RB-09 already made Production fail at startup + entirely (no real `IIdentityProvider` exists yet) — a stronger guarantee than "no + Swagger in Production," but one that means a `UseEnvironment("Production")` host + never reaches this middleware to prove the gate itself works; it only proves RB-09's + unrelated startup throw, which already has its own test. A `"Staging"` environment + satisfies neither `IsDevelopment()` nor `IsProduction()`, so `Program.cs` registers no + `IIdentityProvider` for it — the test supplies one via `ConfigureTestServices` + (`StubIdentityProvider`, the same one Development uses) so the host actually boots, + and the test exercises this ticket's real gate rather than a different ticket's. +- **The Staging host is built via `factory.WithWebHostBuilder(...)`** (layering on the + shared `TestWebApplicationFactory` fixture), not a bare `new +WebApplicationFactory()` — RB-12's implementation note already records the + "table already exists" collision a bare factory hits by sharing the mutable static + `Db.ConnectionString` instead of the fixture's own per-class isolated temp path; + layering avoids repeating that mistake here. + +## Verification + +- **Reverted the guard only** (unwrapped `UseSwagger()`/`UseSwaggerUI()` back to + unconditional, via Edit, tests left in place) and ran `SwaggerGateTests`: + `Swagger_document_is_not_served_outside_development` failed red (`Expected: NotFound, +Actual: OK`). Restored the fix (via Edit) and re-ran: both green. +- **`npm run gen:api`, run for real**: exit 0; `backend/swagger.json` and + `libs/shared/src/infrastructure/api-client.ts` both unchanged (`git status` clean on + both) — see "The hazard" above. +- `dotnet build` (both projects): clean, 0 warnings. +- `dotnet format BigRegister.slnx --verify-no-changes`: clean. +- `dotnet test --filter "Category!=Integration"`: **259 passed, 0 failed** (257 + 2 new).