Compare commits
3 Commits
0e6c7d2066
...
feat/68-e2
| Author | SHA1 | Date | |
|---|---|---|---|
| 986e36bc7d | |||
| 7e152e4432 | |||
| 5bf25f094d |
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);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user