From 1605295051eb3d7ce7591656ae9c7fcab34b2f50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jovan=20Kruni=C4=87?= Date: Tue, 13 Oct 2026 15:50:47 +0200 Subject: [PATCH] WIP tests cap authoriz request --- .../page/auth-callback-page.component.spec.ts | 106 +++++++++ ...itor-authorization-request-handler.spec.ts | 207 ++++++++++++++++++ 2 files changed, 313 insertions(+) create mode 100644 frontend/app/src/app/modules/auth/auth-callback/page/auth-callback-page.component.spec.ts create mode 100644 frontend/app/src/app/modules/auth/capacitor-authorization-request-handler.spec.ts diff --git a/frontend/app/src/app/modules/auth/auth-callback/page/auth-callback-page.component.spec.ts b/frontend/app/src/app/modules/auth/auth-callback/page/auth-callback-page.component.spec.ts new file mode 100644 index 00000000..3ec0534b --- /dev/null +++ b/frontend/app/src/app/modules/auth/auth-callback/page/auth-callback-page.component.spec.ts @@ -0,0 +1,106 @@ +/* + * Copyright (C) 2026 StApps + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ +import {TestBed} from '@angular/core/testing'; +import {Router} from '@angular/router'; +import {NavController} from '@ionic/angular/standalone'; +import {Subject} from 'rxjs'; +import {AuthActions, AuthProvider, IAuthAction} from '../../auth.provider'; +import {AuthHelperService} from '../../auth-helper.service'; +import {AuthCallbackPageComponent} from './auth-callback-page.component'; + +/** Lets pending promise callbacks run. */ +async function flushPromises(): Promise { + for (let index = 0; index < 10; index++) { + await Promise.resolve(); + } +} + +/** Creates the page like the router does when the IdP redirects back. */ +function openCallbackPage(): AuthCallbackPageComponent { + return TestBed.runInInjectionContext(() => new AuthCallbackPageComponent()); +} + +describe('AuthCallbackPageComponent', () => { + let events: Subject; + let authProvider: {events$: Subject; authorizationCallback: jasmine.Spy}; + let authHelper: jasmine.SpyObj>; + let navController: jasmine.SpyObj>; + + beforeEach(() => { + events = new Subject(); + authProvider = {events$: events, authorizationCallback: jasmine.createSpy('authorizationCallback')}; + + authHelper = jasmine.createSpyObj('AuthHelperService', ['getOriginPath', 'deleteOriginPath']); + authHelper.deleteOriginPath.and.resolveTo(); + + navController = jasmine.createSpyObj('NavController', ['navigateRoot']); + navController.navigateRoot.and.resolveTo(true); + + TestBed.configureTestingModule({ + providers: [ + {provide: AuthProvider, useValue: authProvider}, + {provide: AuthHelperService, useValue: authHelper}, + {provide: NavController, useValue: navController}, + {provide: Router, useValue: {}}, + ], + }); + }); + + it('hands the callback URL to AuthProvider', () => { + openCallbackPage(); + + expect(authProvider.authorizationCallback).toHaveBeenCalledOnceWith(window.location.href); + }); + + it('goes back to the page the login was started from', async () => { + authHelper.getOriginPath.and.resolveTo('/assessments'); + openCallbackPage(); + + events.next({action: AuthActions.SignInSuccess}); + await flushPromises(); + + expect(navController.navigateRoot).toHaveBeenCalledOnceWith('/assessments'); + expect(authHelper.deleteOriginPath).toHaveBeenCalled(); + }); + + it('goes to the profile after login when no origin page is known', async () => { + openCallbackPage(); + + events.next({action: AuthActions.SignInSuccess}); + await flushPromises(); + + expect(navController.navigateRoot).toHaveBeenCalledOnceWith('profile'); + }); + + it('goes to the profile when the login failed', async () => { + openCallbackPage(); + + events.next({action: AuthActions.SignInFailed, error: new Error('access_denied')}); + await flushPromises(); + + expect(navController.navigateRoot).toHaveBeenCalledOnceWith('profile'); + expect(authHelper.getOriginPath).not.toHaveBeenCalled(); + }); + + it('ignores other auth events', async () => { + openCallbackPage(); + + events.next({action: AuthActions.RefreshSuccess}); + events.next({action: AuthActions.LoadUserInfoSuccess}); + await flushPromises(); + + expect(navController.navigateRoot).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/app/src/app/modules/auth/capacitor-authorization-request-handler.spec.ts b/frontend/app/src/app/modules/auth/capacitor-authorization-request-handler.spec.ts new file mode 100644 index 00000000..92edb6cd --- /dev/null +++ b/frontend/app/src/app/modules/auth/capacitor-authorization-request-handler.spec.ts @@ -0,0 +1,207 @@ +/* + * Copyright (C) 2026 StApps + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ +import { + AuthorizationError, + AuthorizationNotifier, + AuthorizationRequest, + AuthorizationResponse, + AuthorizationServiceConfiguration, + StorageBackend, +} from '@openid/appauth'; +import {CapacitorAuthorizationRequestHandler} from './capacitor-authorization-request-handler'; + +const PENDING_REQUEST_KEY = 'appauth_capacitor_authorization_request'; +const REDIRECT_URI = 'de.example.app://app.example.org/auth/callback'; + +/** + * In-memory StorageBackend, so the tests can look at what the handler stores. + */ +class MemoryStorage extends StorageBackend { + readonly items = new Map(); + + async getItem(name: string): Promise { + return this.items.get(name) ?? null; // eslint-disable-line unicorn/no-null + } + + async removeItem(name: string): Promise { + this.items.delete(name); + } + + async clear(): Promise { + this.items.clear(); + } + + async setItem(name: string, value: string): Promise { + this.items.set(name, value); + } +} + +/** + * The callback URL as the IdP would send it back to the app. + */ +function callbackUrl(parameters: Record): string { + return `${REDIRECT_URI}?${new URLSearchParams(parameters).toString()}`; +} + +describe('CapacitorAuthorizationRequestHandler', () => { + let storage: MemoryStorage; + let handler: CapacitorAuthorizationRequestHandler; + let listener: jasmine.Spy< + ( + request: AuthorizationRequest, + response: AuthorizationResponse | null, + error: AuthorizationError | null, + ) => void + >; + let windowOpen: jasmine.Spy; + + const configuration = new AuthorizationServiceConfiguration({ + authorization_endpoint: 'https://idp.example.org/authorize', + token_endpoint: 'https://idp.example.org/token', + revocation_endpoint: '', + }); + + /** + * Starts a login like AuthProvider.signIn() does and returns its state. + */ + async function startLogin(): Promise { + const request = new AuthorizationRequest({ + response_type: AuthorizationRequest.RESPONSE_TYPE_CODE, + client_id: 'test-client', + redirect_uri: REDIRECT_URI, + scope: 'openid', + }); + + await handler.performAuthorizationRequest(configuration, request); + + return request.state; + } + + beforeEach(() => { + storage = new MemoryStorage(); + handler = new CapacitorAuthorizationRequestHandler(storage); + + const notifier = new AuthorizationNotifier(); + listener = jasmine.createSpy('authorizationListener'); + notifier.setAuthorizationListener(listener); + handler.setAuthorizationNotifier(notifier); + + // In tests, @capacitor/browser uses its web implementation, which calls window.open(). + windowOpen = spyOn(window, 'open').and.returnValue({close: () => {}} as Window); + }); + + describe('starting the login', () => { + it('opens the authorization URL with state and PKCE (S256)', async () => { + const state = await startLogin(); + + const url = new URL(windowOpen.calls.mostRecent().args[0] as string); + expect(url.origin + url.pathname).toBe('https://idp.example.org/authorize'); + expect(url.searchParams.get('state')).toBe(state); + expect(url.searchParams.get('redirect_uri')).toBe(REDIRECT_URI); + expect(url.searchParams.get('code_challenge_method')).toBe('S256'); + expect(url.searchParams.get('code_challenge')).toBeTruthy(); + }); + + it('stores the pending request including the PKCE code_verifier', async () => { + const state = await startLogin(); + + const stored = JSON.parse(storage.items.get(PENDING_REQUEST_KEY)!); + expect(stored.state).toBe(state); + expect(stored.internal.code_verifier).toBeTruthy(); + }); + }); + + describe('completing the login', () => { + it('delivers the code together with the matching request', async () => { + const state = await startLogin(); + const verifier = JSON.parse(storage.items.get(PENDING_REQUEST_KEY)!).internal.code_verifier; + + await handler.completeAuthorizationRequestFromUrl(callbackUrl({code: 'auth-code', state})); + + expect(listener).toHaveBeenCalledTimes(1); + const [request, response, error] = listener.calls.mostRecent().args; + expect(response?.code).toBe('auth-code'); + expect(error).toBeNull(); + expect(request.internal?.['code_verifier']).toBe(verifier); + }); + + it('accepts a callback only once', async () => { + const state = await startLogin(); + const url = callbackUrl({code: 'auth-code', state}); + + await handler.completeAuthorizationRequestFromUrl(url); + expect(storage.items.has(PENDING_REQUEST_KEY)).toBeFalse(); + + await expectAsync(handler.completeAuthorizationRequestFromUrl(url)).toBeRejectedWithError( + /No pending authorization request/, + ); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('rejects a callback with a different state and keeps the pending request', async () => { + const state = await startLogin(); + + await expectAsync( + handler.completeAuthorizationRequestFromUrl( + callbackUrl({code: 'attacker-code', state: 'other-state'}), + ), + ).toBeRejectedWithError(/state does not match/); + expect(listener).not.toHaveBeenCalled(); + + // The real callback still works afterwards. + await handler.completeAuthorizationRequestFromUrl(callbackUrl({code: 'auth-code', state})); + expect(listener.calls.mostRecent().args[1]?.code).toBe('auth-code'); + }); + + it('rejects a callback without state', async () => { + await startLogin(); + + await expectAsync( + handler.completeAuthorizationRequestFromUrl(callbackUrl({code: 'auth-code'})), + ).toBeRejectedWithError(/state does not match/); + expect(listener).not.toHaveBeenCalled(); + }); + + it('rejects a callback when no login was started', async () => { + await expectAsync( + handler.completeAuthorizationRequestFromUrl(callbackUrl({code: 'auth-code', state: 'any-state'})), + ).toBeRejectedWithError(/No pending authorization request/); + expect(listener).not.toHaveBeenCalled(); + }); + + it('delivers an error response from the IdP and ends the pending request', async () => { + const state = await startLogin(); + + await handler.completeAuthorizationRequestFromUrl( + callbackUrl({error: 'access_denied', error_description: 'User cancelled', state}), + ); + + const [, response, error] = listener.calls.mostRecent().args; + expect(response).toBeNull(); + expect(error?.error).toBe('access_denied'); + expect(error?.errorDescription).toBe('User cancelled'); + expect(storage.items.has(PENDING_REQUEST_KEY)).toBeFalse(); + }); + + it('rejects a callback with neither code nor error', async () => { + const state = await startLogin(); + + await expectAsync( + handler.completeAuthorizationRequestFromUrl(callbackUrl({state})), + ).toBeRejectedWithError(/neither an authorization code nor an error/); + expect(listener).not.toHaveBeenCalled(); + }); + }); +});