Compare commits

..

14 Commits

Author SHA1 Message Date
2b9eb5eb41 ci(e2e): run Playwright from the prebuilt image instead of downloading browsers (refs #73)
All checks were successful
CI / build (pull_request) Successful in 56s
CI / mutation (pull_request) Successful in 4m6s
CI / verify-stack (pull_request) Successful in 7m3s
CI / lint (pull_request) Successful in 5m43s
CI / unit (pull_request) Successful in 1m0s
CI / frontend (pull_request) Successful in 1m50s
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
2a746736dc Merge pull request 'test(e2e): serve the portal + walking-skeleton Playwright e2e (closes #68)' (#72) from feat/68-e2e into main
All checks were successful
CI / verify-stack (push) Successful in 7m19s
CI / lint (push) Successful in 1m10s
CI / build (push) Successful in 54s
CI / unit (push) Successful in 1m0s
CI / frontend (push) Successful in 1m52s
CI / mutation (push) Successful in 4m11s
Reviewed-on: #72
2026-07-13 13:20:57 +00:00
986e36bc7d test(portal-self-service): guard that the DigiD token attaches to relative BFF calls (refs #68)
All checks were successful
CI / lint (pull_request) Successful in 1m8s
CI / build (pull_request) Successful in 53s
CI / unit (pull_request) Successful in 58s
CI / frontend (pull_request) Successful in 2m10s
CI / mutation (pull_request) Successful in 3m58s
CI / verify-stack (pull_request) Successful in 7m48s
The token-attachment bug (secureRoutes set to the app origin, which a relative
api-client URL never matches) was only caught by the full-stack e2e. Add a fast
unit guard: drive the REAL angular-auth-oidc-client interceptor and the REAL
api-client against the production route value, faking only the config source and
the token storage. Asserts the bearer token rides the relative /self-service/
call and is withheld from the anonymous /openbaar/ call.

Extract the value to a shared SECURE_API_ROUTES constant so the test binds to
exactly what the app configures. Verified the guard fails (Authorization null)
if the value regresses to an origin.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 15:00:32 +02:00
7e152e4432 feat(portal-self-service): surface submit failures with a retryable alert (refs #68)
All checks were successful
CI / build (pull_request) Successful in 54s
CI / lint (pull_request) Successful in 1m11s
CI / unit (pull_request) Successful in 1m0s
CI / frontend (pull_request) Successful in 1m49s
CI / mutation (pull_request) Successful in 4m3s
CI / verify-stack (pull_request) Successful in 7m42s
Add an error branch to submit(): on a failed BFF call, set a `failed` signal,
re-enable the button, and render a role="alert" message so the user knows the
submit did not go through and can retry — instead of the click silently doing
nothing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 14:33:09 +02:00
5bf25f094d test(portal-self-service): submit surfaces BFF failures instead of swallowing them (refs #68)
Failing test: when postSelfServiceRegistrations errors, the page should show an
alert, not the confirmation, and keep the submit button available for retry.
Currently submit() has no error handler, so the rejection is swallowed and the
page silently stays put — exactly the failure mode that hid the missing-token
bug behind a 90s e2e timeout.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 14:32:30 +02:00
0e6c7d2066 fix(portal-self-service): attach the DigiD token to relative BFF calls (refs #68)
All checks were successful
CI / lint (pull_request) Successful in 1m12s
CI / build (pull_request) Successful in 51s
CI / unit (pull_request) Successful in 1m3s
CI / verify-stack (pull_request) Successful in 7m59s
CI / frontend (pull_request) Successful in 1m48s
CI / mutation (pull_request) Successful in 3m57s
After login the submit silently did nothing: the confirmation ("...is
ontvangen...") never rendered because the POST to the BFF went out with no
Authorization header, so the BFF rejected it and the no-error-handler
subscribe left the page unchanged.

Root cause: angular-auth-oidc-client's interceptor attaches the token when
`req.url.startsWith(secureRoute)`. The api-client calls the BFF with RELATIVE
URLs (same-origin via the nginx proxy), so `req.url` is `/self-service/...` —
but secureRoutes was configured as the app ORIGIN (`http://self-service`),
which a relative URL never starts with. No match → no token.

Configure secureRoutes with the relative `/self-service/` prefix instead. The
unit test mocked the api-client, so only the walking-skeleton e2e exercises the
real token attachment — now green.

Verified against a focused stack (keycloak + self-service + real BFF + stub
domain): the submit now carries the bearer token, the BFF forwards to the
domain, and the portal shows the confirmation with the returned reference.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 14:09:15 +02:00
39923e0e68 fix(e2e): treat the http portal origin as secure so DigiD PKCE login works (refs #68)
Some checks failed
CI / lint (pull_request) Successful in 1m12s
CI / build (pull_request) Successful in 53s
CI / unit (pull_request) Successful in 1m3s
CI / frontend (pull_request) Successful in 1m47s
CI / mutation (pull_request) Successful in 4m2s
CI / verify-stack (pull_request) Failing after 7m51s
The walking-skeleton e2e timed out waiting for the Keycloak login form
(`#username`). Root cause: in the compose 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 (`crypto.subtle`) is undefined. angular-auth-
oidc-client needs SubtleCrypto to build the PKCE code challenge, so
`authorize()` threw ("Cannot read properties of undefined (reading 'digest')")
and the login redirect never fired.

Production serves the portal over HTTPS, where this works. Instead of
terminating TLS in the throwaway e2e stack, tell Chromium to treat the origin
as secure via --unsafely-treat-insecure-origin-as-secure. The flag is only
honoured by the full Chromium build (new headless), not Playwright's default
headless-shell, so pin channel: 'chromium'.

Verified against a minimal in-network stack (keycloak + self-service): login
redirect now reaches the Keycloak form, and the full login → token exchange →
authenticated portal renders with no console errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 13:37:31 +02:00
2e00ad38ba ci(portal-self-service): run Vitest ahead of the production build to stop worker-start timeout (refs #68)
Some checks failed
CI / lint (pull_request) Successful in 1m11s
CI / build (pull_request) Successful in 54s
CI / unit (pull_request) Successful in 1m3s
CI / frontend (pull_request) Successful in 2m0s
CI / mutation (pull_request) Successful in 4m5s
CI / verify-stack (pull_request) Failing after 10m20s
The frontend lane ran `nx run-many -t lint test build`, so the ~5min
self-service production build shared nx's task pool with the Vitest test
worker. @angular/build:unit-test's Vitest worker has hard-coded 60s/90s
startup timeouts (not configurable); on a CPU-constrained CI runner the
concurrent build starved the worker and it failed with "Timeout waiting
for worker to respond" — flaky, since it passed on the prior commit.

Split the target into a light lint+test phase and a separate build phase
so tests get CPU and the worker starts well inside its window.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 12:51:56 +02:00
be016f920c fix(portal-self-service): health-check nginx over IPv4 (127.0.0.1) (refs #68)
Some checks failed
CI / lint (pull_request) Successful in 1m13s
CI / build (pull_request) Successful in 1m0s
CI / unit (pull_request) Successful in 1m6s
CI / frontend (pull_request) Failing after 7m11s
CI / mutation (pull_request) Successful in 3m59s
CI / verify-stack (pull_request) Failing after 7m47s
nginx listens on IPv4 only (listen 80), but 'localhost' inside the container resolves
to ::1 first, so the wget healthcheck got connection-refused and self-service never
went healthy — timing out the CI stack bring-up. Probe 127.0.0.1 instead.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 14:37:56 +02:00
d3f23a4da3 docs(portal-self-service): serving/e2e decisions + walking-skeleton demo note (refs #68)
Some checks failed
CI / lint (pull_request) Successful in 1m5s
CI / build (pull_request) Successful in 52s
CI / unit (pull_request) Successful in 1m2s
CI / frontend (pull_request) Successful in 1m31s
CI / mutation (pull_request) Successful in 3m55s
CI / verify-stack (pull_request) Has been cancelled
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 14:09:04 +02:00
490e7347b0 test(e2e): walking-skeleton Playwright happy path + verify-e2e lane (refs #68)
tests/e2e Playwright spec drives DigiD login (jan-burger/test123) → submit →
confirmation against the compose-served portal. run-e2e-check.sh runs it inside the
compose network (node container, browser installed at runtime) so the token issuer
(keycloak:8080) matches the BFF authority (ADR-0010). Wired as verify-e2e (Makefile +
verify chain + a verify-stack CI step).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 14:08:10 +02:00
4f311c9b5a ci(portal-self-service): serve the self-service app in compose (refs #68)
Add the self-service nginx service (build the app image, depends_on bff healthy +
keycloak started, health-checked, host port 8140). Add it to WAIT_SVCS and the CI
log dump.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 14:05:10 +02:00
a55ba1160d feat(portal-self-service): runtime config + nginx serve/proxy image (refs #68)
The app loads /config.json at startup (main.ts) so the OIDC authority is set per
environment from one build; appConfig becomes a factory and derives redirectUrl +
secureApiOrigin from the app origin (same-origin as the BFF). A multi-stage
Dockerfile builds the app and serves it via nginx, reverse-proxying /self-service
+ /openbaar to the bff (relative URLs → no CORS); nginx resolves the BFF at request
time. The compose image bakes config.json with the keycloak:8080 authority so the
browser's token issuer matches the BFF (ADR-0010).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 14:03:59 +02:00
20 changed files with 416 additions and 27 deletions

View File

@@ -23,6 +23,16 @@ jobs:
- uses: https://github.com/actions/setup-dotnet@v4
with:
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
build:
@@ -32,6 +42,12 @@ jobs:
- uses: https://github.com/actions/setup-dotnet@v4
with:
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
unit:
@@ -41,6 +57,12 @@ jobs:
- uses: https://github.com/actions/setup-dotnet@v4
with:
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
# 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
with:
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
# Publish the Stryker HTML reports. `if: always()` uploads them even when the
# ratchet fails — that is exactly when you want to inspect the survivors.
@@ -124,10 +152,12 @@ jobs:
run: make verify-domain
- name: BFF → Keycloak + domain + projection
run: make verify-bff
- name: Self-service e2e (Playwright, login → submit → success)
run: make verify-e2e
# Log dump must precede teardown (which removes the containers).
- name: Dump container logs on failure
if: failure()
run: docker compose -f infra/docker-compose.yml logs --no-color --tail=100 oz-init openzaak nrc-init nrc-web nrc-celery nrc-beat flowable-db flowable-rest flowable-init keycloak acl bff domain projection-db event-subscriber projection-api 2>&1 || true
run: docker compose -f infra/docker-compose.yml logs --no-color --tail=100 oz-init openzaak nrc-init nrc-web nrc-celery nrc-beat flowable-db flowable-rest flowable-init keycloak acl bff domain projection-db event-subscriber projection-api self-service 2>&1 || true
- name: Tear down
if: always()
run: make down

5
.gitignore vendored
View File

@@ -52,3 +52,8 @@ vite.config.*.timestamp*
vitest.config.*.timestamp*
.angular
# Playwright e2e (installed/generated in-container or on local runs)
tests/e2e/node_modules/
tests/e2e/test-results/
tests/e2e/playwright-report/

View File

@@ -10,7 +10,7 @@ COMPOSE := infra/docker-compose.yml
# 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)
# 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
WAIT_SVCS := openzaak nrc-web acl bff domain event-subscriber projection-api self-service
# Config files (OpenZaak data.yaml, Keycloak realms, Flowable BPMN) are streamed
# 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
@@ -50,9 +50,16 @@ endif
ci: lint build unit mutation frontend verify
## frontend: install deps and run the Nx lint/test/build for the portals (pnpm + Node required)
# Tests run in their own phase, ahead of the build. The @angular/build:unit-test
# (Vitest) runner spawns a worker with a hard-coded 60s/90s startup timeout that is
# not configurable. When the ~5min production build shares the run-many pool, it
# starves that worker of CPU on constrained CI runners and Vitest fails with
# "Timeout waiting for worker to respond". Splitting the phases keeps tests off the
# heavy build's back so the worker starts well inside its window.
frontend:
pnpm install --frozen-lockfile
pnpm nx run-many -t lint test build
pnpm nx run-many -t lint test
pnpm nx run-many -t build
## lint: verify formatting (no changes)
lint:
@@ -153,6 +160,11 @@ verify-domain:
verify-bff:
bash infra/run-bff-check.sh
## verify-e2e: walking-skeleton Playwright e2e (S-08d) against the up stack — DigiD login →
## submit → confirmation, driven inside the compose network.
verify-e2e:
bash infra/run-e2e-check.sh
## verify: local mirror of the CI verify-stack job — full stack up once, all checks,
## tear down (always). For fast single-concern local iteration use `integration`
## (oz-only) or `verify-notifications` (oz+nrc) instead.
@@ -165,7 +177,8 @@ verify:
&& bash infra/run-notification-check.sh \
&& bash infra/run-projection-check.sh \
&& bash infra/run-domain-check.sh \
&& bash infra/run-bff-check.sh || rc=$$?; \
&& bash infra/run-bff-check.sh \
&& bash infra/run-e2e-check.sh || rc=$$?; \
docker compose -f $(COMPOSE) down --volumes >/dev/null 2>&1; \
docker volume rm -f $(CFG_VOLS) >/dev/null 2>&1; \
exit $$rc'

View File

@@ -0,0 +1,23 @@
# Multi-stage build for the self-service 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/self-service apps/self-service
COPY libs libs
RUN pnpm nx build self-service
FROM nginx:1.27-alpine AS runtime
COPY apps/self-service/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /src/dist/apps/self-service/browser /usr/share/nginx/html
# Compose-time OIDC config: the browser (Playwright, on the compose network) reaches Keycloak by
# service name, so the token issuer matches the BFF's authority (host-consistent, ADR-0010).
RUN printf '{ "authority": "http://keycloak:8080/realms/digid" }\n' > /usr/share/nginx/html/config.json
EXPOSE 80

View File

@@ -0,0 +1,29 @@
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 BFF endpoint groups to the bff service. The api-client uses relative
# URLs, so the browser calls this origin and nginx forwards to the BFF — no CORS, and the DigiD
# token (same-origin) is attached by the app's interceptor (S-08d/ADR-0010).
location /self-service/ {
set $bff http://bff:8080;
proxy_pass $bff;
proxy_set_header Host $host;
}
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,3 @@
{
"authority": "http://localhost:8180/realms/digid"
}

View 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([]);
});
});

View File

@@ -7,16 +7,36 @@ import { provideRouter } from '@angular/router';
import { authInterceptor, provideDigiadAuth } from 'auth';
import { appRoutes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(appRoutes),
provideHttpClient(withInterceptors([authInterceptor()])),
// Dev defaults (host ports). The compose-served app overrides these for the stack (S-08d).
provideDigiadAuth({
authority: 'http://localhost:8180/realms/digid',
redirectUrl: typeof window !== 'undefined' ? window.location.origin : '/',
secureApiOrigin: 'http://localhost:8080',
}),
],
};
/** Environment-specific settings fetched from /config.json at startup (see main.ts). */
export interface RuntimeConfig {
/** The Keycloak `digid` realm issuer as the browser reaches it (dev: localhost; compose: keycloak:8080). */
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
* redirects back). `secureRoutes` uses {@link SECURE_API_ROUTES} — relative prefixes, not the origin.
*/
export function appConfig(runtime: RuntimeConfig): ApplicationConfig {
const origin = typeof window !== 'undefined' ? window.location.origin : '/';
return {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(appRoutes),
provideHttpClient(withInterceptors([authInterceptor()])),
provideDigiadAuth({
authority: runtime.authority,
redirectUrl: origin,
secureRoutes: SECURE_API_ROUTES,
}),
],
};
}

View File

@@ -8,6 +8,11 @@
</p>
} @else {
<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
utrecht-button
appearance="primary-action-button"

View File

@@ -1,6 +1,6 @@
import { signal } from '@angular/core';
import { fireEvent, render, screen } from '@testing-library/angular';
import { of } from 'rxjs';
import { of, throwError } from 'rxjs';
import { AuthService } from 'auth';
import { BffApiV1Service } from 'api-client';
import { axe } from 'vitest-axe';
@@ -43,6 +43,19 @@ describe('RegistrationPage', () => {
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 () => {
// 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.

View File

@@ -21,13 +21,22 @@ export class RegistrationPage {
protected readonly submitting = signal(false);
protected readonly reference = signal<string | undefined>(undefined);
protected readonly submitted = signal(false);
protected readonly failed = signal(false);
submit(): void {
this.submitting.set(true);
this.bff.postSelfServiceRegistrations().subscribe((accepted: SubmitAccepted) => {
this.reference.set(accepted.registrationId);
this.submitted.set(true);
this.submitting.set(false);
this.failed.set(false);
this.bff.postSelfServiceRegistrations().subscribe({
next: (accepted: SubmitAccepted) => {
this.reference.set(accepted.registrationId);
this.submitted.set(true);
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);
},
});
}
}

View File

@@ -1,5 +1,10 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { App } from './app/app';
import { appConfig, type RuntimeConfig } from './app/app.config';
bootstrapApplication(App, appConfig).catch((err) => console.error(err));
// Load environment config before bootstrap so the OIDC authority is set per environment
// (dev: localhost; compose: keycloak:8080) from a single build — 12-factor (S-08d).
fetch('config.json')
.then((response) => response.json() as Promise<RuntimeConfig>)
.then((config) => bootstrapApplication(App, appConfig(config)))
.catch((err) => console.error(err));

View File

@@ -5,6 +5,28 @@ copy-pasteable walkthrough against a local `make up` stack.
---
## S-08d — Walking skeleton complete: browser → submit, end-to-end
**Outcome:** the self-service portal is served in the stack and the full front-of-house happy path
runs in a real browser — **mock DigiD login → submit → confirmation** — closing the walking skeleton
(portal → BFF → domain → Flowable → ACL → OpenZaak, with the openbaar register reading the projection).
```bash
# 1. Bring the whole stack up (portal served on :8140, BFF :8080, Keycloak :8180).
make up
# 2. Automated happy path — Playwright, inside the compose network (issuer-consistent):
make verify-e2e # → login as jan-burger → submit → "ontvangen" confirmation
# 3. By hand: open the portal, log in as jan-burger / test123, click "Registratie indienen".
open http://localhost:8140
```
> The portal is served same-origin with the BFF (nginx proxies `/self-service` + `/openbaar`), so no
> CORS; the OIDC authority comes from `/config.json` at runtime. See `docs/frontend-decisions.md`.
---
## S-08c — Self-service submit form (NL Design System + DigiD)
**Outcome:** a zorgprofessional logs in via mock DigiD and submits a BIG registration through the

View File

@@ -72,3 +72,34 @@ with the submit form (S-08c, #67); any deviation from NL DS will be recorded her
- **Module boundaries:** replaced the demo eslint `depConstraints` (`scope:shop`/`scope:shared`, left
over from the Nx angular template) with a permissive `*` default; scope/type tags can be
introduced when the portal set grows.
---
## Serving + e2e (S-08d, #68)
- **Served by nginx, same-origin as the BFF.** The compose `self-service` image serves the built app
and **reverse-proxies** `/self-service/*` + `/openbaar/*` to the `bff` service. Because the
api-client uses **relative URLs**, the browser calls the app's own origin → nginx forwards to the
BFF: **no CORS**, and the DigiD token (same-origin) is attached by the interceptor. nginx resolves
the BFF at request time (a `resolver` + variable `proxy_pass`) so it starts before the BFF is up.
- **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
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 container on
`cg`, so the browser reaches Keycloak as `keycloak:8080` — the **same issuer** the BFF validates
against (resolves the browser-vs-container mismatch, ADR-0010). It uses the official
`mcr.microsoft.com/playwright:<version>` image with browsers pre-baked, rather than downloading
~150 MB of Chromium on every run (issue #73) — the image tag is kept in lockstep with
`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
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
`authorize()` throws and the login redirect never fires. Production runs behind HTTPS where this is
a non-issue; rather than terminate TLS in the throwaway stack, the Playwright config passes
`--unsafely-treat-insecure-origin-as-secure` (honoured only by the full `channel: 'chromium'`
build, not the default headless-shell). This emulates the production HTTPS secure context without
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
live-stack check like the other `verify-*` runners, not part of the `frontend` unit lane.

View File

@@ -433,6 +433,31 @@ services:
condition: service_healthy
networks: [cg]
# ── Self-Service portal (S-08d) ────────────────────────────────────────────
# nginx serves the Angular app and reverse-proxies /self-service + /openbaar to the BFF
# (same-origin, no CORS). The Playwright e2e drives it inside this network so the DigiD
# token issuer (keycloak:8080) matches the BFF's authority (ADR-0010).
self-service:
build:
context: ..
dockerfile: apps/self-service/Dockerfile
image: register-referentie/self-service:dev
ports:
- "8140: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
keycloak:
condition: service_started
networks: [cg]
volumes:
oz-db:
nrc-db:

29
infra/run-e2e-check.sh Executable file
View File

@@ -0,0 +1,29 @@
#!/usr/bin/env bash
#
# 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).
#
# 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
# 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.
#
# 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
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
root="$(cd "$here/.." && pwd)"
ss="$(docker ps -q --filter 'name=self-service' | head -1)"
[ -n "$ss" ] || { echo "ERROR: no running self-service container — bring the stack up first" >&2; exit 1; }
net="$(docker inspect -f '{{range $k,$_ := .NetworkSettings.Networks}}{{$k}}{{"\n"}}{{end}}' "$ss" | head -1)"
echo ">> running Playwright e2e on network $net against http://self-service"
cid="$(docker create --network "$net" -w /e2e --ipc=host \
-e SELF_SERVICE_URL=http://self-service \
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
docker cp "$root/tests/e2e/." "$cid:/e2e" >/dev/null
docker start -a "$cid"

View File

@@ -13,8 +13,13 @@ export interface DigiadAuthOptions {
authority: string;
/** Where Keycloak redirects back to after login (usually the app origin). */
redirectUrl: string;
/** The BFF origin whose requests get the bearer token attached (secure route). */
secureApiOrigin: string;
/**
* Route prefixes whose requests get the bearer token attached. The api-client calls the BFF with
* **relative** URLs (same-origin via the nginx proxy), so these must be relative path prefixes
* (e.g. `/self-service/`) — angular-auth-oidc-client matches `req.url.startsWith(route)`, and a
* relative `req.url` never starts with an absolute origin.
*/
secureRoutes: string[];
}
/**
@@ -35,7 +40,7 @@ export function provideDigiadAuth(options: DigiadAuthOptions): EnvironmentProvid
responseType: 'code',
silentRenew: true,
useRefreshToken: true,
secureRoutes: [options.secureApiOrigin],
secureRoutes: options.secureRoutes,
logLevel: LogLevel.Warn,
},
},

8
tests/e2e/package.json Normal file
View File

@@ -0,0 +1,8 @@
{
"name": "e2e",
"private": true,
"description": "Playwright walking-skeleton e2e (S-08d) — run inside the compose network by infra/run-e2e-check.sh.",
"devDependencies": {
"@playwright/test": "1.61.1"
}
}

View File

@@ -0,0 +1,28 @@
import { defineConfig, devices } from '@playwright/test';
// The e2e runs inside the compose network (infra/run-e2e-check.sh); baseURL defaults to the
// self-service service. Keep timeouts generous — the first navigation triggers the DigiD flow.
const baseURL = process.env.SELF_SERVICE_URL ?? 'http://self-service';
export default defineConfig({
testDir: '.',
timeout: 90_000,
expect: { timeout: 15_000 },
retries: 1,
reporter: [['list']],
use: {
baseURL,
trace: 'on-first-retry',
// The portal is served over plain HTTP on a non-localhost origin (http://self-service) inside the
// compose network, so it is NOT a secure context — and Web Crypto (`crypto.subtle`) is undefined
// there. angular-auth-oidc-client needs SubtleCrypto to build the PKCE code challenge, so
// `authorize()` throws and the login redirect never fires (the login form never appears). In
// production the portal runs behind HTTPS, where this works. Rather than terminate TLS in the
// throwaway e2e stack, tell Chromium to treat this origin as secure — which faithfully emulates
// the production HTTPS context. This flag is only honoured by the full Chromium build (new
// headless), not Playwright's default headless-shell, so pin `channel: 'chromium'`.
channel: 'chromium',
launchOptions: { args: [`--unsafely-treat-insecure-origin-as-secure=${baseURL}`] },
},
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
});

View File

@@ -0,0 +1,21 @@
import { expect, test } from '@playwright/test';
// Walking-skeleton happy path (S-08d): a zorgprofessional logs in via mock DigiD and submits a
// registration through the self-service portal → BFF → domain. The confirmation shows the reference.
test('DigiD login → submit → confirmation', async ({ page }) => {
// Visiting the guarded page redirects to the Keycloak (mock DigiD) login.
await page.goto('/');
// Keycloak's default login form (stable ids across themes).
await page.locator('#username').fill('jan-burger');
await page.locator('#password').fill('test123');
await page.locator('#kc-login').click();
// Back on the portal, authenticated.
await expect(page.getByRole('heading', { name: /Zelfservice/i })).toBeVisible();
await page.getByRole('button', { name: /indienen/i }).click();
// The BFF accepted it and the page shows the confirmation.
await expect(page.getByText(/ontvangen/i)).toBeVisible();
});