test(bff): endpoints, JWT auth and public-safe projection (refs #8)

Failing tests for the BFF walking-skeleton endpoints:
- POST /self-service/registrations rejects missing/malformed/wrong-key/expired
  tokens (401) and, with a valid digid token, forwards the bsn to the domain and
  returns 202 (WebApplicationFactory + a local test signing key, ADR-0010).
- GET /openbaar/register serves public-safe rows anonymously (never the bsn) and
  filters by q.
- OpenbaarProjection.PublicView (pure) filters by id and maps to id+status only.

Endpoints and PublicView are stubs so the tests compile and fail on their
assertions; the green commit implements them.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-01 10:59:55 +02:00
parent fea806848b
commit 751ca006a7
10 changed files with 380 additions and 2 deletions

View File

@@ -0,0 +1,78 @@
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.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();
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseSetting("Keycloak:Authority", "https://keycloak.invalid/realms/digid");
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);
services.Configure<JwtBearerOptions>(JwtBearerDefaults.AuthenticationScheme, options =>
{
// Validate locally against the test key; never reach out for OIDC metadata.
options.Authority = null;
options.MetadataAddress = null!;
options.RequireHttpsMetadata = false;
options.Configuration = new OpenIdConnectConfiguration();
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = false,
ValidateAudience = false,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
IssuerSigningKey = TestSigningKey,
ClockSkew = TimeSpan.Zero,
};
});
});
}
}
/// <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 Task<SubmitAccepted> SubmitRegistrationAsync(string bsn, CancellationToken ct = default)
{
SubmittedBsn = bsn;
return Task.FromResult(Result);
}
}
/// <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);
}

View File

@@ -0,0 +1,38 @@
using System.Net;
using System.Net.Http.Json;
using Bff.Api;
namespace Bff.Tests;
public class OpenbaarEndpointTests
{
[Fact]
public async Task Serves_public_safe_rows_anonymously()
{
using var factory = new BffFactory();
factory.Projection.Entries.Add(new ProjectionEntry("abc-111", "INGEDIEND", "123456782", "Jan"));
// No Authorization header — the openbaar register is a public lookup (ADR-0010/S-09).
var response = await factory.CreateClient().GetAsync("/openbaar/register");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadAsStringAsync();
Assert.Contains("abc-111", body);
Assert.Contains("INGEDIEND", body);
// The bsn must never appear in a public response.
Assert.DoesNotContain("123456782", body);
}
[Fact]
public async Task Filters_by_the_query_parameter()
{
using var factory = new BffFactory();
factory.Projection.Entries.Add(new ProjectionEntry("abc-111", "INGEDIEND", null, null));
factory.Projection.Entries.Add(new ProjectionEntry("def-222", "INGEDIEND", null, null));
var rows = await factory.CreateClient()
.GetFromJsonAsync<List<OpenbaarEntry>>("/openbaar/register?q=abc");
Assert.Equal("abc-111", Assert.Single(rows!).Id);
}
}

View File

@@ -0,0 +1,48 @@
using Bff.Api;
namespace Bff.Tests;
public class OpenbaarProjectionTests
{
private static readonly ProjectionEntry[] Sample =
[
new("abc-111", "INGEDIEND", "123456782", "Jan"),
new("def-222", "INGEDIEND", "987654321", "Piet"),
];
[Fact]
public void Maps_every_row_to_public_safe_fields_when_no_query()
{
var view = OpenbaarProjection.PublicView(Sample, null);
Assert.Equal(2, view.Count);
Assert.Equal("abc-111", view[0].Id);
Assert.Equal("INGEDIEND", view[0].Status);
}
[Fact]
public void Public_view_exposes_only_id_and_status()
{
// OpenbaarEntry structurally carries only Id + Status — bsn/naam can never leak.
var props = typeof(OpenbaarEntry).GetProperties().Select(p => p.Name).ToArray();
Assert.Equal(["Id", "Status"], props);
}
[Theory]
[InlineData("abc", 1)]
[InlineData("ABC", 1)]
[InlineData("2", 1)]
[InlineData("zzz", 0)]
public void Filters_by_id_containing_the_query_case_insensitively(string q, int expected)
{
var view = OpenbaarProjection.PublicView(Sample, q);
Assert.Equal(expected, view.Count);
}
[Fact]
public void Blank_query_is_treated_as_no_filter()
{
Assert.Equal(2, OpenbaarProjection.PublicView(Sample, " ").Count);
}
}

View File

@@ -0,0 +1,75 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
namespace Bff.Tests;
public class SelfServiceEndpointTests
{
private static HttpRequestMessage Submit(string? bearer)
{
var request = new HttpRequestMessage(HttpMethod.Post, "/self-service/registrations")
{
Content = JsonContent.Create(new { }),
};
if (bearer is not null)
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearer);
return request;
}
[Fact]
public async Task Rejects_a_request_without_a_token()
{
using var factory = new BffFactory();
var client = factory.CreateClient();
var response = await client.SendAsync(Submit(bearer: null));
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
Assert.Null(factory.Domain.SubmittedBsn);
}
[Theory]
[InlineData("not-a-jwt")]
public async Task Rejects_a_malformed_token(string bearer)
{
using var factory = new BffFactory();
var response = await factory.CreateClient().SendAsync(Submit(bearer));
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task Rejects_a_token_signed_with_the_wrong_key()
{
using var factory = new BffFactory();
var response = await factory.CreateClient().SendAsync(Submit(TestTokens.WrongKey("123456782")));
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task Rejects_an_expired_token()
{
using var factory = new BffFactory();
var response = await factory.CreateClient().SendAsync(Submit(TestTokens.Expired("123456782")));
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task Accepts_a_valid_token_and_forwards_the_bsn_to_the_domain()
{
using var factory = new BffFactory();
var client = factory.CreateClient();
var response = await client.SendAsync(Submit(TestTokens.Valid("123456782")));
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
Assert.Equal("123456782", factory.Domain.SubmittedBsn);
var body = await response.Content.ReadFromJsonAsync<SubmitAcceptedDto>();
Assert.Equal("reg-123", body!.RegistrationId);
}
private sealed record SubmitAcceptedDto(string RegistrationId, string Status);
}

View File

@@ -0,0 +1,30 @@
using System.Text;
using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Tokens;
namespace Bff.Tests;
/// <summary>Mints JWTs for the BFF tests — signed with the factory's test key (valid) or otherwise
/// (a wrong key / expired) to exercise the bearer validation the BFF configures.</summary>
internal static class TestTokens
{
public static string Valid(string bsn) => Create(bsn, BffFactory.TestSigningKey, expired: false);
public static string Expired(string bsn) => Create(bsn, BffFactory.TestSigningKey, expired: true);
public static string WrongKey(string bsn) => Create(
bsn,
new SymmetricSecurityKey(Encoding.UTF8.GetBytes("a-different-signing-key-256-bits-long-indeed-yes!")),
expired: false);
private static string Create(string bsn, SymmetricSecurityKey key, bool expired)
{
var handler = new JsonWebTokenHandler();
return handler.CreateToken(new SecurityTokenDescriptor
{
Claims = new Dictionary<string, object> { ["bsn"] = bsn },
Expires = DateTime.UtcNow.AddMinutes(expired ? -5 : 30),
SigningCredentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256),
});
}
}