diff --git a/tests/acceptance/Acceptance.csproj b/tests/acceptance/Acceptance.csproj index 08d182b..50d99ab 100644 --- a/tests/acceptance/Acceptance.csproj +++ b/tests/acceptance/Acceptance.csproj @@ -9,6 +9,8 @@ + + @@ -20,6 +22,7 @@ + diff --git a/tests/acceptance/Features/BffToegang.feature b/tests/acceptance/Features/BffToegang.feature new file mode 100644 index 0000000..85c0e50 --- /dev/null +++ b/tests/acceptance/Features/BffToegang.feature @@ -0,0 +1,22 @@ +# language: en +# Drives S-07 (#8). The BFF is the portals' only backend (ยง8.3): it validates Keycloak digid tokens +# on the self-service submit and serves the openbaar register anonymously with only public-safe +# fields (ADR-0010). Tokens are minted with a local test key; real Keycloak validation is verify-bff. +Feature: Toegang via de BFF + Als portaal wil ik uitsluitend via de BFF met de backend praten + zodat tokenvalidatie en veilige velden centraal geregeld zijn. + + Scenario: Indienen met een geldig token wordt doorgezet naar het domein + Given a zorgprofessional with a valid DigiD token for BSN "123456782" + When they submit a registration via the BFF + Then the BFF accepts it and forwards BSN "123456782" to the domain + + Scenario: Indienen zonder token wordt geweigerd + Given a caller without a token + When they submit a registration via the BFF + Then the BFF rejects it as unauthorized + + Scenario: Het openbaar register is anoniem en toont geen BSN + Given the projection contains a zaak "abc-111" for BSN "123456782" + When the openbaar register is queried anonymously + Then the response lists "abc-111" without exposing the BSN diff --git a/tests/acceptance/Steps/BffToegangSteps.cs b/tests/acceptance/Steps/BffToegangSteps.cs new file mode 100644 index 0000000..7e19e04 --- /dev/null +++ b/tests/acceptance/Steps/BffToegangSteps.cs @@ -0,0 +1,83 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using Acceptance.Support; +using Bff.Api; +using Reqnroll; +using Xunit; + +namespace Acceptance.Steps; + +/// Bindings for BffToegang.feature (S-07). Drives the real BFF over HTTP with a +/// fake domain + projection and locally-minted tokens (ADR-0010). One host per scenario. +[Binding] +public sealed class BffToegangSteps : IDisposable +{ + private readonly BffAcceptanceHost _host = new(); + private HttpClient _client = null!; + private string? _token; + private HttpResponseMessage? _response; + + [Given("a zorgprofessional with a valid DigiD token for BSN \"(.*)\"")] + public void GivenAValidToken(string bsn) + { + _token = BffAcceptanceHost.MintToken(bsn); + _client = _host.CreateClient(); + } + + [Given("a caller without a token")] + public void GivenNoToken() => _client = _host.CreateClient(); + + [Given("the projection contains a zaak \"(.*)\" for BSN \"(.*)\"")] + public void GivenTheProjectionContains(string zaakId, string bsn) + { + _host.Projection.Entries.Add(new ProjectionEntry(zaakId, "INGEDIEND", bsn, null)); + _client = _host.CreateClient(); + } + + [When("they submit a registration via the BFF")] + public async Task WhenTheySubmit() + { + var request = new HttpRequestMessage(HttpMethod.Post, "/self-service/registrations") + { + Content = JsonContent.Create(new { }), + }; + if (_token is not null) + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _token); + _response = await _client.SendAsync(request); + } + + [When("the openbaar register is queried anonymously")] + public async Task WhenTheOpenbaarRegisterIsQueried() + => _response = await _client.GetAsync("/openbaar/register"); + + [Then("the BFF accepts it and forwards BSN \"(.*)\" to the domain")] + public void ThenAcceptedAndForwarded(string bsn) + { + Assert.Equal(HttpStatusCode.Accepted, _response!.StatusCode); + Assert.Equal(bsn, _host.Domain.SubmittedBsn); + } + + [Then("the BFF rejects it as unauthorized")] + public void ThenUnauthorized() + { + Assert.Equal(HttpStatusCode.Unauthorized, _response!.StatusCode); + Assert.Null(_host.Domain.SubmittedBsn); + } + + [Then("the response lists \"(.*)\" without exposing the BSN")] + public async Task ThenListsWithoutBsn(string zaakId) + { + Assert.Equal(HttpStatusCode.OK, _response!.StatusCode); + var body = await _response.Content.ReadAsStringAsync(); + Assert.Contains(zaakId, body); + Assert.DoesNotContain("123456782", body); + } + + public void Dispose() + { + _response?.Dispose(); + _client?.Dispose(); + _host.Dispose(); + } +} diff --git a/tests/acceptance/Support/BffAcceptanceHost.cs b/tests/acceptance/Support/BffAcceptanceHost.cs new file mode 100644 index 0000000..dcd94a2 --- /dev/null +++ b/tests/acceptance/Support/BffAcceptanceHost.cs @@ -0,0 +1,80 @@ +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.JsonWebTokens; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; +using Microsoft.IdentityModel.Tokens; + +namespace Acceptance.Support; + +/// Hosts the real BFF in-process for the acceptance scenario, with fake downstreams and a +/// local test signing key so tokens can be minted without a live Keycloak (ADR-0010). +public sealed class BffAcceptanceHost : WebApplicationFactory +{ + private static readonly SymmetricSecurityKey TestKey = + new(Encoding.UTF8.GetBytes("acceptance-bff-signing-key-at-least-256-bits-long!!")); + + public CapturingDomainClient Domain { get; } = new(); + public StubProjectionClient Projection { get; } = new(); + + public static string MintToken(string bsn) => new JsonWebTokenHandler().CreateToken(new SecurityTokenDescriptor + { + Claims = new Dictionary { ["bsn"] = bsn }, + Expires = DateTime.UtcNow.AddMinutes(30), + SigningCredentials = new SigningCredentials(TestKey, SecurityAlgorithms.HmacSha256), + }); + + 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(Domain); + services.AddSingleton(Projection); + services.Configure(JwtBearerDefaults.AuthenticationScheme, options => + { + 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 = TestKey, + ClockSkew = TimeSpan.Zero, + }; + }); + }); + } +} + +/// Records the bsn the BFF forwarded. +public sealed class CapturingDomainClient : IDomainClient +{ + public string? SubmittedBsn { get; private set; } + + public Task SubmitRegistrationAsync(string bsn, CancellationToken ct = default) + { + SubmittedBsn = bsn; + return Task.FromResult(new SubmitAccepted("reg-acc-1", "Ingediend")); + } +} + +/// Serves configurable projection rows. +public sealed class StubProjectionClient : IProjectionClient +{ + public List Entries { get; } = []; + + public Task> GetRegisterAsync(CancellationToken ct = default) + => Task.FromResult>(Entries); +}