Files
atomic-design-poc/backend/tests/BigRegister.Tests/StubIdentityProviderTests.cs
T
ehoandClaude Opus 5 8b8b522052 fix(auth): make no-identity representable; stub dev-only (RB-09)
IIdentityProvider.Resolve returned a non-nullable CallerIdentity, so
the interface could not express "no identity" - StubIdentityProvider
was forced to invent one for any request carrying no credential at
all. Consequence: a production behandelportal build sends no
X-Medewerker header (medewerkerInterceptor is dev-only), so it used
to authenticate as the seeded citizen, role drafter - failing closed
on backoffice capabilities but open on every citizen-scoped endpoint,
including CanRevealBigNummer.

Resolve now returns CallerIdentity?. StubIdentityProvider keeps a
non-nullable return type (a valid narrower override) since it never
itself has "no identity" to report - it is registered only under
IsDevelopment() now. Production registers nothing and throws an
InvalidOperationException immediately during startup instead: there
is no real DigiD/employee-SSO provider in this POC yet, so a
misconfigured Production deploy must fail before serving a single
request, not resolve one per request. The identity-resolution
middleware turns a null resolution into a 401 rather than passing it
downstream.

Added StubIdentityProviderTests.Never_returns_null_even_with_no_headers_at_all
and ProductionIdentityProviderTests, which builds its own
WebApplicationFactory<Program> with UseEnvironment("Production") and
asserts startup throws. Verified both new tests fail red against the
pre-fix code.

RB-01's residual (GET /uploads/{id}/content reached via plain browser
navigation, no identity header) is confirmed unchanged in Development
and its Production consequence is written up in
implementation/rb-09.md for whoever lands the real identity provider -
no signed-URL/cookie scheme was designed here, per scope.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 14:06:49 +02:00

127 lines
4.9 KiB
C#

using BigRegister.Api.Data;
using BigRegister.Domain.Authorization;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Testing;
namespace BigRegister.Tests;
/// WP-53 (extended WP-62): the dev stub identity provider — role from X-Role (unchanged
/// behaviour, applies to either actor kind), subject BSN from X-Subject defaulting to the
/// single seeded citizen so every existing request (none of which send X-Subject) resolves
/// exactly as before this WP. X-Medewerker (+ X-Rollen) selects the medewerker actor kind.
public class StubIdentityProviderTests
{
private static CallerIdentity Resolve(
string? role = null, string? subject = null, string? medewerker = null, string? rollen = null)
{
var ctx = new DefaultHttpContext();
if (role is not null) ctx.Request.Headers["X-Role"] = role;
if (subject is not null) ctx.Request.Headers["X-Subject"] = subject;
if (medewerker is not null) ctx.Request.Headers["X-Medewerker"] = medewerker;
if (rollen is not null) ctx.Request.Headers["X-Rollen"] = rollen;
return new StubIdentityProvider().Resolve(ctx);
}
private static ZorgverlenerCaller ResolveZorgverlener(string? role = null, string? subject = null) =>
Assert.IsType<ZorgverlenerCaller>(Resolve(role, subject));
[Fact]
public void No_headers_resolves_to_the_seeded_citizen_as_a_drafter()
{
var caller = ResolveZorgverlener(role: null, subject: null);
Assert.Equal(DocumentStore.DemoOwner, caller.Bsn);
Assert.Equal(PrincipalRole.Drafter, caller.Role);
}
[Theory]
[InlineData("approver", PrincipalRole.Approver)]
[InlineData("admin", PrincipalRole.Admin)]
[InlineData("something-unknown", PrincipalRole.Drafter)]
public void X_role_maps_to_the_principal_role(string header, PrincipalRole expected)
{
Assert.Equal(expected, Resolve(header, subject: null).Role);
}
[Fact]
public void X_subject_overrides_the_default_bsn()
{
var caller = ResolveZorgverlener(role: null, subject: "999888777");
Assert.Equal("999888777", caller.Bsn);
}
[Fact]
public void No_headers_resolves_a_zorgverlener_kind()
{
Assert.IsType<ZorgverlenerCaller>(Resolve());
}
[Fact]
public void X_medewerker_resolves_a_medewerker_with_the_default_behandelaar_rol()
{
var caller = Assert.IsType<MedewerkerCaller>(Resolve(medewerker: "m.jansen"));
Assert.Equal("m.jansen", caller.MedewerkerId);
Assert.Equal("m.jansen", caller.SubjectId);
Assert.Contains(MedewerkerRol.Behandelaar, caller.Rollen);
}
[Fact]
public void X_medewerker_takes_precedence_over_x_subject()
{
var caller = Resolve(subject: "999888777", medewerker: "m.jansen");
Assert.IsType<MedewerkerCaller>(caller);
}
[Fact]
public void Empty_x_medewerker_falls_through_to_the_zorgverlener_default()
{
var caller = Assert.IsType<ZorgverlenerCaller>(Resolve(medewerker: ""));
Assert.Equal(DocumentStore.DemoOwner, caller.Bsn);
}
[Theory]
[InlineData("behandelaar", new[] { MedewerkerRol.Behandelaar })]
[InlineData("Behandelaar, behandelaar", new[] { MedewerkerRol.Behandelaar })]
[InlineData("geen", new MedewerkerRol[0])]
public void X_rollen_parses_known_tokens_and_drops_unknown_ones(string rollen, MedewerkerRol[] expected)
{
var caller = Assert.IsType<MedewerkerCaller>(Resolve(medewerker: "m.jansen", rollen: rollen));
Assert.Equal(expected, caller.Rollen);
}
[Fact]
public void X_role_still_applies_to_a_medewerker()
{
var caller = Resolve(role: "admin", medewerker: "m.jansen");
Assert.Equal(PrincipalRole.Admin, caller.Role);
}
/// RB-09/BIO-002: IIdentityProvider.Resolve can now return null ("no identity"), but this
/// stub's own contract stays non-nullable — it is a developer convenience that always invents
/// a caller, never a source of "no identity" itself. A request with genuinely no headers at
/// all still resolves to the seeded citizen, unchanged.
[Fact]
public void Never_returns_null_even_with_no_headers_at_all()
{
Assert.NotNull(new StubIdentityProvider().Resolve(new DefaultHttpContext()));
}
}
/// RB-09/BIO-002: in Production, StubIdentityProvider is not registered at all (it is
/// Development-only) and there is no real DigiD/employee-SSO IIdentityProvider in this POC yet —
/// so a Production build must fail at startup rather than silently resolving every request to
/// the seeded citizen (the failure mode BIO-002 documents).
public class ProductionIdentityProviderTests
{
[Fact]
public void Production_environment_with_no_real_identity_provider_fails_at_startup()
{
using var factory = new WebApplicationFactory<Program>()
.WithWebHostBuilder(builder => builder.UseEnvironment("Production"));
// The throw happens while the app builds services, before any request can be served —
// triggered here by the test host materialising that host to hand out a client.
Assert.ThrowsAny<Exception>(() => factory.CreateClient());
}
}