Compare commits
13 Commits
0e6c7d2066
...
feat/10-op
| Author | SHA1 | Date | |
|---|---|---|---|
| b00d8bd60e | |||
| 7ec9d514ec | |||
| b8a88cb531 | |||
| 28fd4ca964 | |||
| 2c1734c251 | |||
| 1b89b45d21 | |||
| 7e8c5d7b51 | |||
| 2b9eb5eb41 | |||
| 60df0845aa | |||
| 2a746736dc | |||
| 986e36bc7d | |||
| 7e152e4432 | |||
| 5bf25f094d |
@@ -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.
|
||||||
|
|||||||
30
BACKLOG.md
30
BACKLOG.md
@@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
2
Makefile
2
Makefile
@@ -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
21
apps/openbaar/Dockerfile
Normal 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
|
||||||
34
apps/openbaar/eslint.config.mjs
Normal file
34
apps/openbaar/eslint.config.mjs
Normal 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
23
apps/openbaar/nginx.conf
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
80
apps/openbaar/project.json
Normal file
80
apps/openbaar/project.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
apps/openbaar/public/favicon.ico
Normal file
BIN
apps/openbaar/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
19
apps/openbaar/src/app/app.config.ts
Normal file
19
apps/openbaar/src/app/app.config.ts
Normal 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(),
|
||||||
|
],
|
||||||
|
};
|
||||||
0
apps/openbaar/src/app/app.css
Normal file
0
apps/openbaar/src/app/app.css
Normal file
1
apps/openbaar/src/app/app.html
Normal file
1
apps/openbaar/src/app/app.html
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<router-outlet></router-outlet>
|
||||||
4
apps/openbaar/src/app/app.routes.ts
Normal file
4
apps/openbaar/src/app/app.routes.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { Route } from '@angular/router';
|
||||||
|
import { RegisterPage } from './register/register-page';
|
||||||
|
|
||||||
|
export const appRoutes: Route[] = [{ path: '', component: RegisterPage }];
|
||||||
14
apps/openbaar/src/app/app.spec.ts
Normal file
14
apps/openbaar/src/app/app.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
12
apps/openbaar/src/app/app.ts
Normal file
12
apps/openbaar/src/app/app.ts
Normal 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';
|
||||||
|
}
|
||||||
54
apps/openbaar/src/app/register/register-page.html
Normal file
54
apps/openbaar/src/app/register/register-page.html
Normal 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>
|
||||||
57
apps/openbaar/src/app/register/register-page.spec.ts
Normal file
57
apps/openbaar/src/app/register/register-page.spec.ts
Normal 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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
45
apps/openbaar/src/app/register/register-page.ts
Normal file
45
apps/openbaar/src/app/register/register-page.ts
Normal 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);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
13
apps/openbaar/src/index.html
Normal file
13
apps/openbaar/src/index.html
Normal 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>
|
||||||
6
apps/openbaar/src/main.ts
Normal file
6
apps/openbaar/src/main.ts
Normal 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));
|
||||||
2
apps/openbaar/src/styles.css
Normal file
2
apps/openbaar/src/styles.css
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
/* NL Design System theme — Utrecht design tokens (docs/frontend-decisions.md). */
|
||||||
|
@import '@utrecht/design-tokens/dist/index.css';
|
||||||
9
apps/openbaar/tsconfig.app.json
Normal file
9
apps/openbaar/tsconfig.app.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "../../dist/out-tsc",
|
||||||
|
"types": []
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"],
|
||||||
|
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
|
||||||
|
}
|
||||||
31
apps/openbaar/tsconfig.json
Normal file
31
apps/openbaar/tsconfig.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
8
apps/openbaar/tsconfig.spec.json
Normal file
8
apps/openbaar/tsconfig.spec.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "../../dist/out-tsc",
|
||||||
|
"types": ["vitest/globals"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.d.ts"]
|
||||||
|
}
|
||||||
65
apps/self-service/src/app/app.config.spec.ts
Normal file
65
apps/self-service/src/app/app.config.spec.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||||
|
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { BffApiV1Service } from 'api-client';
|
||||||
|
import { authInterceptor } from 'auth';
|
||||||
|
import { AbstractSecurityStorage, ConfigurationService } from 'angular-auth-oidc-client';
|
||||||
|
import { SECURE_API_ROUTES } from './app.config';
|
||||||
|
|
||||||
|
// Guards the DigiD token wiring end-to-end. The api-client calls the BFF with RELATIVE URLs, and the
|
||||||
|
// angular-auth-oidc-client interceptor attaches the token only when `req.url` starts with a configured
|
||||||
|
// secureRoute. A regression to an absolute origin (as once shipped) makes the relative URL never match,
|
||||||
|
// so the submit goes out unauthenticated and fails silently. This drives the REAL interceptor and the
|
||||||
|
// REAL api-client against the REAL production route value (SECURE_API_ROUTES); only the config source
|
||||||
|
// and the token storage are faked, so the assertion turns on the actual route-matching.
|
||||||
|
describe('self-service DigiD token wiring', () => {
|
||||||
|
let http: HttpTestingController;
|
||||||
|
let bff: BffApiV1Service;
|
||||||
|
const token = 'digid-access-token';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [
|
||||||
|
provideHttpClient(withInterceptors([authInterceptor()])),
|
||||||
|
provideHttpClientTesting(),
|
||||||
|
{
|
||||||
|
provide: ConfigurationService,
|
||||||
|
useValue: {
|
||||||
|
hasAtLeastOneConfig: () => true,
|
||||||
|
getAllConfigurations: () => [{ configId: 'digid', secureRoutes: SECURE_API_ROUTES }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// A signed-in session: the storage the interceptor's token lookup reads from.
|
||||||
|
provide: AbstractSecurityStorage,
|
||||||
|
useValue: {
|
||||||
|
read: () => JSON.stringify({ authzData: token, authnResult: { id_token: 'id-token' } }),
|
||||||
|
write: () => undefined,
|
||||||
|
remove: () => undefined,
|
||||||
|
clear: () => undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
http = TestBed.inject(HttpTestingController);
|
||||||
|
bff = TestBed.inject(BffApiV1Service);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => http.verify());
|
||||||
|
|
||||||
|
it('attaches the bearer token to the relative self-service BFF call', () => {
|
||||||
|
bff.postSelfServiceRegistrations().subscribe();
|
||||||
|
|
||||||
|
const req = http.expectOne('/self-service/registrations');
|
||||||
|
expect(req.request.headers.get('Authorization')).toBe(`Bearer ${token}`);
|
||||||
|
req.flush({ registrationId: 'reg-1', status: 'Ingediend' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves the anonymous openbaar register call unauthenticated', () => {
|
||||||
|
bff.getOpenbaarRegister().subscribe();
|
||||||
|
|
||||||
|
const req = http.expectOne((r) => r.url === '/openbaar/register');
|
||||||
|
expect(req.request.headers.has('Authorization')).toBe(false);
|
||||||
|
req.flush([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -13,12 +13,17 @@ export interface RuntimeConfig {
|
|||||||
authority: string;
|
authority: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Route prefixes whose requests carry the DigiD token. These MUST match the **relative** URLs the
|
||||||
|
* api-client actually calls (same-origin via the nginx proxy) — the interceptor matches on `req.url`,
|
||||||
|
* which stays relative, so an absolute origin would never match and the token would go unattached.
|
||||||
|
* `/openbaar/` is deliberately excluded: it is the anonymous public register.
|
||||||
|
*/
|
||||||
|
export const SECURE_API_ROUTES = ['/self-service/'];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the app providers from runtime config. `redirectUrl` is the app's own origin (where Keycloak
|
* Build the app providers from runtime config. `redirectUrl` is the app's own origin (where Keycloak
|
||||||
* redirects back). The app is served same-origin as the BFF (nginx proxies /self-service + /openbaar),
|
* redirects back). `secureRoutes` uses {@link SECURE_API_ROUTES} — relative prefixes, not the origin.
|
||||||
* so the api-client uses **relative** URLs — hence `secureRoutes` is the relative `/self-service/`
|
|
||||||
* prefix (the guarded BFF route), not the origin: the interceptor matches on `req.url`, which stays
|
|
||||||
* relative, so an origin would never match and the token would not be attached.
|
|
||||||
*/
|
*/
|
||||||
export function appConfig(runtime: RuntimeConfig): ApplicationConfig {
|
export function appConfig(runtime: RuntimeConfig): ApplicationConfig {
|
||||||
const origin = typeof window !== 'undefined' ? window.location.origin : '/';
|
const origin = typeof window !== 'undefined' ? window.location.origin : '/';
|
||||||
@@ -30,7 +35,7 @@ export function appConfig(runtime: RuntimeConfig): ApplicationConfig {
|
|||||||
provideDigiadAuth({
|
provideDigiadAuth({
|
||||||
authority: runtime.authority,
|
authority: runtime.authority,
|
||||||
redirectUrl: origin,
|
redirectUrl: origin,
|
||||||
secureRoutes: ['/self-service/'],
|
secureRoutes: SECURE_API_ROUTES,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,6 +8,11 @@
|
|||||||
</p>
|
</p>
|
||||||
} @else {
|
} @else {
|
||||||
<p utrecht-paragraph>U bent ingelogd met BSN {{ bsn() }}.</p>
|
<p utrecht-paragraph>U bent ingelogd met BSN {{ bsn() }}.</p>
|
||||||
|
@if (failed()) {
|
||||||
|
<p utrecht-paragraph role="alert">
|
||||||
|
Er ging iets mis bij het indienen van uw registratie. Probeer het opnieuw.
|
||||||
|
</p>
|
||||||
|
}
|
||||||
<button
|
<button
|
||||||
utrecht-button
|
utrecht-button
|
||||||
appearance="primary-action-button"
|
appearance="primary-action-button"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { signal } from '@angular/core';
|
import { signal } from '@angular/core';
|
||||||
import { fireEvent, render, screen } from '@testing-library/angular';
|
import { fireEvent, render, screen } from '@testing-library/angular';
|
||||||
import { of } from 'rxjs';
|
import { of, throwError } from 'rxjs';
|
||||||
import { AuthService } from 'auth';
|
import { AuthService } from 'auth';
|
||||||
import { BffApiV1Service } from 'api-client';
|
import { BffApiV1Service } from 'api-client';
|
||||||
import { axe } from 'vitest-axe';
|
import { axe } from 'vitest-axe';
|
||||||
@@ -43,6 +43,19 @@ describe('RegistrationPage', () => {
|
|||||||
expect(await screen.findByText(/ontvangen/i)).toBeTruthy();
|
expect(await screen.findByText(/ontvangen/i)).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows an error and keeps the submit available when the BFF call fails', async () => {
|
||||||
|
const { post, providers: p } = providers(vi.fn().mockReturnValue(throwError(() => new Error('BFF rejected'))));
|
||||||
|
await render(RegistrationPage, { providers: p });
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /indienen/i }));
|
||||||
|
|
||||||
|
expect(post).toHaveBeenCalledTimes(1);
|
||||||
|
// The failure is surfaced (not swallowed), the confirmation is not shown, and the user can retry.
|
||||||
|
expect(await screen.findByRole('alert')).toBeTruthy();
|
||||||
|
expect(screen.queryByText(/ontvangen/i)).toBeNull();
|
||||||
|
expect(screen.getByRole('button', { name: /indienen/i })).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
it('has no WCAG 2.1 AA violations on the submit page', async () => {
|
it('has no WCAG 2.1 AA violations on the submit page', async () => {
|
||||||
// The portal is Dutch; the real index.html sets lang. Set it here so the document-level
|
// The portal is Dutch; the real index.html sets lang. Set it here so the document-level
|
||||||
// html-has-lang rule reflects the app, not the bare jsdom document.
|
// html-has-lang rule reflects the app, not the bare jsdom document.
|
||||||
|
|||||||
@@ -21,13 +21,22 @@ export class RegistrationPage {
|
|||||||
protected readonly submitting = signal(false);
|
protected readonly submitting = signal(false);
|
||||||
protected readonly reference = signal<string | undefined>(undefined);
|
protected readonly reference = signal<string | undefined>(undefined);
|
||||||
protected readonly submitted = signal(false);
|
protected readonly submitted = signal(false);
|
||||||
|
protected readonly failed = signal(false);
|
||||||
|
|
||||||
submit(): void {
|
submit(): void {
|
||||||
this.submitting.set(true);
|
this.submitting.set(true);
|
||||||
this.bff.postSelfServiceRegistrations().subscribe((accepted: SubmitAccepted) => {
|
this.failed.set(false);
|
||||||
|
this.bff.postSelfServiceRegistrations().subscribe({
|
||||||
|
next: (accepted: SubmitAccepted) => {
|
||||||
this.reference.set(accepted.registrationId);
|
this.reference.set(accepted.registrationId);
|
||||||
this.submitted.set(true);
|
this.submitted.set(true);
|
||||||
this.submitting.set(false);
|
this.submitting.set(false);
|
||||||
|
},
|
||||||
|
// Surface the failure instead of swallowing it: re-enable the button so the user can retry.
|
||||||
|
error: () => {
|
||||||
|
this.failed.set(true);
|
||||||
|
this.submitting.set(false);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user