Files
atomic-design-poc/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-15.md
T
ehoandClaude Opus 5 a93218e8ac fix(backend): gate Swagger + the OpenAPI doc behind IsDevelopment (RB-15)
BIO-015: app.UseSwagger()/app.UseSwaggerUI() ran unconditionally, so
the full OpenAPI document (every route + request/response shape) and
SwaggerUI's interactive "Try it out" were reachable in every
environment, including a real deployment.

Both now run only inside `if (app.Environment.IsDevelopment())`.
AddSwaggerGen/AddEndpointsApiExplorer stay unconditional — DI
registration only, no HTTP surface by itself.

RB-09 already made a non-Development environment throw at startup,
which broke `npm run gen:api` until that script pinned
ASPNETCORE_ENVIRONMENT=Development for its one CLI invocation. This
change sits in the same pipeline, so it was verified rather than
assumed: `dotnet swagger tofile` resolves ISwaggerProvider straight
out of DI and never sends an HTTP request through this middleware, so
gating it can't affect that tool by construction. Ran the real
`npm run gen:api` to confirm — exit 0, regenerated files byte-identical
to what's committed.

New tests exercise the gate on a third ("Staging") environment name,
not Production — Production already can't boot at all post-RB-09, so
a Production-environment test would only re-prove that unrelated
startup throw, not this gate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 16:40:23 +02:00

6.1 KiB

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<Program>() — 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).