diff --git a/services/bff/Bff.Api/DownstreamClients.cs b/services/bff/Bff.Api/DownstreamClients.cs index a2d8151..f9761d5 100644 --- a/services/bff/Bff.Api/DownstreamClients.cs +++ b/services/bff/Bff.Api/DownstreamClients.cs @@ -54,6 +54,19 @@ public interface IProjectionClient Task> GetRegisterAsync(CancellationToken ct = default); } +/// A published zaaktype as the beheer catalogus viewer shows it (S-15a): the business +/// Identificatie + human Omschrijving. The ZGW URL the ACL also returns is dropped — an +/// internal reference, not shown in the portal. +public sealed record BeheerZaaktype(string Identificatie, string Omschrijving); + +/// Port to the ACL for read-only catalogus queries (beheer portal, S-15a). The BFF reaches the +/// ACL directly for this read: the catalogus isn't a domain concern, and the ACL is the only code +/// allowed to read the ZGW Catalogi API (§8.1, ADR-0025). +public interface IAclClient +{ + Task> GetZaaktypenAsync(CancellationToken ct = default); +} + /// Calls the Domain Service's POST /registrations. public sealed class DomainClient(HttpClient http) : IDomainClient { @@ -121,3 +134,11 @@ public sealed class ProjectionClient(HttpClient http) : IProjectionClient public async Task> GetRegisterAsync(CancellationToken ct = default) => await http.GetFromJsonAsync>("register", ct) ?? []; } + +/// Calls the ACL's GET /catalogi/zaaktypen (S-15a). The ACL also returns each zaaktype's +/// ZGW URL; deserializing into keeps only the public-safe fields. +public sealed class AclClient(HttpClient http) : IAclClient +{ + public async Task> GetZaaktypenAsync(CancellationToken ct = default) + => await http.GetFromJsonAsync>("catalogi/zaaktypen", ct) ?? []; +} diff --git a/services/bff/Bff.Tests/BeheerEndpointTests.cs b/services/bff/Bff.Tests/BeheerEndpointTests.cs new file mode 100644 index 0000000..ee884de --- /dev/null +++ b/services/bff/Bff.Tests/BeheerEndpointTests.cs @@ -0,0 +1,57 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using Bff.Api; + +namespace Bff.Tests; + +/// +/// The beheer catalogus viewer (S-15a): reached only with a medewerker-realm token carrying the +/// beheerder role. A missing token is 401; an authenticated medewerker without the role (e.g. +/// a plain behandelaar) is 403; a beheerder gets the read-only list of published zaaktypen. +/// +public class BeheerEndpointTests +{ + private static HttpRequestMessage Zaaktypen(string? bearer) + { + var request = new HttpRequestMessage(HttpMethod.Get, "/beheer/catalogi/zaaktypen"); + if (bearer is not null) + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearer); + return request; + } + + [Fact] + public async Task Rejects_the_catalogus_without_a_token() + { + using var factory = new BffFactory(); + + var response = await factory.CreateClient().SendAsync(Zaaktypen(bearer: null)); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task Rejects_a_medewerker_without_the_beheerder_role() + { + using var factory = new BffFactory(); + + var response = await factory.CreateClient().SendAsync(Zaaktypen(TestTokens.Medewerker("behandelaar"))); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact] + public async Task Serves_the_published_zaaktypen_to_a_beheerder() + { + using var factory = new BffFactory(); + factory.Acl.Zaaktypen.Add(new BeheerZaaktype("BIG-REGISTRATIE", "BIG-registratie")); + + var response = await factory.CreateClient().SendAsync(Zaaktypen(TestTokens.Medewerker("beheerder"))); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var items = await response.Content.ReadFromJsonAsync>(); + var item = Assert.Single(items!); + Assert.Equal("BIG-REGISTRATIE", item.Identificatie); + Assert.Equal("BIG-registratie", item.Omschrijving); + } +} diff --git a/services/bff/Bff.Tests/BffFactory.cs b/services/bff/Bff.Tests/BffFactory.cs index 7985ba1..7f1141e 100644 --- a/services/bff/Bff.Tests/BffFactory.cs +++ b/services/bff/Bff.Tests/BffFactory.cs @@ -23,6 +23,7 @@ internal sealed class BffFactory : WebApplicationFactory public FakeDomainClient Domain { get; } = new(); public FakeProjectionClient Projection { get; } = new(); + public FakeAclClient Acl { get; } = new(); private static void ValidateWithTestKey(IServiceCollection services, string scheme) => services.Configure(scheme, options => @@ -54,11 +55,13 @@ internal sealed class BffFactory : WebApplicationFactory 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.UseSetting("Downstream:Acl:BaseUrl", "http://acl.invalid/"); builder.ConfigureTestServices(services => { services.AddSingleton(Domain); services.AddSingleton(Projection); + services.AddSingleton(Acl); // 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 @@ -138,3 +141,12 @@ internal sealed class FakeProjectionClient : IProjectionClient public Task> GetRegisterAsync(CancellationToken ct = default) => Task.FromResult>(Entries); } + +/// Serves a configurable set of catalogus zaaktypen (beheer viewer, S-15a). +internal sealed class FakeAclClient : IAclClient +{ + public List Zaaktypen { get; } = []; + + public Task> GetZaaktypenAsync(CancellationToken ct = default) + => Task.FromResult>(Zaaktypen); +}