Compare commits

...

9 Commits

Author SHA1 Message Date
b00d8bd60e docs(portal-openbaar): record openbaar decisions + walking-skeleton public-visibility demo (refs #10)
All checks were successful
CI / lint (pull_request) Successful in 1m14s
CI / build (pull_request) Successful in 54s
CI / unit (pull_request) Successful in 1m4s
CI / frontend (pull_request) Successful in 1m55s
CI / mutation (pull_request) Successful in 4m1s
CI / verify-stack (pull_request) Successful in 6m57s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 16:16:30 +02:00
7ec9d514ec test(e2e): assert the submitted registration becomes publicly visible (refs #10)
Extend the walking-skeleton happy path: after the self-service submit + confirmation,
open the anonymous openbaar register portal and poll (reload) until the submitted
INGEDIEND entry appears — completing submit → BFF → domain → projection → public
visibility. Verified the openbaar portal renders the public-safe register (id+status,
no bsn) against a real BFF + stub projection.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 16:15:43 +02:00
b8a88cb531 ci(portal-openbaar): serve the openbaar app in compose (nginx + BFF proxy) (refs #10)
Add the openbaar nginx image (serves the Angular app, reverse-proxies /openbaar to
the BFF, same-origin) and wire it into the compose stack on :8141, anonymous (no
Keycloak dependency). Add it to the health-wait list so the e2e can rely on it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 16:13:03 +02:00
28fd4ca964 feat(portal-openbaar): render the public register with search + empty state (refs #10)
Implement RegisterPage: load the full public register from the BFF on open, filter
by the search term via /openbaar/register?q=, and show a results table (referentie +
status), a loading indicator, and an empty-state message. Anonymous, same-origin
relative calls. Adds the app-shell spec. All openbaar lint/test/build green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 16:11:19 +02:00
2c1734c251 test(portal-openbaar): drive the openbaar register page — list, search, empty state (refs #10)
Scaffolds the anonymous openbaar Angular app (mirrors self-service: standalone +
signals, NL DS via the ui lib, no auth) and adds a failing register-page spec:
loads the public register from the BFF on open, searches by term, and shows an
empty-state message. The stub page renders only the heading, so the list/search/
empty-state assertions fail (red).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 16:09:37 +02:00
1b89b45d21 docs(backlog): split S-09 into portal (#10) and approval flow (#75) (refs #10)
The walking skeleton has only INGEDIEND and no approval/status-transition path, so
the Openbaar portal (demoable against the existing public-safe BFF endpoint) and the
approve→visible flow are separate slices. See #75 for the approval flow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 16:05:16 +02:00
7e8c5d7b51 Merge pull request 'ci: speed up pipeline — NuGet cache + prebuilt Playwright image' (#74) from chore/73-ci-speedups into main
All checks were successful
CI / lint (push) Successful in 1m13s
CI / build (push) Successful in 57s
CI / unit (push) Successful in 1m4s
CI / frontend (push) Successful in 1m46s
CI / mutation (push) Successful in 4m5s
CI / verify-stack (push) Successful in 6m22s
Reviewed-on: #74
2026-07-13 13:59:15 +00:00
2b9eb5eb41 ci(e2e): run Playwright from the prebuilt image instead of downloading browsers (refs #73)
All checks were successful
CI / lint (pull_request) Successful in 5m43s
CI / build (pull_request) Successful in 56s
CI / unit (pull_request) Successful in 1m0s
CI / frontend (pull_request) Successful in 1m50s
CI / mutation (pull_request) Successful in 4m6s
CI / verify-stack (pull_request) Successful in 7m3s
The verify-e2e lane downloaded ~150 MB of Chromium (npx playwright install) on
every verify-stack run. Use the official mcr.microsoft.com/playwright image with
browsers pre-baked; npm install still pins @playwright/test from tests/e2e, and
the image tag is kept in lockstep with that version. Verified the exact
create + docker cp + start flow launches the baked browser with no download.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 15:29:11 +02:00
60df0845aa ci: cache the NuGet package store across the .NET jobs (refs #73)
lint, build, unit and mutation each restored packages from the network on every
run. There are no lock files (so setup-dotnet's built-in cache doesn't apply), so
cache ~/.nuget/packages keyed on the project files via actions/cache. Pinned @v3
to avoid the GHES guard that breaks @v4 on Gitea (gitea-actions-gotchas.md); the
cache is best-effort, so a miss simply restores from the network.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 15:29:11 +02:00
28 changed files with 579 additions and 19 deletions

View File

@@ -23,6 +23,16 @@ jobs:
- uses: https://github.com/actions/setup-dotnet@v4 - uses: https://github.com/actions/setup-dotnet@v4
with: with:
dotnet-version: '10.0.x' dotnet-version: '10.0.x'
# Cache the NuGet package store so each .NET job restores from disk, not the network. There are
# no lock files (so setup-dotnet's built-in cache doesn't apply); key on the project files. @v3
# avoids the GHES guard that breaks @v4 on Gitea (gitea-actions-gotchas.md); cache is best-effort
# — a miss just restores from the network. See issue #73.
- uses: https://github.com/actions/cache@v3
with:
path: ~/.nuget/packages
key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj') }}
restore-keys: |
nuget-${{ runner.os }}-
- run: make lint - run: make lint
build: build:
@@ -32,6 +42,12 @@ jobs:
- uses: https://github.com/actions/setup-dotnet@v4 - uses: https://github.com/actions/setup-dotnet@v4
with: with:
dotnet-version: '10.0.x' dotnet-version: '10.0.x'
- uses: https://github.com/actions/cache@v3
with:
path: ~/.nuget/packages
key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj') }}
restore-keys: |
nuget-${{ runner.os }}-
- run: make build - run: make build
unit: unit:
@@ -41,6 +57,12 @@ jobs:
- uses: https://github.com/actions/setup-dotnet@v4 - uses: https://github.com/actions/setup-dotnet@v4
with: with:
dotnet-version: '10.0.x' dotnet-version: '10.0.x'
- uses: https://github.com/actions/cache@v3
with:
path: ~/.nuget/packages
key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj') }}
restore-keys: |
nuget-${{ runner.os }}-
- run: make unit - run: make unit
# Frontend (Nx/Angular) lane: install with pnpm, then Nx lint + test + build. # Frontend (Nx/Angular) lane: install with pnpm, then Nx lint + test + build.
@@ -64,6 +86,12 @@ jobs:
- uses: https://github.com/actions/setup-dotnet@v4 - uses: https://github.com/actions/setup-dotnet@v4
with: with:
dotnet-version: '10.0.x' dotnet-version: '10.0.x'
- uses: https://github.com/actions/cache@v3
with:
path: ~/.nuget/packages
key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj') }}
restore-keys: |
nuget-${{ runner.os }}-
- run: make mutation - run: make mutation
# Publish the Stryker HTML reports. `if: always()` uploads them even when the # Publish the Stryker HTML reports. `if: always()` uploads them even when the
# ratchet fails — that is exactly when you want to inspect the survivors. # ratchet fails — that is exactly when you want to inspect the survivors.

View File

@@ -162,20 +162,36 @@ The skeleton proves the spine end-to-end: a registration, a workflow, a zaak in
**Out of scope (whole of S-08):** document upload, status tracking page. **Out of scope (whole of S-08):** document upload, status tracking page.
### S-09 · Openbaar Register portal — public lookup ### S-09 · Openbaar Register portal — public lookup *(#10)*
**Outcome:** The openbaar Angular app shows a search box. Anonymous. Queries the BFF's `/openbaar/register` which reads only the projection's **public-safe** fields. Confirms the walking skeleton end-to-end. **Outcome:** The openbaar Angular app shows a search box. Anonymous. Queries the BFF's `/openbaar/register` which reads only the projection's **public-safe** fields. Shows the public-visibility half of the walking skeleton.
_Split from the original S-09 — scoped to the portal only; the approval flow is **S-09b (#75)**._
**Acceptance:** **Acceptance:**
- E2E test: zorgprofessional registers via self-service (S-08), behandelaar approves via a temporary admin endpoint (no behandel-portal yet), openbaar register shows the entry. - E2E test: after a zorgprofessional registers via self-service (S-08), the openbaar register shows the entry (as `INGEDIEND`).
- Public-safe field whitelist enforced and tested. - Public-safe field whitelist enforced and tested (already in the BFF; add a portal component test + a11y check).
**Touches:** `apps/openbaar/`, projection-api hardening, tests. **Touches:** `apps/openbaar/`, compose serving, e2e, docs.
**Out of scope:** advanced search filters, sorting. **Out of scope:** approval/status transition (S-09b), advanced search filters, sorting.
**End of walking skeleton.** Demo: submit → process → projection → public visibility. All CI gates green on Gitea Actions. Cut release `vYYYY.MM.0` and publish via Gitea Releases. ### S-09b · Approval flow — temp admin endpoint + status transition to projection *(#75)*
**Outcome:** A behandelaar approves a submitted registration via a temporary admin endpoint (no behandel-portal yet — S-12). The approval transitions the zaak status through the ACL → NRC → event-subscriber → projection, and the openbaar register then shows the entry as approved.
**Acceptance:**
- A new terminal/approved status (e.g. `INGESCHREVEN`) exists and is projected.
- Temporary admin approve endpoint transitions a registration via a real ZGW status set (behind the ACL, §8).
- E2E: register (S-08) → approve → openbaar shows the entry as approved.
**Touches:** `services/domain`, `services/acl`, `services/event-subscriber`, `services/projection-api`, e2e.
**Out of scope:** behandel-portal UI (S-12), assessment logic (S-13), escalation (S-15).
**End of walking skeleton** (S-09 + S-09b). Demo: submit → process → projection → public visibility. All CI gates green on Gitea Actions. Cut release `vYYYY.MM.0` and publish via Gitea Releases.
--- ---

View File

@@ -10,7 +10,7 @@ COMPOSE := infra/docker-compose.yml
# Long-running services with a healthcheck — the smoke polls these for readiness # Long-running services with a healthcheck — the smoke polls these for readiness
# (infra/wait-healthy.sh). One-shot init jobs (oz-init, nrc-init, flowable-init) # (infra/wait-healthy.sh). One-shot init jobs (oz-init, nrc-init, flowable-init)
# are not polled; they only need to have run. See docs/runbooks/gitea-actions-gotchas.md. # are not polled; they only need to have run. See docs/runbooks/gitea-actions-gotchas.md.
WAIT_SVCS := openzaak nrc-web acl bff domain event-subscriber projection-api self-service WAIT_SVCS := openzaak nrc-web acl bff domain event-subscriber projection-api self-service openbaar
# Config files (OpenZaak data.yaml, Keycloak realms, Flowable BPMN) are streamed # Config files (OpenZaak data.yaml, Keycloak realms, Flowable BPMN) are streamed
# into external named volumes via `docker cp` (infra/seed-config.sh) instead of # into external named volumes via `docker cp` (infra/seed-config.sh) instead of
# bind-mounted, because bind mounts don't reach sibling containers on the # bind-mounted, because bind mounts don't reach sibling containers on the

21
apps/openbaar/Dockerfile Normal file
View File

@@ -0,0 +1,21 @@
# Multi-stage build for the openbaar portal (Angular → nginx).
# Build context is the repo root (the app needs the pnpm workspace + libs). See infra/docker-compose.yml.
FROM node:24-slim AS build
WORKDIR /src
RUN corepack enable && corepack prepare pnpm@11.5.2 --activate
# Restore first (cached unless the manifests change).
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml nx.json tsconfig.base.json eslint.config.mjs ./
RUN pnpm install --frozen-lockfile
# Sources (only what the app + its libs need).
COPY apps/openbaar apps/openbaar
COPY libs libs
RUN pnpm nx build openbaar
FROM nginx:1.27-alpine AS runtime
COPY apps/openbaar/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /src/dist/apps/openbaar/browser /usr/share/nginx/html
# No runtime config: the openbaar register is anonymous (no OIDC authority to inject).
EXPOSE 80

View File

@@ -0,0 +1,34 @@
import nx from '@nx/eslint-plugin';
import baseConfig from '../../eslint.config.mjs';
export default [
...nx.configs['flat/angular'],
...nx.configs['flat/angular-template'],
...baseConfig,
{
files: ['**/*.ts'],
rules: {
'@angular-eslint/directive-selector': [
'error',
{
type: 'attribute',
prefix: 'app',
style: 'camelCase',
},
],
'@angular-eslint/component-selector': [
'error',
{
type: 'element',
prefix: 'app',
style: 'kebab-case',
},
],
},
},
{
files: ['**/*.html'],
// Override or add rules here
rules: {},
},
];

23
apps/openbaar/nginx.conf Normal file
View File

@@ -0,0 +1,23 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Resolve the BFF via Docker's embedded DNS at request time (variable proxy_pass), so nginx starts
# even before the BFF is up and picks up restarts — instead of failing to load the config.
resolver 127.0.0.11 ipv6=off valid=30s;
# Same-origin API: proxy the anonymous openbaar endpoint group to the bff service. The api-client
# uses relative URLs, so the browser calls this origin and nginx forwards to the BFF — no CORS.
location /openbaar/ {
set $bff http://bff:8080;
proxy_pass $bff;
proxy_set_header Host $host;
}
# SPA fallback — Angular client-side routing.
location / {
try_files $uri $uri/ /index.html;
}
}

View File

@@ -0,0 +1,80 @@
{
"name": "openbaar",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"projectType": "application",
"prefix": "app",
"sourceRoot": "apps/openbaar/src",
"tags": [],
"targets": {
"build": {
"executor": "@angular/build:application",
"outputs": ["{options.outputPath}"],
"defaultConfiguration": "production",
"options": {
"outputPath": "dist/apps/openbaar",
"browser": "apps/openbaar/src/main.ts",
"tsConfig": "apps/openbaar/tsconfig.app.json",
"assets": [
{
"glob": "**/*",
"input": "apps/openbaar/public"
}
],
"styles": ["apps/openbaar/src/styles.css"]
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "1mb",
"maximumError": "2mb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kb",
"maximumError": "8kb"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
}
},
"serve": {
"continuous": true,
"executor": "@angular/build:dev-server",
"defaultConfiguration": "development",
"configurations": {
"production": {
"buildTarget": "openbaar:build:production"
},
"development": {
"buildTarget": "openbaar:build:development"
}
}
},
"lint": {
"executor": "@nx/eslint:lint"
},
"test": {
"executor": "@angular/build:unit-test",
"options": {
"watch": false
}
},
"serve-static": {
"continuous": true,
"executor": "@nx/web:file-server",
"options": {
"buildTarget": "openbaar:build",
"staticFilePath": "dist/apps/openbaar/browser",
"spa": true
}
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1,19 @@
import { provideHttpClient } from '@angular/common/http';
import {
ApplicationConfig,
provideBrowserGlobalErrorListeners,
} from '@angular/core';
import { provideRouter } from '@angular/router';
import { appRoutes } from './app.routes';
/**
* The openbaar register is a public, anonymous read: no DigiD, no auth interceptor. The app is served
* same-origin as the BFF (nginx proxies /openbaar), so the api-client's relative calls stay same-origin.
*/
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(appRoutes),
provideHttpClient(),
],
};

View File

View File

@@ -0,0 +1 @@
<router-outlet></router-outlet>

View File

@@ -0,0 +1,4 @@
import { Route } from '@angular/router';
import { RegisterPage } from './register/register-page';
export const appRoutes: Route[] = [{ path: '', component: RegisterPage }];

View File

@@ -0,0 +1,14 @@
import { provideRouter } from '@angular/router';
import { render } from '@testing-library/angular';
import { App } from './app';
describe('App', () => {
it('renders the router outlet shell', async () => {
const { container } = await render(App, {
providers: [provideRouter([])],
});
// The shell is a thin host for routed pages (the RegisterPage owns the heading).
expect(container.querySelector('router-outlet')).toBeTruthy();
});
});

View File

@@ -0,0 +1,12 @@
import { Component } from '@angular/core';
import { RouterModule } from '@angular/router';
@Component({
imports: [RouterModule],
selector: 'app-root',
templateUrl: './app.html',
styleUrl: './app.css',
})
export class App {
protected title = 'openbaar';
}

View File

@@ -0,0 +1,54 @@
<main utrecht-document class="utrecht-theme">
<utrecht-article>
<utrecht-heading-1>Openbaar BIG-register</utrecht-heading-1>
<p utrecht-paragraph>
Zoek in het openbare register van BIG-registraties. Alleen publieke gegevens worden getoond.
</p>
<div role="search">
<label for="register-search" utrecht-form-label>Zoek op referentie</label>
<input
id="register-search"
type="search"
utrecht-textbox
[ngModel]="query()"
(ngModelChange)="query.set($event)"
[ngModelOptions]="{ standalone: true }"
(keyup.enter)="search()"
/>
<button
utrecht-button
appearance="primary-action-button"
type="button"
[disabled]="loading()"
(click)="search()"
>
Zoeken
</button>
</div>
@if (loading()) {
<p utrecht-paragraph role="status">Bezig met laden…</p>
} @else if (searched() && entries().length === 0) {
<p utrecht-paragraph role="status">Geen inschrijvingen gevonden.</p>
} @else if (entries().length > 0) {
<table utrecht-table>
<caption>Inschrijvingen in het openbaar register</caption>
<thead>
<tr>
<th scope="col">Referentie</th>
<th scope="col">Status</th>
</tr>
</thead>
<tbody>
@for (entry of entries(); track entry.id) {
<tr>
<td>{{ entry.id }}</td>
<td>{{ entry.status }}</td>
</tr>
}
</tbody>
</table>
}
</utrecht-article>
</main>

View File

@@ -0,0 +1,57 @@
import { fireEvent, render, screen } from '@testing-library/angular';
import { of } from 'rxjs';
import { BffApiV1Service, type OpenbaarEntry } from 'api-client';
import { axe } from 'vitest-axe';
import { RegisterPage } from './register-page';
const sample: OpenbaarEntry[] = [
{ id: 'zaak-abc', status: 'INGEDIEND' },
{ id: 'zaak-def', status: 'INGESCHREVEN' },
];
function providers(get = vi.fn().mockReturnValue(of(sample))) {
return {
get,
providers: [{ provide: BffApiV1Service, useValue: { getOpenbaarRegister: get } }],
};
}
describe('RegisterPage', () => {
it('lists the public register entries from the BFF on open', async () => {
const { get } = providers();
await render(RegisterPage, { providers: providers(get).providers });
expect(get).toHaveBeenCalled();
expect(await screen.findByText(/zaak-abc/)).toBeTruthy();
expect(screen.getByText(/INGEDIEND/)).toBeTruthy();
expect(screen.getByText(/zaak-def/)).toBeTruthy();
});
it('searches by the entered term', async () => {
const get = vi.fn().mockReturnValue(of(sample));
await render(RegisterPage, { providers: providers(get).providers });
fireEvent.input(screen.getByRole('searchbox'), { target: { value: 'zaak-abc' } });
fireEvent.click(screen.getByRole('button', { name: /zoek/i }));
expect(get).toHaveBeenLastCalledWith({ q: 'zaak-abc' });
});
it('shows an empty-state message when the register has no matches', async () => {
const get = vi.fn().mockReturnValue(of([] as OpenbaarEntry[]));
await render(RegisterPage, { providers: providers(get).providers });
expect(await screen.findByText(/geen inschrijvingen gevonden/i)).toBeTruthy();
});
it('has no WCAG 2.1 AA violations', async () => {
document.documentElement.lang = 'nl';
const { container } = await render(RegisterPage, { providers: providers().providers });
const results = await axe(container, {
runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'] },
});
expect(results.violations).toEqual([]);
});
});

View File

@@ -0,0 +1,45 @@
import { Component, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { BffApiV1Service, type OpenbaarEntry } from 'api-client';
import { UtrechtComponentsModule } from 'ui';
/**
* The openbaar (public) BIG-register: an anonymous search over the read projection's public-safe
* view (id + status only — bsn/naam never leave the BFF; ADR-0010). Loads the full register on open
* and filters by the search term via the BFF's `/openbaar/register?q=` endpoint (S-09).
*/
@Component({
selector: 'app-register-page',
imports: [FormsModule, UtrechtComponentsModule],
templateUrl: './register-page.html',
})
export class RegisterPage {
private readonly bff = inject(BffApiV1Service);
protected readonly query = signal('');
protected readonly entries = signal<OpenbaarEntry[]>([]);
protected readonly loading = signal(false);
protected readonly searched = signal(false);
constructor() {
// Show the full register on open; the search box narrows it.
this.search();
}
search(): void {
const q = this.query().trim();
this.loading.set(true);
this.bff.getOpenbaarRegister(q ? { q } : {}).subscribe({
next: (rows: OpenbaarEntry[]) => {
this.entries.set(rows);
this.loading.set(false);
this.searched.set(true);
},
error: () => {
this.entries.set([]);
this.loading.set(false);
this.searched.set(true);
},
});
}
}

View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="nl">
<head>
<meta charset="utf-8" />
<title>Openbaar BIG-register</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="favicon.ico" />
</head>
<body>
<app-root></app-root>
</body>
</html>

View File

@@ -0,0 +1,6 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { App } from './app/app';
import { appConfig } from './app/app.config';
// The openbaar register is anonymous (no DigiD, no runtime config) — bootstrap directly.
bootstrapApplication(App, appConfig).catch((err) => console.error(err));

View File

@@ -0,0 +1,2 @@
/* NL Design System theme — Utrecht design tokens (docs/frontend-decisions.md). */
@import '@utrecht/design-tokens/dist/index.css';

View File

@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"types": []
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
}

View File

@@ -0,0 +1,31 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"isolatedModules": true,
"target": "es2022",
"moduleResolution": "bundler",
"emitDecoratorMetadata": false,
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}

View File

@@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"types": ["vitest/globals"]
},
"include": ["src/**/*.ts", "src/**/*.d.ts"]
}

View File

@@ -168,3 +168,32 @@ curl -fsS http://localhost:8120/register | jq 'length' # → unchanged
> `bsn` / `naam_placeholder` are deferred (ADR-0008) — the notification doesn't carry them and > `bsn` / `naam_placeholder` are deferred (ADR-0008) — the notification doesn't carry them and
> the subscriber may not read OpenZaak directly (§8.1). They surface in a later slice. > the subscriber may not read OpenZaak directly (§8.1). They surface in a later slice.
---
## S-09 — Openbaar Register portal (public visibility)
**Outcome:** the entry a zorgprofessional submits via self-service becomes publicly visible in the
anonymous openbaar register portal — closing the walking-skeleton loop (submit → process → projection
→ public visibility).
**The path:** self-service submit → BFF → domain → (zaak) OpenZaak → NRC → Event Subscriber →
projection → openbaar portal reads the BFF's public-safe `GET /openbaar/register`.
```bash
# 1. Bring the full stack up (self-service :8140, openbaar :8141).
make up
# 2. Submit a registration via the self-service portal (mock DigiD: jan-burger / test123),
# or drive the whole happy path automatically (login → submit → public visibility):
make verify-e2e
# 3. Open the public register — no login. It lists the submitted entry (id + status only).
# Only public-safe fields cross the BFF: bsn / naam never appear.
open http://localhost:8141/ # search box; searches the BFF by referentie
curl -fsS http://localhost:8140/openbaar/register | jq # same public-safe view via the BFF proxy
# → [ { "id": "<zaak-uuid>", "status": "INGEDIEND" } ]
```
> The register shows `INGEDIEND` entries today; the approval transition to a terminal status
> (e.g. `INGESCHREVEN`) lands with the approval flow (S-09b, #75).

View File

@@ -85,12 +85,14 @@ with the submit form (S-08c, #67); any deviation from NL DS will be recorded her
- **Runtime config.** The app fetches `/config.json` before bootstrap (`main.ts`); `appConfig` is a - **Runtime config.** The app fetches `/config.json` before bootstrap (`main.ts`); `appConfig` is a
factory. The dev default (`public/config.json`) points at `localhost:8180`; the Docker image bakes factory. The dev default (`public/config.json`) points at `localhost:8180`; the Docker image bakes
the compose value (`keycloak:8080`). One build, per-environment OIDC authority. the compose value (`keycloak:8080`). One build, per-environment OIDC authority.
- **e2e runs inside the compose network.** `infra/run-e2e-check.sh` runs Playwright in a `node` - **e2e runs inside the compose network.** `infra/run-e2e-check.sh` runs Playwright in a container on
container on `cg`, so the browser reaches Keycloak as `keycloak:8080` — the **same issuer** the BFF `cg`, so the browser reaches Keycloak as `keycloak:8080` — the **same issuer** the BFF validates
validates against (resolves the browser-vs-container mismatch, ADR-0010). Chromium is installed at against (resolves the browser-vs-container mismatch, ADR-0010). It uses the official
runtime, so there's no Playwright-image-version pinning to keep in sync. The spec is copied in `mcr.microsoft.com/playwright:<version>` image with browsers pre-baked, rather than downloading
(`docker cp`), not mounted, so it leaves nothing root-owned on the host. Wired as `verify-e2e` in ~150 MB of Chromium on every run (issue #73) — the image tag is kept in lockstep with
the `verify-stack` CI job. `tests/e2e/package.json`'s `@playwright/test` version. The spec is copied in (`docker cp`), not
mounted, so it leaves nothing root-owned on the host. Wired as `verify-e2e` in the `verify-stack`
CI job.
- **e2e treats the portal origin as secure.** In-network the portal is served over plain HTTP on a - **e2e treats the portal origin as secure.** In-network the portal is served over plain HTTP on a
non-localhost origin (`http://self-service`), which is **not a secure context**, so Web Crypto non-localhost origin (`http://self-service`), which is **not a secure context**, so Web Crypto
(`crypto.subtle`) is unavailable. angular-auth-oidc-client needs it for the PKCE code challenge, so (`crypto.subtle`) is unavailable. angular-auth-oidc-client needs it for the PKCE code challenge, so
@@ -101,3 +103,17 @@ with the submit form (S-08c, #67); any deviation from NL DS will be recorded her
touching the app or its production config. touching the app or its production config.
- `tests/e2e` is a standalone Playwright project (its own `package.json`), not an Nx project — it's a - `tests/e2e` is a standalone Playwright project (its own `package.json`), not an Nx project — it's a
live-stack check like the other `verify-*` runners, not part of the `frontend` unit lane. live-stack check like the other `verify-*` runners, not part of the `frontend` unit lane.
## Openbaar Register portal (S-09, #10)
- **Anonymous, no auth.** The openbaar register is a public read, so `apps/openbaar` has no
`angular-auth-oidc-client`, no interceptor, and no `config.json` — `main.ts` bootstraps `appConfig`
directly with just `provideHttpClient` + `provideRouter`. This is the deliberate contrast to
self-service and keeps the app trivially cacheable/CDN-able.
- **Same-origin via nginx, like self-service.** The compose `openbaar` image serves the built app and
reverse-proxies `/openbaar` to the BFF; the api-client's relative calls stay same-origin (no CORS).
Served on `:8141`, health-checked over IPv4 (`127.0.0.1`), no Keycloak dependency.
- **Public-safe by construction.** The portal only ever sees the BFF's `OpenbaarProjection.PublicView`
(id + status); `bsn`/`naam` never leave the BFF. The e2e asserts the bsn never renders.
- **Loads on open, filters on search.** `RegisterPage` fetches the full register on construction and
re-queries `/openbaar/register?q=` on search — no client-side filtering, the BFF owns the query.

View File

@@ -458,6 +458,27 @@ services:
condition: service_started condition: service_started
networks: [cg] networks: [cg]
# The openbaar (public) register portal: nginx serves the Angular app and reverse-proxies
# /openbaar to the BFF. Anonymous — no DigiD, no Keycloak dependency (S-09).
openbaar:
build:
context: ..
dockerfile: apps/openbaar/Dockerfile
image: register-referentie/openbaar:dev
ports:
- "8141:80"
healthcheck:
# 127.0.0.1, not localhost: nginx listens on IPv4 only, but localhost resolves to ::1 first.
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/ || exit 1"]
interval: 5s
timeout: 3s
retries: 5
start_period: 10s
depends_on:
bff:
condition: service_healthy
networks: [cg]
volumes: volumes:
oz-db: oz-db:
nrc-db: nrc-db:

View File

@@ -3,10 +3,14 @@
# Walking-skeleton e2e (S-08d) against an ALREADY-RUNNING full stack: drive the self-service portal # Walking-skeleton e2e (S-08d) against an ALREADY-RUNNING full stack: drive the self-service portal
# in a real browser through mock-DigiD login → submit → confirmation (login → BFF → domain). # in a real browser through mock-DigiD login → submit → confirmation (login → BFF → domain).
# #
# Runs Playwright INSIDE the compose network (a node container on `cg`), so the browser reaches # Runs Playwright INSIDE the compose network (a container on `cg`), so the browser reaches
# Keycloak by service name (keycloak:8080) — the same authority the BFF validates against, so the # Keycloak by service name (keycloak:8080) — the same authority the BFF validates against, so the
# token issuer matches (ADR-0010). The spec is copied into the container (docker cp), not mounted, # token issuer matches (ADR-0010). The spec is copied into the container (docker cp), not mounted,
# so it leaves no root-owned files on the host. The caller owns stack bring-up + teardown. # so it leaves no root-owned files on the host. The caller owns stack bring-up + teardown.
#
# Uses the official Playwright image with browsers pre-baked, instead of downloading ~150 MB of
# Chromium on every run (issue #73). The image tag MUST match tests/e2e/package.json's
# @playwright/test version — bump both together.
set -euo pipefail set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
@@ -19,7 +23,7 @@ echo ">> running Playwright e2e on network $net against http://self-service"
cid="$(docker create --network "$net" -w /e2e --ipc=host \ cid="$(docker create --network "$net" -w /e2e --ipc=host \
-e SELF_SERVICE_URL=http://self-service \ -e SELF_SERVICE_URL=http://self-service \
node:24 sh -c 'npm install --no-audit --no-fund && npx playwright install --with-deps chromium && npx playwright test')" mcr.microsoft.com/playwright:v1.61.1-noble sh -c 'npm install --no-audit --no-fund && npx playwright test')"
trap 'docker rm -f "$cid" >/dev/null 2>&1 || true' EXIT trap 'docker rm -f "$cid" >/dev/null 2>&1 || true' EXIT
docker cp "$root/tests/e2e/." "$cid:/e2e" >/dev/null docker cp "$root/tests/e2e/." "$cid:/e2e" >/dev/null
docker start -a "$cid" docker start -a "$cid"

View File

@@ -1,8 +1,9 @@
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
// Walking-skeleton happy path (S-08d): a zorgprofessional logs in via mock DigiD and submits a // Walking-skeleton happy path (S-08d + S-09): a zorgprofessional logs in via mock DigiD and submits a
// registration through the self-service portal → BFF → domain. The confirmation shows the reference. // registration through the self-service portal → BFF → domain; the projection is updated via NRC → the
test('DigiD login → submit → confirmation', async ({ page }) => { // event-subscriber, and the entry becomes publicly visible in the openbaar register portal.
test('DigiD login → submit → confirmation → public visibility', async ({ page }) => {
// Visiting the guarded page redirects to the Keycloak (mock DigiD) login. // Visiting the guarded page redirects to the Keycloak (mock DigiD) login.
await page.goto('/'); await page.goto('/');
@@ -18,4 +19,16 @@ test('DigiD login → submit → confirmation', async ({ page }) => {
// The BFF accepted it and the page shows the confirmation. // The BFF accepted it and the page shows the confirmation.
await expect(page.getByText(/ontvangen/i)).toBeVisible(); await expect(page.getByText(/ontvangen/i)).toBeVisible();
// The openbaar register (anonymous, its own origin) shows the submitted entry once the projection
// catches up. The projection updates asynchronously (NRC → event-subscriber), and the register loads
// on open, so reload until a submitted (INGEDIEND) row appears.
await page.goto('http://openbaar/');
await expect(page.getByRole('heading', { name: /Openbaar BIG-register/i })).toBeVisible();
await expect
.poll(async () => {
await page.reload();
return page.getByRole('cell', { name: 'INGEDIEND' }).count();
}, { timeout: 30_000, intervals: [1_000, 2_000, 3_000, 5_000] })
.toBeGreaterThan(0);
}); });