diff --git a/apps/behandel/src/app/app.config.spec.ts b/apps/behandel/src/app/app.config.spec.ts new file mode 100644 index 0000000..ba0dafc --- /dev/null +++ b/apps/behandel/src/app/app.config.spec.ts @@ -0,0 +1,73 @@ +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 medewerker 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 makes the relative URL never match, so +// the behandel calls go out unauthenticated and the BFF answers 401. This drives the REAL interceptor +// and the REAL api-client against the REAL production route value (SECURE_API_ROUTES); only the config +// source and token storage are faked, so the assertion turns on the actual route-matching. +describe('behandel medewerker token wiring', () => { + let http: HttpTestingController; + let bff: BffApiV1Service; + const token = 'medewerker-access-token'; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + provideHttpClient(withInterceptors([authInterceptor()])), + provideHttpClientTesting(), + { + provide: ConfigurationService, + useValue: { + hasAtLeastOneConfig: () => true, + getAllConfigurations: () => [{ configId: 'medewerker', 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 werkbak call', () => { + bff.getBehandelWerkbak().subscribe(); + + const req = http.expectOne('/behandel/werkbak'); + expect(req.request.headers.get('Authorization')).toBe(`Bearer ${token}`); + req.flush([]); + }); + + it('attaches the bearer token to the relative decide call', () => { + bff.postBehandelRegistrationsIdDecide('reg-1', { besluit: 'goedkeuren' }).subscribe(); + + const req = http.expectOne('/behandel/registrations/reg-1/decide'); + expect(req.request.headers.get('Authorization')).toBe(`Bearer ${token}`); + req.flush(null); + }); + + 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/behandel/src/app/app.spec.ts b/apps/behandel/src/app/app.spec.ts new file mode 100644 index 0000000..d1010c3 --- /dev/null +++ b/apps/behandel/src/app/app.spec.ts @@ -0,0 +1,15 @@ +import { provideRouter } from '@angular/router'; +import { render, screen } from '@testing-library/angular'; +import { App } from './app'; + +describe('App', () => { + it('renders the router outlet shell', async () => { + const { container } = await render(App, { + providers: [provideRouter([])], + }); + + // The shell is a thin host for routed pages (the WerkbakPage owns the heading). + expect(container.querySelector('router-outlet')).toBeTruthy(); + expect(screen).toBeTruthy(); + }); +}); diff --git a/apps/behandel/src/app/werkbak/werkbak-page.spec.ts b/apps/behandel/src/app/werkbak/werkbak-page.spec.ts new file mode 100644 index 0000000..9799845 --- /dev/null +++ b/apps/behandel/src/app/werkbak/werkbak-page.spec.ts @@ -0,0 +1,110 @@ +import { signal } from '@angular/core'; +import { fireEvent, render, screen } from '@testing-library/angular'; +import { of, throwError } from 'rxjs'; +import { BffApiV1Service, type WerkbakItem } from 'api-client'; +import { AuthService } from 'auth'; +import { axe } from 'vitest-axe'; +import { WerkbakPage } from './werkbak-page'; + +const sample: WerkbakItem[] = [ + { registrationId: 'reg-1', bsn: '123456782', status: 'InBehandeling' }, + { registrationId: 'reg-2', bsn: '111222333', status: 'InBehandeling' }, +]; + +class FakeAuth extends AuthService { + readonly isAuthenticated = signal(true); + readonly bsn = signal(undefined); + override readonly roles = signal(['behandelaar']); + login(): void { + /* not exercised here */ + } + logout(): void { + /* spied in tests */ + } +} + +function setup( + overrides: { + getBehandelWerkbak?: ReturnType; + postBehandelRegistrationsIdDecide?: ReturnType; + } = {}, +) { + const getBehandelWerkbak = + overrides.getBehandelWerkbak ?? vi.fn().mockReturnValue(of(sample)); + const postBehandelRegistrationsIdDecide = + overrides.postBehandelRegistrationsIdDecide ?? vi.fn().mockReturnValue(of(undefined)); + return { + getBehandelWerkbak, + postBehandelRegistrationsIdDecide, + providers: [ + { + provide: BffApiV1Service, + useValue: { getBehandelWerkbak, postBehandelRegistrationsIdDecide }, + }, + { provide: AuthService, useClass: FakeAuth }, + ], + }; +} + +describe('WerkbakPage', () => { + it('lists the registrations awaiting beoordeling on open', async () => { + const { getBehandelWerkbak, providers } = setup(); + await render(WerkbakPage, { providers }); + + expect(getBehandelWerkbak).toHaveBeenCalled(); + expect(await screen.findByText('reg-1')).toBeTruthy(); + expect(screen.getByText('123456782')).toBeTruthy(); + expect(screen.getByText('reg-2')).toBeTruthy(); + }); + + it('approves a registration (goedkeuren) and refreshes the werkbak', async () => { + const { getBehandelWerkbak, postBehandelRegistrationsIdDecide, providers } = setup(); + await render(WerkbakPage, { providers }); + + fireEvent.click((await screen.findAllByRole('button', { name: /goedkeuren/i }))[0]); + + expect(postBehandelRegistrationsIdDecide).toHaveBeenCalledWith('reg-1', { + besluit: 'goedkeuren', + }); + // Reloaded after the decision: once on open, once after deciding. + expect(getBehandelWerkbak).toHaveBeenCalledTimes(2); + }); + + it('rejects a registration (afwijzen) via the decide endpoint', async () => { + const { postBehandelRegistrationsIdDecide, providers } = setup(); + await render(WerkbakPage, { providers }); + + fireEvent.click((await screen.findAllByRole('button', { name: /afwijzen/i }))[0]); + + expect(postBehandelRegistrationsIdDecide).toHaveBeenCalledWith('reg-1', { + besluit: 'afwijzen', + }); + }); + + it('shows an empty state when the werkbak has no items', async () => { + const { providers } = setup({ getBehandelWerkbak: vi.fn().mockReturnValue(of([])) }); + await render(WerkbakPage, { providers }); + + expect(await screen.findByText(/werkbak is leeg/i)).toBeTruthy(); + }); + + it('surfaces a load failure instead of swallowing it', async () => { + const { providers } = setup({ + getBehandelWerkbak: vi.fn().mockReturnValue(throwError(() => new Error('403'))), + }); + await render(WerkbakPage, { providers }); + + expect(await screen.findByText(/kon de werkbak niet laden/i)).toBeTruthy(); + }); + + it('has no WCAG 2.1 AA violations', async () => { + document.documentElement.lang = 'nl'; + const { container } = await render(WerkbakPage, { providers: setup().providers }); + + const results = await axe(container, { + runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'] }, + }); + + expect(results.violations).toEqual([]); + }); +});