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>
This commit is contained in:
eho
2026-08-27 16:40:23 +02:00
co-authored by Claude Opus 5
parent ee0d449510
commit a93218e8ac
3 changed files with 156 additions and 2 deletions
+15 -2
View File
@@ -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.
@@ -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<TestWebApplicationFactory>
{
[Fact]
public async Task Swagger_document_is_served_in_development()
{
// The default test environment (WebApplicationFactory<T> 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<Program>()` — 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<IIdentityProvider, StubIdentityProvider>()));
var res = await staging.CreateClient().GetAsync("/swagger/v1/swagger.json");
Assert.Equal(HttpStatusCode.NotFound, res.StatusCode);
}
}