fix(ci): unbreak backend format, storybook-a11y, and e2e jobs
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -49,11 +49,13 @@ jobs:
|
|||||||
# Hard resource ceiling so a runaway test-storybook (one headless Chromium per Jest
|
# 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
|
# 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.
|
# 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
|
# 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.
|
# is host-only, drop this `container:` block and rely on the worker cap alone.
|
||||||
container:
|
container:
|
||||||
image: node:24-bookworm
|
image: node:24-bookworm
|
||||||
options: --cpus=2 --memory=4g --memory-swap=4g
|
options: --cpus=2 --memory=6g --memory-swap=6g
|
||||||
timeout-minutes: 15
|
timeout-minutes: 15
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|||||||
@@ -1,38 +1,38 @@
|
|||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
namespace BigRegister.Api.Data.Migrations
|
namespace BigRegister.Api.Data.Migrations
|
||||||
{
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class ZaakAndDrcUrls : Migration
|
||||||
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public partial class ZaakAndDrcUrls : Migration
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
migrationBuilder.AddColumn<string>(
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
name: "DrcUrl",
|
||||||
{
|
table: "Documents",
|
||||||
migrationBuilder.AddColumn<string>(
|
type: "TEXT",
|
||||||
name: "DrcUrl",
|
nullable: true);
|
||||||
table: "Documents",
|
|
||||||
type: "TEXT",
|
|
||||||
nullable: true);
|
|
||||||
|
|
||||||
migrationBuilder.AddColumn<string>(
|
migrationBuilder.AddColumn<string>(
|
||||||
name: "ZaakUrl",
|
name: "ZaakUrl",
|
||||||
table: "Applications",
|
table: "Applications",
|
||||||
type: "TEXT",
|
type: "TEXT",
|
||||||
nullable: true);
|
nullable: true);
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.DropColumn(
|
|
||||||
name: "DrcUrl",
|
|
||||||
table: "Documents");
|
|
||||||
|
|
||||||
migrationBuilder.DropColumn(
|
|
||||||
name: "ZaakUrl",
|
|
||||||
table: "Applications");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "DrcUrl",
|
||||||
|
table: "Documents");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ZaakUrl",
|
||||||
|
table: "Applications");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using BigRegister.Api.Contracts;
|
using BigRegister.Api.Contracts;
|
||||||
@@ -353,6 +355,36 @@ api.MapGet("/admin/cases", (HttpContext ctx, IZaakSource zaken) => CasesAdmin(ct
|
|||||||
.Produces<List<ApplicationSummaryDto>>()
|
.Produces<List<ApplicationSummaryDto>>()
|
||||||
.ProducesProblem(StatusCodes.Status403Forbidden);
|
.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
|
// Admin delete removes ANY case (any owner, submitted or not) — unlike the user-facing
|
||||||
// DELETE /applications/{id}. A missing id is a 404.
|
// DELETE /applications/{id}. A missing id is a 404.
|
||||||
api.MapDelete("/admin/cases/{id}", (string id, HttpContext ctx) => CasesAdmin(ctx, () =>
|
api.MapDelete("/admin/cases/{id}", (string id, HttpContext ctx) => CasesAdmin(ctx, () =>
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace BigRegister.Api.Zgw;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The Notificaties API (NRC) webhook body (WP-52) — the standard ZGW notification shape POSTed
|
||||||
|
/// to a subscribed <c>abonnement</c>'s <c>callbackUrl</c> on every zaak event. Only
|
||||||
|
/// <see cref="HoofdObject"/> (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.
|
||||||
|
/// </summary>
|
||||||
|
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<string, string>? Kenmerken);
|
||||||
@@ -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
|
/// 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
|
/// 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).
|
/// (ZTC). WP-50 (create-zaak) stayed on those two; WP-51 adds the Documenten API (DRC); WP-52
|
||||||
/// BRC/NRC arrive with later slices (WP-52+).
|
/// adds the Notificaties API (NRC) — inbound only, see <see cref="NotificatieAuthorization"/>.
|
||||||
|
/// BRC arrives with a later slice, if ever.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class ZgwOptions
|
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 —
|
/// URL (Catalogi), so create-document (WP-51) knows which type to register per category —
|
||||||
/// the document analogue of <see cref="ZaaktypeUrls"/>.</summary>
|
/// the document analogue of <see cref="ZaaktypeUrls"/>.</summary>
|
||||||
public Dictionary<string, string> InformatieobjecttypeUrls { get; init; } = new();
|
public Dictionary<string, string> InformatieobjecttypeUrls { get; init; } = new();
|
||||||
|
|
||||||
|
/// <summary>Notificaties API (NRC) base URL (WP-52) — documentation/provisioning only, no
|
||||||
|
/// code in this app calls it: subscribing an <c>abonnement</c> is a one-time out-of-band
|
||||||
|
/// step (see <c>openzaak-integration.md</c>), not something the BFF does at runtime.</summary>
|
||||||
|
public string NrcBaseUrl { get; init; } = "";
|
||||||
|
|
||||||
|
/// <summary>The exact <c>Authorization</c> header value NRC must send on every
|
||||||
|
/// <c>POST /zgw/notificaties</c> callback (WP-52) — a plain shared secret set into the
|
||||||
|
/// <c>abonnement</c>'s <c>auth</c> field when provisioning, not a JWT. Empty (the default)
|
||||||
|
/// means every notification is rejected — an unconfigured secret must never mean "accept
|
||||||
|
/// anything".</summary>
|
||||||
|
public string NotificatieAuthorization { get; init; } = "";
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-6
@@ -33,13 +33,15 @@ test('drafter composes → approver sends; admin republishes appearance', async
|
|||||||
await expect(page.getByText('Concept opgeslagen')).toBeVisible({ timeout: 10_000 });
|
await expect(page.getByText('Concept opgeslagen')).toBeVisible({ timeout: 10_000 });
|
||||||
|
|
||||||
// --- Preview: draft is watermarked ---
|
// --- 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([
|
const [draftPreview] = await Promise.all([
|
||||||
page.waitForResponse((r) => r.url().includes('/api/v1/brief/preview'), { timeout: 10_000 }),
|
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: 'Openen als document (PDF)' }).click(),
|
||||||
]);
|
]);
|
||||||
expect(draftPreview.headers()['content-type']).toContain('text/html');
|
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();
|
await page.getByRole('button', { name: 'Sluiten' }).click();
|
||||||
|
|
||||||
// --- Submit → approve → send (role change = full navigation, per WP-33 stickiness) ---
|
// --- 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();
|
await expect(page.getByText('De brief is verzonden.')).toBeVisible();
|
||||||
|
|
||||||
// --- Preview: sent letter serves its frozen, unwatermarked archive ---
|
// --- 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([
|
const [sentPreview] = await Promise.all([
|
||||||
page.waitForResponse((r) => r.url().includes('/api/v1/brief/preview'), { timeout: 10_000 }),
|
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(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 ---
|
// --- Admin republishes the appearance ---
|
||||||
await page.goto('/brief/huisstijl?role=admin');
|
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 ---
|
// --- Drafter's canvas reflects the new appearance on a fresh letter ---
|
||||||
await page.goto('/brief?role=drafter');
|
await page.goto('/brief?role=drafter');
|
||||||
await page.getByRole('button', { name: 'Opnieuw beginnen (demo)' }).click();
|
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 expect(page.locator('dialog')).toContainText(unique);
|
||||||
await page.getByRole('button', { name: 'Sluiten' }).click();
|
await page.getByRole('button', { name: 'Sluiten' }).click();
|
||||||
|
|
||||||
|
|||||||
@@ -2,16 +2,22 @@ import type { Meta, StoryObj } from '@storybook/angular';
|
|||||||
import { applicationConfig } from '@storybook/angular';
|
import { applicationConfig } from '@storybook/angular';
|
||||||
import { provideRouter } from '@angular/router';
|
import { provideRouter } from '@angular/router';
|
||||||
import { AccessStore } from '@shared/application/access.store';
|
import { AccessStore } from '@shared/application/access.store';
|
||||||
|
import { FeatureFlagStore } from '@shared/application/feature-flags.store';
|
||||||
import { ShellComponent } from './shell.component';
|
import { ShellComponent } from './shell.component';
|
||||||
|
|
||||||
const meta: Meta<ShellComponent> = {
|
const meta: Meta<ShellComponent> = {
|
||||||
title: 'Design System/Templates/Shell',
|
title: 'Design System/Templates/Shell',
|
||||||
component: ShellComponent,
|
component: ShellComponent,
|
||||||
// The persistent header injects AccessStore (for its capability-gated admin links);
|
// The persistent header injects AccessStore (for its capability-gated admin links) and
|
||||||
// stub it so the story needs no HTTP/ApiClient. `can` false → no admin links.
|
// 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: [
|
decorators: [
|
||||||
applicationConfig({
|
applicationConfig({
|
||||||
providers: [provideRouter([]), { provide: AccessStore, useValue: { can: () => false } }],
|
providers: [
|
||||||
|
provideRouter([]),
|
||||||
|
{ provide: AccessStore, useValue: { can: () => false } },
|
||||||
|
{ provide: FeatureFlagStore, useValue: { enabled: () => true } },
|
||||||
|
],
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,16 +2,19 @@ import type { Meta, StoryObj } from '@storybook/angular';
|
|||||||
import { applicationConfig } from '@storybook/angular';
|
import { applicationConfig } from '@storybook/angular';
|
||||||
import { provideRouter } from '@angular/router';
|
import { provideRouter } from '@angular/router';
|
||||||
import { AccessStore } from '@shared/application/access.store';
|
import { AccessStore } from '@shared/application/access.store';
|
||||||
|
import { FeatureFlagStore } from '@shared/application/feature-flags.store';
|
||||||
import { Capability } from '@shared/domain/capability';
|
import { Capability } from '@shared/domain/capability';
|
||||||
import { SiteHeaderComponent } from './site-header.component';
|
import { SiteHeaderComponent } from './site-header.component';
|
||||||
|
|
||||||
// The header injects AccessStore for the capability-gated admin links; stub it so the
|
// The header injects AccessStore for the capability-gated admin links and FeatureFlagStore
|
||||||
// story needs no HTTP/ApiClient. `can` decides which admin links appear.
|
// (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[]) =>
|
const withCaps = (caps: Capability[]) =>
|
||||||
applicationConfig({
|
applicationConfig({
|
||||||
providers: [
|
providers: [
|
||||||
provideRouter([]),
|
provideRouter([]),
|
||||||
{ provide: AccessStore, useValue: { can: (c: Capability) => caps.includes(c) } },
|
{ provide: AccessStore, useValue: { can: (c: Capability) => caps.includes(c) } },
|
||||||
|
{ provide: FeatureFlagStore, useValue: { enabled: () => true } },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user