test(bff): acceptance scenario for BFF access (valid/invalid tokens) (refs #8)
Use-case-level BDD (Reqnroll) driving the real BFF over HTTP with fake downstreams and locally-minted tokens: a valid DigiD token is accepted and the bsn forwarded to the domain; a tokenless submit is 401; the openbaar register is anonymous and never exposes the bsn (ADR-0010). Real Keycloak validation is the verify-bff check. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,8 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||||
|
<!-- The BFF acceptance scenario drives the real HTTP endpoints in-process (ADR-0010). -->
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.8" />
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||||
<PackageReference Include="Reqnroll.xUnit" Version="3.3.4" />
|
<PackageReference Include="Reqnroll.xUnit" Version="3.3.4" />
|
||||||
<PackageReference Include="xunit" Version="2.9.3" />
|
<PackageReference Include="xunit" Version="2.9.3" />
|
||||||
@@ -20,6 +22,7 @@
|
|||||||
<ProjectReference Include="..\..\services\acl\Acl.Infrastructure\Acl.Infrastructure.csproj" />
|
<ProjectReference Include="..\..\services\acl\Acl.Infrastructure\Acl.Infrastructure.csproj" />
|
||||||
<ProjectReference Include="..\..\services\event-subscriber\EventSubscriber.Application\EventSubscriber.Application.csproj" />
|
<ProjectReference Include="..\..\services\event-subscriber\EventSubscriber.Application\EventSubscriber.Application.csproj" />
|
||||||
<ProjectReference Include="..\..\services\domain\Big.Application\Big.Application.csproj" />
|
<ProjectReference Include="..\..\services\domain\Big.Application\Big.Application.csproj" />
|
||||||
|
<ProjectReference Include="..\..\services\bff\Bff.Api\Bff.Api.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
22
tests/acceptance/Features/BffToegang.feature
Normal file
22
tests/acceptance/Features/BffToegang.feature
Normal file
@@ -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
|
||||||
83
tests/acceptance/Steps/BffToegangSteps.cs
Normal file
83
tests/acceptance/Steps/BffToegangSteps.cs
Normal file
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>Bindings for <c>BffToegang.feature</c> (S-07). Drives the real BFF over HTTP with a
|
||||||
|
/// fake domain + projection and locally-minted tokens (ADR-0010). One host per scenario.</summary>
|
||||||
|
[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();
|
||||||
|
}
|
||||||
|
}
|
||||||
80
tests/acceptance/Support/BffAcceptanceHost.cs
Normal file
80
tests/acceptance/Support/BffAcceptanceHost.cs
Normal file
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>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).</summary>
|
||||||
|
public sealed class BffAcceptanceHost : WebApplicationFactory<Program>
|
||||||
|
{
|
||||||
|
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<string, object> { ["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<IDomainClient>(Domain);
|
||||||
|
services.AddSingleton<IProjectionClient>(Projection);
|
||||||
|
services.Configure<JwtBearerOptions>(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,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Records the bsn the BFF forwarded.</summary>
|
||||||
|
public sealed class CapturingDomainClient : IDomainClient
|
||||||
|
{
|
||||||
|
public string? SubmittedBsn { get; private set; }
|
||||||
|
|
||||||
|
public Task<SubmitAccepted> SubmitRegistrationAsync(string bsn, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
SubmittedBsn = bsn;
|
||||||
|
return Task.FromResult(new SubmitAccepted("reg-acc-1", "Ingediend"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Serves configurable projection rows.</summary>
|
||||||
|
public sealed class StubProjectionClient : IProjectionClient
|
||||||
|
{
|
||||||
|
public List<ProjectionEntry> Entries { get; } = [];
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<ProjectionEntry>> GetRegisterAsync(CancellationToken ct = default)
|
||||||
|
=> Task.FromResult<IReadOnlyList<ProjectionEntry>>(Entries);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user