Compare commits
7 Commits
d3f23a4da3
...
feat/68-e2
| Author | SHA1 | Date | |
|---|---|---|---|
| 986e36bc7d | |||
| 7e152e4432 | |||
| 5bf25f094d | |||
| 0e6c7d2066 | |||
| 39923e0e68 | |||
| 2e00ad38ba | |||
| be016f920c |
9
Makefile
9
Makefile
@@ -50,9 +50,16 @@ endif
|
|||||||
ci: lint build unit mutation frontend verify
|
ci: lint build unit mutation frontend verify
|
||||||
|
|
||||||
## frontend: install deps and run the Nx lint/test/build for the portals (pnpm + Node required)
|
## 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:
|
frontend:
|
||||||
pnpm install --frozen-lockfile
|
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: verify formatting (no changes)
|
||||||
lint:
|
lint:
|
||||||
|
|||||||
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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -14,9 +14,16 @@ export interface RuntimeConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the app providers from runtime config. `redirectUrl` and `secureApiOrigin` are the app's own
|
* Route prefixes whose requests carry the DigiD token. These MUST match the **relative** URLs the
|
||||||
* origin: the app is served same-origin as the BFF (nginx proxies /self-service + /openbaar), so the
|
* api-client actually calls (same-origin via the nginx proxy) — the interceptor matches on `req.url`,
|
||||||
* api-client's relative calls stay same-origin and the token interceptor attaches to them.
|
* 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 {
|
export function appConfig(runtime: RuntimeConfig): ApplicationConfig {
|
||||||
const origin = typeof window !== 'undefined' ? window.location.origin : '/';
|
const origin = typeof window !== 'undefined' ? window.location.origin : '/';
|
||||||
@@ -28,7 +35,7 @@ export function appConfig(runtime: RuntimeConfig): ApplicationConfig {
|
|||||||
provideDigiadAuth({
|
provideDigiadAuth({
|
||||||
authority: runtime.authority,
|
authority: runtime.authority,
|
||||||
redirectUrl: origin,
|
redirectUrl: origin,
|
||||||
secureApiOrigin: origin,
|
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);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,5 +91,13 @@ with the submit form (S-08c, #67); any deviation from NL DS will be recorded her
|
|||||||
runtime, so there's no Playwright-image-version pinning to keep in sync. The spec is copied in
|
runtime, so there's no Playwright-image-version pinning to keep in sync. The spec is copied in
|
||||||
(`docker cp`), not mounted, so it leaves nothing root-owned on the host. Wired as `verify-e2e` in
|
(`docker cp`), not mounted, so it leaves nothing root-owned on the host. Wired as `verify-e2e` in
|
||||||
the `verify-stack` CI job.
|
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
|
- `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.
|
||||||
|
|||||||
@@ -445,7 +445,8 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "8140:80"
|
- "8140:80"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "wget -q -O /dev/null http://localhost/ || exit 1"]
|
# 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
|
interval: 5s
|
||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|||||||
@@ -13,8 +13,13 @@ export interface DigiadAuthOptions {
|
|||||||
authority: string;
|
authority: string;
|
||||||
/** Where Keycloak redirects back to after login (usually the app origin). */
|
/** Where Keycloak redirects back to after login (usually the app origin). */
|
||||||
redirectUrl: string;
|
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',
|
responseType: 'code',
|
||||||
silentRenew: true,
|
silentRenew: true,
|
||||||
useRefreshToken: true,
|
useRefreshToken: true,
|
||||||
secureRoutes: [options.secureApiOrigin],
|
secureRoutes: options.secureRoutes,
|
||||||
logLevel: LogLevel.Warn,
|
logLevel: LogLevel.Warn,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { defineConfig, devices } from '@playwright/test';
|
|||||||
|
|
||||||
// The e2e runs inside the compose network (infra/run-e2e-check.sh); baseURL defaults to the
|
// 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.
|
// 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({
|
export default defineConfig({
|
||||||
testDir: '.',
|
testDir: '.',
|
||||||
timeout: 90_000,
|
timeout: 90_000,
|
||||||
@@ -9,8 +11,18 @@ export default defineConfig({
|
|||||||
retries: 1,
|
retries: 1,
|
||||||
reporter: [['list']],
|
reporter: [['list']],
|
||||||
use: {
|
use: {
|
||||||
baseURL: process.env.SELF_SERVICE_URL ?? 'http://self-service',
|
baseURL,
|
||||||
trace: 'on-first-retry',
|
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'] } }],
|
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user