WIP tests cap authoriz request

This commit is contained in:
Jovan Krunić
2026-10-13 15:50:47 +02:00
parent 388e65a2e5
commit 1605295051
2 changed files with 313 additions and 0 deletions
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<void> {
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<IAuthAction>;
let authProvider: {events$: Subject<IAuthAction>; authorizationCallback: jasmine.Spy};
let authHelper: jasmine.SpyObj<Pick<AuthHelperService, 'getOriginPath' | 'deleteOriginPath'>>;
let navController: jasmine.SpyObj<Pick<NavController, 'navigateRoot'>>;
beforeEach(() => {
events = new Subject<IAuthAction>();
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();
});
});
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<string, string>();
async getItem(name: string): Promise<string | null> {
return this.items.get(name) ?? null; // eslint-disable-line unicorn/no-null
}
async removeItem(name: string): Promise<void> {
this.items.delete(name);
}
async clear(): Promise<void> {
this.items.clear();
}
async setItem(name: string, value: string): Promise<void> {
this.items.set(name, value);
}
}
/**
* The callback URL as the IdP would send it back to the app.
*/
function callbackUrl(parameters: Record<string, string>): 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<string> {
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();
});
});
});