## What & why S-10b: the self-service **diploma upload** is now real. After submitting, the citizen picks a PDF and uploads it; the portal base64-encodes it client-side → BFF → domain → **ACL**, which stores it in the ZGW **Documenten (DRC) API** as an `enkelvoudiginformatieobject` and relates it to the zaak, then the `WachtOpDocumenten` wait completes and the case advances to beoordeling. Per §8.1 only the ACL talks to ZGW. Closes #103 Mechanism in **ADR-0018** (proposal #107). Builds on S-10a (#102). The zaak-close-on-expiry item is carved to **#106 (S-10c)**. ## Definition of Done - [x] Linked Gitea issue (above). - [x] Failing test committed before the implementation (red→green per layer). - [x] Conventional Commits referencing the issue (`refs #103`). - [ ] CI green — all Gitea Actions jobs (pending on this PR). - [x] `docker compose up` health unaffected (ACL boots on a placeholder informatieobjecttype URL; the real one is injected by verify-domain). - [x] Docs updated (ADR-0018, demo-script, BACKLOG + S-10c). - [x] ADR added (`docs/architecture/adr-0018-diploma-upload-via-acl-documenten.md`). - [x] Demo note in `docs/demo-script.md`. ## Notes for reviewers - **ACL** (`OpenZaakGateway.StoreDocumentAsync` + `AclService.StoreDiplomaAsync` + `POST /documenten`) reuses the existing gateway patterns (ZGW Bearer, buffered non-chunked body, **no CRS** — Documenten isn't geo). Unit-tested via the stub handler; an **integration test** stores a real document against live OpenZaak (verify-acl). - **Transport:** base64 JSON on every hop (portal encodes client-side) — I deviated from proposal #107's multipart to keep one contract shape and avoid `IFormFile`/antiforgery/multipart-client plumbing; fine at diploma size (ADR-0018 §Alternatives). - **Infra:** `seed_catalogus.py` seeds + publishes a "Diploma" `informatieobjecttype` and relates it to the zaaktype (while both concept); `verify-domain` injects its URL into the ACL. No new ZGW scopes (seed applicatie has `heeft_alle_autorisaties`). - **e2e:** uploads a real PDF (`setInputFiles`) after the openbaar INGEDIEND row confirms the zaak is open (so storage doesn't race the OpenZaak worker). - **Scope boundary:** the ZGW zaak is not set to a cancellation status on 30-day expiry — that's #106 (S-10c). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Reviewed-on: #108
94 lines
3.9 KiB
C#
94 lines
3.9 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.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"));
|
|
}
|
|
|
|
public Task<bool> WithdrawRegistrationAsync(string registrationId, string bsn, CancellationToken ct = default)
|
|
=> Task.FromResult(true);
|
|
|
|
public Task<bool> ProvideDocumentsAsync(
|
|
string registrationId, string bsn, string contentBase64, string? fileName, string? contentType, CancellationToken ct = default)
|
|
=> Task.FromResult(true);
|
|
|
|
public Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default)
|
|
=> Task.FromResult<IReadOnlyList<WerkbakItem>>([]);
|
|
|
|
public Task DecideAsync(string registrationId, string besluit, CancellationToken ct = default)
|
|
=> Task.CompletedTask;
|
|
}
|
|
|
|
/// <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);
|
|
}
|