Compare commits
6 Commits
0e6c7d2066
...
chore/73-c
| Author | SHA1 | Date | |
|---|---|---|---|
| 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.
|
||||||
|
|||||||
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.reference.set(accepted.registrationId);
|
this.bff.postSelfServiceRegistrations().subscribe({
|
||||||
this.submitted.set(true);
|
next: (accepted: SubmitAccepted) => {
|
||||||
this.submitting.set(false);
|
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);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user