diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index eff1cd1..4e3a624 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -124,10 +124,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 diff --git a/.gitignore b/.gitignore index c206ae3..30b841d 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/Makefile b/Makefile index 01b48ae..3075548 100644 --- a/Makefile +++ b/Makefile @@ -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' diff --git a/apps/self-service/Dockerfile b/apps/self-service/Dockerfile new file mode 100644 index 0000000..6f72a08 --- /dev/null +++ b/apps/self-service/Dockerfile @@ -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 diff --git a/apps/self-service/nginx.conf b/apps/self-service/nginx.conf new file mode 100644 index 0000000..d399eee --- /dev/null +++ b/apps/self-service/nginx.conf @@ -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; + } +} diff --git a/apps/self-service/public/config.json b/apps/self-service/public/config.json new file mode 100644 index 0000000..7642cac --- /dev/null +++ b/apps/self-service/public/config.json @@ -0,0 +1,3 @@ +{ + "authority": "http://localhost:8180/realms/digid" +} diff --git a/apps/self-service/src/app/app.config.spec.ts b/apps/self-service/src/app/app.config.spec.ts new file mode 100644 index 0000000..439e411 --- /dev/null +++ b/apps/self-service/src/app/app.config.spec.ts @@ -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([]); + }); +}); diff --git a/apps/self-service/src/app/app.config.ts b/apps/self-service/src/app/app.config.ts index d7a7072..06382d2 100644 --- a/apps/self-service/src/app/app.config.ts +++ b/apps/self-service/src/app/app.config.ts @@ -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, + }), + ], + }; +} diff --git a/apps/self-service/src/app/registration/registration-page.html b/apps/self-service/src/app/registration/registration-page.html index 3aa25cf..a8b6627 100644 --- a/apps/self-service/src/app/registration/registration-page.html +++ b/apps/self-service/src/app/registration/registration-page.html @@ -8,6 +8,11 @@

} @else {

U bent ingelogd met BSN {{ bsn() }}.

+ @if (failed()) { +

+ Er ging iets mis bij het indienen van uw registratie. Probeer het opnieuw. +

+ }