All checks were successful
## What & why First half of **S-12c** (behandel-portal backend), per **ADR-0013** (decisions recorded in #84): - **BFF multi-realm auth.** A second JWT bearer scheme (`medewerker`) alongside the default `digid` scheme. On validation it lifts Keycloak's `realm_access.roles` onto the principal, and a `behandelaar` policy (medewerker scheme + `behandelaar` role) gates `/behandel/*`. Self-service keeps the digid scheme. - **Werkbak = Flowable tasks.** The domain `Werkbak` query reads the open `Beoordelen` tasks (§8.2, S-12b's `IUserTaskClient`) and enriches each with its aggregate's bsn + status; `GET /behandel/werkbak` (domain) is proxied by the BFF `GET /behandel/werkbak` behind the behandelaar policy. The read projection stays the anonymous openbaar model (no premature `IN_BEHANDELING`/personal-data plumbing — deferred in ADR-0008). Behavior: `/behandel/werkbak` is **401** without a token, **403** for a medewerker lacking the role, **200 + werkbak** for a behandelaar. **S-12c-2** (next): `POST /behandel/registrations/{id}/decide` → domain decision + complete the Flowable task. ## Definition of Done - [x] Linked issue: #13 (umbrella, `refs`); closes the adr-proposal #84 - [x] Tests first; red → green per layer - [x] Unit + acceptance green (`make unit`): domain 78, bff 23, acceptance 9 (+ acl/event-subscriber unaffected) - [x] api-client `test` green; openapi.json regenerated (drift guard passes) - [x] Mutation ≥ break(90): **domain 100%, bff 100%** - [x] ADR-0013 added; `Keycloak__MedewerkerAuthority` wired into compose - [ ] CI green (pending) Part of #13. closes #84 Reviewed-on: #85
97 lines
4.3 KiB
C#
97 lines
4.3 KiB
C#
using System.Text;
|
|
using Bff.Api;
|
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
|
using Microsoft.AspNetCore.Hosting;
|
|
using Microsoft.AspNetCore.Mvc.Testing;
|
|
using Microsoft.AspNetCore.TestHost;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.IdentityModel.Protocols;
|
|
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
|
|
using Microsoft.IdentityModel.Tokens;
|
|
|
|
namespace Bff.Tests;
|
|
|
|
/// <summary>
|
|
/// Test host for the BFF. It swaps the downstream clients for in-memory fakes and reconfigures the
|
|
/// JWT bearer to validate against a local test key (no live Keycloak) — so token validation is
|
|
/// exercised in-process with tokens the tests mint (ADR-0010).
|
|
/// </summary>
|
|
internal sealed class BffFactory : WebApplicationFactory<Program>
|
|
{
|
|
public static readonly SymmetricSecurityKey TestSigningKey =
|
|
new(Encoding.UTF8.GetBytes("bff-test-signing-key-that-is-at-least-256-bits-long!"));
|
|
|
|
public FakeDomainClient Domain { get; } = new();
|
|
public FakeProjectionClient Projection { get; } = new();
|
|
|
|
private static void ValidateWithTestKey(IServiceCollection services, string scheme) =>
|
|
services.Configure<JwtBearerOptions>(scheme, options =>
|
|
{
|
|
// Validate locally against the test key and NEVER reach out for OIDC metadata. A static
|
|
// configuration manager guarantees this regardless of Configure/PostConfigure ordering —
|
|
// clearing Authority alone left the medewerker scheme fetching metadata under CI timing
|
|
// (2s hang → 401), because JwtBearer's PostConfigure could still build a ConfigurationManager.
|
|
options.Authority = null;
|
|
options.MetadataAddress = null!;
|
|
options.RequireHttpsMetadata = false;
|
|
options.Configuration = new OpenIdConnectConfiguration();
|
|
options.ConfigurationManager =
|
|
new StaticConfigurationManager<OpenIdConnectConfiguration>(new OpenIdConnectConfiguration());
|
|
options.TokenValidationParameters = new TokenValidationParameters
|
|
{
|
|
ValidateIssuer = false,
|
|
ValidateAudience = false,
|
|
ValidateLifetime = true,
|
|
ValidateIssuerSigningKey = true,
|
|
IssuerSigningKey = TestSigningKey,
|
|
ClockSkew = TimeSpan.Zero,
|
|
};
|
|
});
|
|
|
|
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
|
{
|
|
builder.UseSetting("Keycloak:Authority", "https://keycloak.invalid/realms/digid");
|
|
builder.UseSetting("Keycloak:MedewerkerAuthority", "https://keycloak.invalid/realms/medewerker");
|
|
builder.UseSetting("Downstream:Domain:BaseUrl", "http://domain.invalid/");
|
|
builder.UseSetting("Downstream:Projection:BaseUrl", "http://projection.invalid/");
|
|
|
|
builder.ConfigureTestServices(services =>
|
|
{
|
|
services.AddSingleton<IDomainClient>(Domain);
|
|
services.AddSingleton<IProjectionClient>(Projection);
|
|
|
|
// Both realms validate locally against the test key (no live Keycloak). The medewerker
|
|
// scheme keeps its OnTokenValidated role-lifting from Program.cs — only the validation
|
|
// parameters are swapped here.
|
|
ValidateWithTestKey(services, JwtBearerDefaults.AuthenticationScheme);
|
|
ValidateWithTestKey(services, "medewerker");
|
|
});
|
|
}
|
|
}
|
|
|
|
/// <summary>Captures the bsn the BFF forwarded and returns a canned acceptance.</summary>
|
|
internal sealed class FakeDomainClient : IDomainClient
|
|
{
|
|
public string? SubmittedBsn { get; private set; }
|
|
public SubmitAccepted Result { get; set; } = new("reg-123", "Ingediend");
|
|
public List<WerkbakItem> Werkbak { get; } = [];
|
|
|
|
public Task<SubmitAccepted> SubmitRegistrationAsync(string bsn, CancellationToken ct = default)
|
|
{
|
|
SubmittedBsn = bsn;
|
|
return Task.FromResult(Result);
|
|
}
|
|
|
|
public Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default)
|
|
=> Task.FromResult<IReadOnlyList<WerkbakItem>>(Werkbak);
|
|
}
|
|
|
|
/// <summary>Serves a configurable set of projection rows.</summary>
|
|
internal sealed class FakeProjectionClient : IProjectionClient
|
|
{
|
|
public List<ProjectionEntry> Entries { get; } = [];
|
|
|
|
public Task<IReadOnlyList<ProjectionEntry>> GetRegisterAsync(CancellationToken ct = default)
|
|
=> Task.FromResult<IReadOnlyList<ProjectionEntry>>(Entries);
|
|
}
|