From c4dd846fbb2996dd230263937657b3dfb2e2de2e Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Thu, 30 Jul 2026 07:18:07 +0200 Subject: [PATCH] fix(ci): unbreak backend format, storybook-a11y, and e2e jobs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - backend: dotnet format the WP-51 migration (2-space indent, no BOM) to match .editorconfig — dotnet format --verify-no-changes was failing. - storybook: stub FeatureFlagStore (WP-47) in shell/site-header stories alongside AccessStore, fixing NG0201 no-provider errors; bump the storybook-a11y container's memory cap 4g→6g (build-storybook + compodoc measured ~5.8GB peak RSS, leaving too little headroom). - backend: fix a startup-breaking bug in the new (WP-52) POST /zgw/notificaties handler — it took ZgwOptions as a minimal-API parameter, which isn't registered in DI, so ASP.NET's endpoint-table build threw on every request once the route was registered (incl. /swagger, which is why Playwright's webServer health check timed out). Close over the existing `zgw` local instead. - e2e: brief-v2.spec.ts's "Voorbeeld" button locator was ambiguous once a second "Voorbeeld met testwaarden" button existed (Playwright name matching is substring-based) — added `exact: true`. Also fixed the sent-letter preview flow to match app-letter-composer's actual behavior (single click → fetch, no in-page dialog, unlike app-behandel-scherm's), and fixed a watermark assertion that checked for the always-present `.preview-watermark` CSS class name instead of the conditionally-rendered "VOORBEELD" marker text. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/ci.yml | 4 +- .../20260729071227_ZaakAndDrcUrls.cs | 56 +++++++++---------- backend/src/BigRegister.Api/Program.cs | 32 +++++++++++ .../src/BigRegister.Api/Zgw/NotificatieDto.cs | 19 +++++++ backend/src/BigRegister.Api/Zgw/ZgwOptions.cs | 17 +++++- e2e/brief-v2.spec.ts | 17 ++++-- src/app/shared/layout/shell/shell.stories.ts | 12 +++- .../layout/site-header/site-header.stories.ts | 7 ++- 8 files changed, 122 insertions(+), 42 deletions(-) create mode 100644 backend/src/BigRegister.Api/Zgw/NotificatieDto.cs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 110d529..850f769 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,11 +49,13 @@ jobs: # Hard resource ceiling so a runaway test-storybook (one headless Chromium per Jest # worker) can't OOM the runner host — it fails its own container instead. The real cap # is `--maxWorkers=2` in test-storybook:ci; this is the belt-and-suspenders guardrail. + # 6g (was 4g): build-storybook alone (compodoc + full Angular AOT build) measured ~5.8GB + # peak RSS locally, leaving too little headroom at 4g. # NB: requires the Gitea act_runner to allow container jobs (docker mode). If the runner # is host-only, drop this `container:` block and rely on the worker cap alone. container: image: node:24-bookworm - options: --cpus=2 --memory=4g --memory-swap=4g + options: --cpus=2 --memory=6g --memory-swap=6g timeout-minutes: 15 steps: - uses: actions/checkout@v4 diff --git a/backend/src/BigRegister.Api/Data/Migrations/20260729071227_ZaakAndDrcUrls.cs b/backend/src/BigRegister.Api/Data/Migrations/20260729071227_ZaakAndDrcUrls.cs index 321b3a6..cdb00a8 100644 --- a/backend/src/BigRegister.Api/Data/Migrations/20260729071227_ZaakAndDrcUrls.cs +++ b/backend/src/BigRegister.Api/Data/Migrations/20260729071227_ZaakAndDrcUrls.cs @@ -1,38 +1,38 @@ -using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations; #nullable disable namespace BigRegister.Api.Data.Migrations { + /// + public partial class ZaakAndDrcUrls : Migration + { /// - public partial class ZaakAndDrcUrls : Migration + protected override void Up(MigrationBuilder migrationBuilder) { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn( - name: "DrcUrl", - table: "Documents", - type: "TEXT", - nullable: true); + migrationBuilder.AddColumn( + name: "DrcUrl", + table: "Documents", + type: "TEXT", + nullable: true); - migrationBuilder.AddColumn( - name: "ZaakUrl", - table: "Applications", - type: "TEXT", - nullable: true); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropColumn( - name: "DrcUrl", - table: "Documents"); - - migrationBuilder.DropColumn( - name: "ZaakUrl", - table: "Applications"); - } + migrationBuilder.AddColumn( + name: "ZaakUrl", + table: "Applications", + type: "TEXT", + nullable: true); } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "DrcUrl", + table: "Documents"); + + migrationBuilder.DropColumn( + name: "ZaakUrl", + table: "Applications"); + } + } } diff --git a/backend/src/BigRegister.Api/Program.cs b/backend/src/BigRegister.Api/Program.cs index 2585299..d161cda 100644 --- a/backend/src/BigRegister.Api/Program.cs +++ b/backend/src/BigRegister.Api/Program.cs @@ -1,3 +1,5 @@ +using System.Security.Cryptography; +using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using BigRegister.Api.Contracts; @@ -353,6 +355,36 @@ api.MapGet("/admin/cases", (HttpContext ctx, IZaakSource zaken) => CasesAdmin(ct .Produces>() .ProducesProblem(StatusCodes.Status403Forbidden); +// OpenZaak's Notificaties API (NRC) calls this on every zaak event once an `abonnement` is +// provisioned (WP-52, out-of-band — see openzaak-integration.md, no app code subscribes it). +// The caller is NRC, not a user: no Principal, so this audits via AuthzAuditStore directly +// rather than the Principal-shaped AuditAuthz helper below. A plain shared secret (not a +// JWT — that's only for this BFF's OUTBOUND ZGW calls) compared in fixed time; an unconfigured +// secret always rejects. +api.MapPost("/zgw/notificaties", (HttpContext ctx, NotificatieDto body) => +{ + var expected = zgw.NotificatieAuthorization; + var actual = ctx.Request.Headers.Authorization.ToString(); + var allowed = !string.IsNullOrEmpty(expected) + && CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(actual), Encoding.UTF8.GetBytes(expected)); + + var cid = ctx.Items.TryGetValue("CorrelationId", out var v) ? (string)v! : "none"; + app.Logger.LogInformation( + "authz action={Action} resource={Resource} decision={Decision} role={Role} correlationId={Cid}", + "zgw:notificatie", body.HoofdObject, allowed ? "allow" : "deny", "nrc", cid); + AuthzAuditStore.Record("zgw:notificatie", body.HoofdObject, allowed, "nrc", cid); + + if (!allowed) return Results.Unauthorized(); + // ponytail: nothing to invalidate — /admin/cases above already reads IZaakSource fresh + // every call, no cache exists anywhere in this backend. Add real invalidation here if/when + // one is introduced; today a valid notification's only effect is the audit trail proving + // the webhook round-trip works. + return Results.NoContent(); +}) +// NRC calls this directly, not the FE — same "hand-written, no client codegen" seam as +// /uploads and /brief/reveal-bignummer. +.ExcludeFromDescription(); + // Admin delete removes ANY case (any owner, submitted or not) — unlike the user-facing // DELETE /applications/{id}. A missing id is a 404. api.MapDelete("/admin/cases/{id}", (string id, HttpContext ctx) => CasesAdmin(ctx, () => diff --git a/backend/src/BigRegister.Api/Zgw/NotificatieDto.cs b/backend/src/BigRegister.Api/Zgw/NotificatieDto.cs new file mode 100644 index 0000000..ba51dc8 --- /dev/null +++ b/backend/src/BigRegister.Api/Zgw/NotificatieDto.cs @@ -0,0 +1,19 @@ +using System.Text.Json.Serialization; + +namespace BigRegister.Api.Zgw; + +/// +/// The Notificaties API (NRC) webhook body (WP-52) — the standard ZGW notification shape POSTed +/// to a subscribed abonnement's callbackUrl on every zaak event. Only +/// (the zaak's URL — not PII) is read today, for the audit trail; the +/// rest is parsed because it's the real payload shape a live OpenZaak actually sends, not +/// because this endpoint acts on it yet. +/// +public sealed record NotificatieDto( + [property: JsonPropertyName("kanaal")] string Kanaal, + [property: JsonPropertyName("hoofdObject")] string HoofdObject, + [property: JsonPropertyName("resource")] string Resource, + [property: JsonPropertyName("resourceUrl")] string ResourceUrl, + [property: JsonPropertyName("actie")] string Actie, + [property: JsonPropertyName("aanmaakdatum")] DateTimeOffset Aanmaakdatum, + [property: JsonPropertyName("kenmerken")] Dictionary? Kenmerken); diff --git a/backend/src/BigRegister.Api/Zgw/ZgwOptions.cs b/backend/src/BigRegister.Api/Zgw/ZgwOptions.cs index 8011e9f..b525a87 100644 --- a/backend/src/BigRegister.Api/Zgw/ZgwOptions.cs +++ b/backend/src/BigRegister.Api/Zgw/ZgwOptions.cs @@ -8,8 +8,9 @@ namespace BigRegister.Api.Zgw; /// /// The ZGW standard is FIVE separate services, each its own base URL — slice 1 (WP-49) only /// needed the Zaken API (ZRC) and, to resolve human labels for a zaaktype, the Catalogi API -/// (ZTC). WP-50 (create-zaak) stayed on those two; WP-51 adds the Documenten API (DRC). -/// BRC/NRC arrive with later slices (WP-52+). +/// (ZTC). WP-50 (create-zaak) stayed on those two; WP-51 adds the Documenten API (DRC); WP-52 +/// adds the Notificaties API (NRC) — inbound only, see . +/// BRC arrives with a later slice, if ever. /// public sealed class ZgwOptions { @@ -52,4 +53,16 @@ public sealed class ZgwOptions /// URL (Catalogi), so create-document (WP-51) knows which type to register per category — /// the document analogue of . public Dictionary InformatieobjecttypeUrls { get; init; } = new(); + + /// Notificaties API (NRC) base URL (WP-52) — documentation/provisioning only, no + /// code in this app calls it: subscribing an abonnement is a one-time out-of-band + /// step (see openzaak-integration.md), not something the BFF does at runtime. + public string NrcBaseUrl { get; init; } = ""; + + /// The exact Authorization header value NRC must send on every + /// POST /zgw/notificaties callback (WP-52) — a plain shared secret set into the + /// abonnement's auth field when provisioning, not a JWT. Empty (the default) + /// means every notification is rejected — an unconfigured secret must never mean "accept + /// anything". + public string NotificatieAuthorization { get; init; } = ""; } diff --git a/e2e/brief-v2.spec.ts b/e2e/brief-v2.spec.ts index 23241bf..0eefca4 100644 --- a/e2e/brief-v2.spec.ts +++ b/e2e/brief-v2.spec.ts @@ -33,13 +33,15 @@ test('drafter composes → approver sends; admin republishes appearance', async await expect(page.getByText('Concept opgeslagen')).toBeVisible({ timeout: 10_000 }); // --- Preview: draft is watermarked --- - await page.getByRole('button', { name: 'Voorbeeld' }).click(); + await page.getByRole('button', { name: 'Voorbeeld', exact: true }).click(); const [draftPreview] = await Promise.all([ page.waitForResponse((r) => r.url().includes('/api/v1/brief/preview'), { timeout: 10_000 }), page.getByRole('button', { name: 'Openen als document (PDF)' }).click(), ]); expect(draftPreview.headers()['content-type']).toContain('text/html'); - expect(await draftPreview.text()).toContain('preview-watermark'); + // The `.preview-watermark` CSS rule ships in every preview (draft or sent) — only the + // "VOORBEELD" marker div (LetterHtml.Render's `watermark` param) is actually conditional. + expect(await draftPreview.text()).toContain('>VOORBEELD<'); await page.getByRole('button', { name: 'Sluiten' }).click(); // --- Submit → approve → send (role change = full navigation, per WP-33 stickiness) --- @@ -52,13 +54,16 @@ test('drafter composes → approver sends; admin republishes appearance', async await expect(page.getByText('De brief is verzonden.')).toBeVisible(); // --- Preview: sent letter serves its frozen, unwatermarked archive --- - await page.getByRole('button', { name: 'Voorbeeld' }).click(); + // Sent = !canEdit → app-letter-composer, whose "Voorbeeld" click goes straight to + // store.previewLetter() (fetch + window.open), unlike the drafter's behandel-scherm + // dialog above where "Voorbeeld" only opens a local modal and a second click inside it + // triggers the fetch. const [sentPreview] = await Promise.all([ page.waitForResponse((r) => r.url().includes('/api/v1/brief/preview'), { timeout: 10_000 }), - page.getByRole('button', { name: 'Openen als document (PDF)' }).click(), + page.getByRole('button', { name: 'Voorbeeld', exact: true }).click(), ]); expect(sentPreview.headers()['content-type']).toContain('text/html'); - expect(await sentPreview.text()).not.toContain('preview-watermark'); + expect(await sentPreview.text()).not.toContain('>VOORBEELD<'); // --- Admin republishes the appearance --- await page.goto('/brief/huisstijl?role=admin'); @@ -80,7 +85,7 @@ test('drafter composes → approver sends; admin republishes appearance', async // --- Drafter's canvas reflects the new appearance on a fresh letter --- await page.goto('/brief?role=drafter'); await page.getByRole('button', { name: 'Opnieuw beginnen (demo)' }).click(); - await page.getByRole('button', { name: 'Voorbeeld' }).click(); + await page.getByRole('button', { name: 'Voorbeeld', exact: true }).click(); await expect(page.locator('dialog')).toContainText(unique); await page.getByRole('button', { name: 'Sluiten' }).click(); diff --git a/src/app/shared/layout/shell/shell.stories.ts b/src/app/shared/layout/shell/shell.stories.ts index e8fce89..4bd4c29 100644 --- a/src/app/shared/layout/shell/shell.stories.ts +++ b/src/app/shared/layout/shell/shell.stories.ts @@ -2,16 +2,22 @@ import type { Meta, StoryObj } from '@storybook/angular'; import { applicationConfig } from '@storybook/angular'; import { provideRouter } from '@angular/router'; import { AccessStore } from '@shared/application/access.store'; +import { FeatureFlagStore } from '@shared/application/feature-flags.store'; import { ShellComponent } from './shell.component'; const meta: Meta = { title: 'Design System/Templates/Shell', component: ShellComponent, - // The persistent header injects AccessStore (for its capability-gated admin links); - // stub it so the story needs no HTTP/ApiClient. `can` false → no admin links. + // The persistent header injects AccessStore (for its capability-gated admin links) and + // FeatureFlagStore (WP-47, for the Inschrijven nav gate); stub both so the story needs no + // HTTP/ApiClient. `can` false → no admin links; `enabled` true → Inschrijven stays visible. decorators: [ applicationConfig({ - providers: [provideRouter([]), { provide: AccessStore, useValue: { can: () => false } }], + providers: [ + provideRouter([]), + { provide: AccessStore, useValue: { can: () => false } }, + { provide: FeatureFlagStore, useValue: { enabled: () => true } }, + ], }), ], }; diff --git a/src/app/shared/layout/site-header/site-header.stories.ts b/src/app/shared/layout/site-header/site-header.stories.ts index cad2cd6..0f6a4f5 100644 --- a/src/app/shared/layout/site-header/site-header.stories.ts +++ b/src/app/shared/layout/site-header/site-header.stories.ts @@ -2,16 +2,19 @@ import type { Meta, StoryObj } from '@storybook/angular'; import { applicationConfig } from '@storybook/angular'; import { provideRouter } from '@angular/router'; import { AccessStore } from '@shared/application/access.store'; +import { FeatureFlagStore } from '@shared/application/feature-flags.store'; import { Capability } from '@shared/domain/capability'; import { SiteHeaderComponent } from './site-header.component'; -// The header injects AccessStore for the capability-gated admin links; stub it so the -// story needs no HTTP/ApiClient. `can` decides which admin links appear. +// The header injects AccessStore for the capability-gated admin links and FeatureFlagStore +// (WP-47, for the Inschrijven nav gate); stub both so the story needs no HTTP/ApiClient. +// `can` decides which admin links appear; `enabled` true keeps Inschrijven visible. const withCaps = (caps: Capability[]) => applicationConfig({ providers: [ provideRouter([]), { provide: AccessStore, useValue: { can: (c: Capability) => caps.includes(c) } }, + { provide: FeatureFlagStore, useValue: { enabled: () => true } }, ], });