diff --git a/frontend/app/__mocks__/@capacitor/core.ts b/frontend/app/__mocks__/@capacitor/core.ts
new file mode 100644
index 00000000..fe88d238
--- /dev/null
+++ b/frontend/app/__mocks__/@capacitor/core.ts
@@ -0,0 +1,64 @@
+/*
+ * 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 .
+ */
+
+/*
+ * tsconfig.spec.json maps every `@capacitor/*` import to this folder, so this
+ * file replaces `@capacitor/core` in all tests – including the imports made by
+ * real Capacitor plugins (registerPlugin, WebPlugin, CapacitorHttp, …).
+ *
+ * Therefore everything is re-exported from the real package (imported via a
+ * relative path, which bypasses the path mapping) and only `Capacitor` is
+ * replaced by a controllable mock. The local export shadows the one from
+ * `export *`.
+ */
+export * from '../../node_modules/@capacitor/core';
+
+type Platform = 'web' | 'ios' | 'android';
+
+export class CapacitorMock {
+ private platform: Platform = 'web';
+
+ getPlatform(): Platform {
+ return this.platform;
+ }
+
+ isNativePlatform(): boolean {
+ return this.platform !== 'web';
+ }
+
+ isPluginAvailable(_name: string): boolean {
+ return this.isNativePlatform();
+ }
+
+ convertFileSrc(filePath: string): string {
+ return filePath;
+ }
+
+ /**
+ * Test helper: simulate running on the given platform.
+ */
+ setPlatform(platform: Platform): void {
+ this.platform = platform;
+ }
+
+ /**
+ * Test helper: back to the default (web), call this in afterEach.
+ */
+ reset(): void {
+ this.platform = 'web';
+ }
+}
+
+export const Capacitor = new CapacitorMock();
diff --git a/frontend/app/src/app/modules/auth/auth-helper.service.spec.ts b/frontend/app/src/app/modules/auth/auth-helper.service.spec.ts
index 0003c66e..dedb6f48 100644
--- a/frontend/app/src/app/modules/auth/auth-helper.service.spec.ts
+++ b/frontend/app/src/app/modules/auth/auth-helper.service.spec.ts
@@ -1,3 +1,10 @@
+import {TestBed} from '@angular/core/testing';
+import {AlertController} from '@ionic/angular/standalone';
+import {TranslateService} from '@ngx-translate/core';
+
+import {SimpleBrowser} from '../../util/browser.factory';
+import {ConfigProvider} from '../config/config.provider';
+import {StorageProvider} from '../storage/storage.provider';
import {AuthActions, AuthProvider} from './auth.provider';
import {AuthHelperService} from './auth-helper.service';
@@ -38,14 +45,21 @@ describe('AuthHelperService', () => {
function createService(authConfig: unknown = {default: {endpoints: {mapping: USER_MAPPING}}}) {
configProvider.getAnyValue.and.returnValue(authConfig);
- return new AuthHelperService(
- translateService as any,
- configProvider as any,
- storageProvider as any,
- authProvider as any,
- browser as any,
- alertController as any,
- );
+ // AuthHelperService uses inject(), so it has to be created inside an injection context.
+ TestBed.resetTestingModule();
+ TestBed.configureTestingModule({
+ providers: [
+ AuthHelperService,
+ {provide: TranslateService, useValue: translateService},
+ {provide: ConfigProvider, useValue: configProvider},
+ {provide: StorageProvider, useValue: storageProvider},
+ {provide: AuthProvider, useValue: authProvider},
+ {provide: SimpleBrowser, useValue: browser},
+ {provide: AlertController, useValue: alertController},
+ ],
+ });
+
+ return TestBed.inject(AuthHelperService);
}
/** Options passed to the most recent AlertController.create() call. */
diff --git a/frontend/app/src/app/modules/auth/auth.provider.methods.spec.ts b/frontend/app/src/app/modules/auth/auth.provider.methods.spec.ts
new file mode 100644
index 00000000..be675d59
--- /dev/null
+++ b/frontend/app/src/app/modules/auth/auth.provider.methods.spec.ts
@@ -0,0 +1,78 @@
+/*
+ * 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 {Capacitor} from '@capacitor/core';
+import {SCAuthorizationProvider} from '@openstapps/core';
+
+import type {CapacitorMock} from '../../../../__mocks__/@capacitor/core';
+import {getClientConfig, getPlatformScopes} from './auth.provider.methods';
+
+const capacitor = Capacitor as unknown as CapacitorMock;
+
+const authConfig = {
+ default: {
+ client: {
+ clientId: 'client-id',
+ scopes: 'openid profile email offline_access',
+ url: 'https://idp.example.com',
+ },
+ endpoints: {
+ authorization: 'https://idp.example.com/authorize',
+ token: 'https://idp.example.com/token',
+ userinfo: 'https://idp.example.com/userinfo',
+ mapping: {},
+ },
+ } as unknown as SCAuthorizationProvider,
+};
+
+describe('auth.provider.methods', () => {
+ afterEach(() => {
+ capacitor.reset();
+ });
+
+ describe('getClientConfig', () => {
+ it('requests offline_access on iOS', () => {
+ capacitor.setPlatform('ios');
+
+ expect(getClientConfig(authConfig).scopes).toBe('openid profile email offline_access');
+ });
+
+ it('requests offline_access on Android', () => {
+ capacitor.setPlatform('android');
+
+ expect(getClientConfig(authConfig).scopes).toBe('openid profile email offline_access');
+ });
+
+ it('does not request offline_access in the browser', () => {
+ capacitor.setPlatform('web');
+
+ expect(getClientConfig(authConfig).scopes).toBe('openid profile email');
+ });
+ });
+
+ describe('getPlatformScopes', () => {
+ it('leaves scopes without offline_access unchanged in the browser', () => {
+ expect(getPlatformScopes('openid profile')).toBe('openid profile');
+ });
+
+ it('normalizes extra whitespace', () => {
+ expect(getPlatformScopes(' openid offline_access profile ')).toBe('openid profile');
+ });
+
+ it('still accepts an explicit platform flag', () => {
+ expect(getPlatformScopes('openid offline_access', true)).toBe('openid offline_access');
+ });
+ });
+});
diff --git a/frontend/app/src/app/modules/auth/auth.provider.methods.ts b/frontend/app/src/app/modules/auth/auth.provider.methods.ts
index ce051789..3ad6d009 100644
--- a/frontend/app/src/app/modules/auth/auth.provider.methods.ts
+++ b/frontend/app/src/app/modules/auth/auth.provider.methods.ts
@@ -28,6 +28,8 @@ export interface OidcEndpoints {
endSession?: string;
}
+export const OFFLINE_ACCESS_SCOPE = 'offline_access';
+
/**
* Returns the configuration of the OIDC client.
*/
@@ -39,15 +41,40 @@ export function getClientConfig(
const provider =
authConfig.default;
+ console.log(getPlatformScopes(provider.client.scopes));
+
return {
server_host: provider.client.url,
client_id: provider.client.clientId,
- scopes: provider.client.scopes,
+ scopes: getPlatformScopes(provider.client.scopes),
redirect_url: getRedirectUrl(),
pkce: true,
};
}
+/**
+ * Returns the scopes to request on the current platform.
+ *
+ * `offline_access` (long-lived refresh tokens) is only requested in the
+ * native apps, where tokens live in the Keychain / Keystore. In the browser,
+ * tokens live in web storage and are reachable by any XSS, so the session is
+ * limited to the access token lifetime instead.
+ */
+export function getPlatformScopes(
+ scopes: string,
+ isNative: boolean = Capacitor.isNativePlatform(),
+): string {
+ const scopeList = scopes
+ .split(/\s+/)
+ .filter(scope => scope.length > 0);
+
+ return (
+ isNative
+ ? scopeList
+ : scopeList.filter(scope => scope !== OFFLINE_ACCESS_SCOPE)
+ ).join(' ');
+}
+
/**
* Returns the AppAuth OAuth/OIDC service configuration.
*/
diff --git a/frontend/app/src/app/modules/auth/auth.provider.spec.ts b/frontend/app/src/app/modules/auth/auth.provider.spec.ts
index 8db72393..fdcf94cc 100644
--- a/frontend/app/src/app/modules/auth/auth.provider.spec.ts
+++ b/frontend/app/src/app/modules/auth/auth.provider.spec.ts
@@ -5,9 +5,9 @@ import {
TokenRequestHandler,
TokenResponse,
} from '@openid/appauth';
-import {BehaviorSubject, firstValueFrom} from 'rxjs';
+import {BehaviorSubject, firstValueFrom, skip} from 'rxjs';
-import {AuthProvider, IAuthAction, INVALID_GRANT} from './auth.provider';
+import {AuthActions, AuthProvider, IAuthAction, INVALID_GRANT, isInvalidGrant} from './auth.provider';
const TOKEN_RESPONSE_KEY = 'token_response';
const TOKEN_LIFETIME = 3600;
@@ -58,6 +58,13 @@ describe('AuthProvider', () => {
await provider.loadTokenFromStorage();
}
+ /** Records every auth event emitted after this call (skips the replayed last one). */
+ function recordEvents(): AuthActions[] {
+ const events: AuthActions[] = [];
+ provider.events$.pipe(skip(1)).subscribe(event => events.push(event.action));
+ return events;
+ }
+
const isAuthenticated = () => firstValueFrom(provider.isAuthenticated$);
const isLoggedIn = () => firstValueFrom(provider.isLoggedIn$);
@@ -187,6 +194,100 @@ describe('AuthProvider', () => {
expect(tokenRequest).not.toHaveBeenCalled();
});
+ it('sends only one refresh request for parallel callers', async () => {
+ await givenLoadedToken(makeToken({remainingSeconds: -60}));
+ tokenRequest.and.resolveTo(makeToken({accessToken: 'new-access-token'}));
+
+ const tokens = await Promise.all([
+ provider.getValidToken(),
+ provider.getValidToken(),
+ provider.getValidToken(),
+ ]);
+
+ expect(tokenRequest).toHaveBeenCalledTimes(1);
+ expect(tokens.map(token => token.accessToken)).toEqual([
+ 'new-access-token',
+ 'new-access-token',
+ 'new-access-token',
+ ]);
+ });
+
+ it('refreshes again after a previous refresh has finished', async () => {
+ await givenLoadedToken(makeToken({remainingSeconds: -60}));
+ tokenRequest.and.resolveTo(makeToken({accessToken: 'first', remainingSeconds: -60}));
+
+ // The first refresh returns an already expired token, so the call fails ...
+ await expectAsync(provider.getValidToken()).toBeRejected();
+
+ // ... and the next call must start a new request instead of reusing the old one.
+ tokenRequest.and.resolveTo(makeToken({accessToken: 'second'}));
+ expect((await provider.getValidToken()).accessToken).toBe('second');
+ expect(tokenRequest).toHaveBeenCalledTimes(2);
+ });
+
+ describe('without a refresh token (browser)', () => {
+ it('uses the token within the buffer instead of failing early', async () => {
+ await givenLoadedToken(makeToken({accessToken: 'current-access-token', remainingSeconds: 60, refreshToken: null}));
+
+ const token = await provider.getValidToken();
+
+ expect(token.accessToken).toBe('current-access-token');
+ expect(tokenRequest).not.toHaveBeenCalled();
+ });
+
+ it('logs the user out when the token expires while the app is open', async () => {
+ await givenLoadedToken(makeToken({remainingSeconds: 60, refreshToken: null}));
+ expect(await isLoggedIn()).toBeTrue();
+ const events = recordEvents();
+
+ jasmine.clock().tick(2 * 60 * 1000);
+
+ await expectAsync(provider.getValidToken()).toBeRejectedWithError(/No Refresh Token Available/);
+ expect(await isLoggedIn()).toBeFalse();
+ expect(internals.tokenSubject.value).toBeUndefined();
+ expect(storageRemoveItem).toHaveBeenCalledOnceWith(TOKEN_RESPONSE_KEY);
+ expect(events).toContain(AuthActions.SessionExpired);
+ expect(tokenRequest).not.toHaveBeenCalled();
+ });
+
+ it('does not restore an expired token from storage', async () => {
+ await givenLoadedToken(makeToken({remainingSeconds: -60, refreshToken: null}));
+
+ expect(await isLoggedIn()).toBeFalse();
+ expect(internals.tokenSubject.value).toBeUndefined();
+ expect(storageRemoveItem).toHaveBeenCalledOnceWith(TOKEN_RESPONSE_KEY);
+ });
+ });
+
+ describe('when refresh fails with invalid_grant from the native requestor', () => {
+ // CapacitorRequestor rejects non-2xx responses itself and keeps the OAuth body in extras.
+ const refreshError = new AppAuthError('HTTP 400', {error: INVALID_GRANT});
+
+ it('logs the user out', async () => {
+ await givenLoadedToken(makeToken({remainingSeconds: -60}));
+ const events = recordEvents();
+ tokenRequest.and.rejectWith(refreshError);
+
+ await expectAsync(provider.getValidToken()).toBeRejectedWith(refreshError);
+
+ expect(await isLoggedIn()).toBeFalse();
+ expect(storageRemoveItem).toHaveBeenCalledOnceWith(TOKEN_RESPONSE_KEY);
+ expect(events).toEqual([AuthActions.RefreshFailed, AuthActions.SessionExpired]);
+ });
+
+ it('logs the user out only once for parallel callers', async () => {
+ await givenLoadedToken(makeToken({remainingSeconds: -60}));
+ const events = recordEvents();
+ tokenRequest.and.rejectWith(refreshError);
+
+ const results = await Promise.allSettled([provider.getValidToken(), provider.getValidToken()]);
+
+ expect(results.map(result => result.status)).toEqual(['rejected', 'rejected']);
+ expect(tokenRequest).toHaveBeenCalledTimes(1);
+ expect(events.filter(event => event === AuthActions.SessionExpired).length).toBe(1);
+ });
+ });
+
describe('when refresh fails with invalid_grant', () => {
const refreshError = new AppAuthError(INVALID_GRANT);
@@ -236,4 +337,24 @@ describe('AuthProvider', () => {
});
});
});
+
+ describe('isInvalidGrant', () => {
+ it('detects invalid_grant as error message', () => {
+ expect(isInvalidGrant(new AppAuthError(INVALID_GRANT))).toBeTrue();
+ });
+
+ it('detects invalid_grant in the response body', () => {
+ expect(isInvalidGrant(new AppAuthError('HTTP 400', {error: INVALID_GRANT}))).toBeTrue();
+ });
+
+ it('ignores other OAuth errors', () => {
+ expect(isInvalidGrant(new AppAuthError('HTTP 400', {error: 'invalid_request'}))).toBeFalse();
+ });
+
+ it('ignores network and non-AppAuth errors', () => {
+ expect(isInvalidGrant(new AppAuthError('HTTP 503'))).toBeFalse();
+ expect(isInvalidGrant(new Error(INVALID_GRANT))).toBeFalse();
+ expect(isInvalidGrant(undefined)).toBeFalse();
+ });
+ });
});
diff --git a/frontend/app/src/app/modules/auth/auth.provider.ts b/frontend/app/src/app/modules/auth/auth.provider.ts
index acf6e3cc..a096af2f 100644
--- a/frontend/app/src/app/modules/auth/auth.provider.ts
+++ b/frontend/app/src/app/modules/auth/auth.provider.ts
@@ -58,7 +58,7 @@ import {storageFactory} from './factories/storage.factory';
const TOKEN_RESPONSE_KEY = 'token_response';
export const INVALID_GRANT: ErrorType = 'invalid_grant';
-const AUTH_EXPIRY_BUFFER = -3 * 60;
+const AUTH_EXPIRY_BUFFER = -10 * 60;
export interface AuthConfig {
server_host: string;
@@ -98,6 +98,15 @@ export enum AuthActions {
LoadUserInfoFailed =
'LoadUserInfoFailed',
+
+ /**
+ * The session can no longer be renewed: the access token expired without a
+ * refresh token (browser), or the IdP rejected the refresh token with
+ * invalid_grant (e.g. refresh token chain lifetime reached).
+ * The user is logged out locally.
+ */
+ SessionExpired =
+ 'SessionExpired',
}
export interface IAuthAction {
@@ -125,6 +134,31 @@ interface AuthorizationHandler {
Promise;
}
+/**
+ * Returns true if the token endpoint rejected the grant (RFC 6749, 5.2).
+ *
+ * AppAuth's BaseTokenRequestHandler only produces
+ * AppAuthError('invalid_grant') when the requestor resolves with the error
+ * body. Our requestors reject non-2xx responses themselves, so the OAuth error
+ * may also be found in the error's extras (the parsed response body).
+ */
+export function isInvalidGrant(
+ error: unknown,
+): boolean {
+ if (!(error instanceof AppAuthError)) {
+ return false;
+ }
+
+ if (error.message === INVALID_GRANT) {
+ return true;
+ }
+
+ const extras =
+ error.extras as {error?: unknown} | undefined;
+
+ return extras?.error === INVALID_GRANT;
+}
+
class NoHashQueryStringUtils extends BasicQueryStringUtils {
override parse(
input: LocationLike,
@@ -183,6 +217,13 @@ export class AuthProvider {
private notifierInitialized = false;
+ /**
+ * Refresh request currently in progress. Parallel callers share it, so the
+ * same refresh token is never sent twice (with refresh token rotation the
+ * second request would fail with invalid_grant and log the user out).
+ */
+ private refreshInFlight?: Promise;
+
constructor(
private readonly platform: Platform,
private readonly configProvider: ConfigProvider,
@@ -353,11 +394,12 @@ export class AuthProvider {
public async refreshToken():
Promise {
try {
- await this.requestTokenRefresh();
- } catch (error) {
- await this.handleRefreshFailure(
- error,
- );
+ await this.refreshSession();
+ } catch {
+ /*
+ * Already handled by refreshSession(): the session is expired on
+ * invalid_grant and kept on temporary errors.
+ */
}
}
@@ -505,6 +547,23 @@ export class AuthProvider {
JSON.parse(stored),
);
+ /*
+ * Expired and not renewable (browser, no refresh token):
+ * this session is over, do not show the user as logged in.
+ */
+ if (
+ !token.isValid(0) &&
+ !token.refreshToken
+ ) {
+ await this.storage.removeItem(
+ TOKEN_RESPONSE_KEY,
+ );
+
+ throw new Error(
+ 'Stored Token Expired',
+ );
+ }
+
this.notify({
action:
AuthActions
@@ -535,35 +594,40 @@ export class AuthProvider {
* expiry buffer.
*/
if (currentToken?.isValid(buffer)) {
- console.log(currentToken?.isValid(buffer));
return currentToken;
}
- /*
- * Access token is expired or will expire soon.
- * We can only recover automatically if a refresh token exists.
- */
if (!currentToken?.refreshToken) {
+ /*
+ * Without a refresh token (browser) nothing can be renewed early,
+ * so the buffer is pointless: use the token until it really expires.
+ */
+ if (currentToken?.isValid(0)) {
+ return currentToken;
+ }
+
+ /*
+ * Expired and not renewable: the session is over.
+ */
+ if (currentToken) {
+ await this.expireSession(
+ new Error('Access Token Expired Without Refresh Token'),
+ );
+ }
+
throw new Error(
'Unable To Obtain Valid Token: No Refresh Token Available',
);
}
/*
- * Refresh immediately.
+ * Access token is expired or will expire soon: refresh.
*
- * requestTokenRefresh() stores the newly returned token
- * and updates tokenSubject via RefreshSuccess.
+ * refreshSession() stores the newly returned token and updates
+ * tokenSubject via RefreshSuccess. On invalid_grant it has already
+ * logged the user out.
*/
- try {
- await this.requestTokenRefresh();
- } catch (error) {
- await this.handleRefreshFailure(
- error,
- );
-
- throw error;
- }
+ await this.refreshSession();
const refreshedToken =
this.tokenSubject.value;
@@ -583,53 +647,71 @@ export class AuthProvider {
return refreshedToken;
}
+ /**
+ * Refreshes the token once, shared by all parallel callers.
+ * Rejects with the original error after it has been handled.
+ */
+ private refreshSession(): Promise {
+ this.refreshInFlight ??= (async () => {
+ try {
+ await this.requestTokenRefresh();
+ } catch (error) {
+ await this.handleRefreshFailure(
+ error,
+ );
+
+ throw error;
+ }
+ })().finally(() => {
+ this.refreshInFlight = undefined;
+ });
+
+ return this.refreshInFlight;
+ }
+
private async handleRefreshFailure(
error: unknown,
): Promise {
+ this.notify({
+ action:
+ AuthActions.RefreshFailed,
+
+ error,
+ });
+
/*
- * AppAuth maps OAuth token endpoint errors to AppAuthError.
- * This OAuth error means that the refresh token is no longer
- * usable, for example because it expired, was revoked or
- * the refresh-token chain lifetime has been reached.
+ * invalid_grant means that the refresh token is no longer usable,
+ * for example because it expired, was revoked or the refresh-token
+ * chain lifetime has been reached. Log the user out.
*
- * Network and other temporary errors must not log the user
- * out.
+ * Network and other temporary errors must not log the user out.
*/
- if (
- error instanceof AppAuthError &&
- error.message === INVALID_GRANT
- ) {
- try {
- await this.storage.removeItem(
- TOKEN_RESPONSE_KEY,
- );
- } catch {
- /*
- * Do not hide the original OAuth error if removing the
- * locally stored token fails.
- */
- }
-
- this.tokenSubject.next(
- undefined,
+ if (isInvalidGrant(error)) {
+ await this.expireSession(
+ error,
);
+ }
+ }
- this.userSubject.next(
- undefined,
- );
-
- this.authenticatedSubject.next(
- false,
- );
-
- this.loggedInSubject.next(
- false,
+ /**
+ * Ends the local session because it cannot be renewed anymore.
+ */
+ private async expireSession(
+ error: unknown,
+ ): Promise {
+ try {
+ await this.storage.removeItem(
+ TOKEN_RESPONSE_KEY,
);
+ } catch {
+ /*
+ * Still log out in memory if removing the stored token fails.
+ */
}
this.notify({
action:
- AuthActions.RefreshFailed,
+ AuthActions.SessionExpired,
error,
});
@@ -983,7 +1065,8 @@ export class AuthProvider {
switch (action.action) {
case AuthActions.SignInFailed:
case AuthActions.SignOutSuccess:
- case AuthActions.SignOutFailed: {
+ case AuthActions.SignOutFailed:
+ case AuthActions.SessionExpired: {
this.tokenSubject.next(
undefined,
);
diff --git a/frontend/app/src/app/modules/auth/capacitor-requestor.ts b/frontend/app/src/app/modules/auth/capacitor-requestor.ts
index 204a94a3..84e5be17 100644
--- a/frontend/app/src/app/modules/auth/capacitor-requestor.ts
+++ b/frontend/app/src/app/modules/auth/capacitor-requestor.ts
@@ -62,15 +62,34 @@ export class CapacitorRequestor implements Requestor {
const response = await CapacitorHttp.request(options);
if (response.status < 200 || response.status >= 300) {
+ const body = this.parseBody(response.data);
+
+ /*
+ * Use the OAuth error code (e.g. 'invalid_grant') as message when the
+ * server sent one (RFC 6749, 5.2), so callers can tell a rejected
+ * refresh token apart from temporary failures.
+ */
throw new AppAuthError(
- `HTTP ${response.status}`,
- response.data,
+ typeof body?.error === 'string' ? body.error : `HTTP ${response.status}`,
+ body ?? response.data,
);
}
return response.data as T;
}
+ private parseBody(data: unknown): Record | undefined {
+ if (typeof data === 'string') {
+ try {
+ data = JSON.parse(data);
+ } catch {
+ return undefined;
+ }
+ }
+
+ return data !== null && typeof data === 'object' ? (data as Record) : undefined;
+ }
+
private getHeaders(headers: XhrSettings['headers']): HttpHeaders {
const result: HttpHeaders = {};
diff --git a/frontend/app/src/app/modules/profile/id-cards.spec.ts b/frontend/app/src/app/modules/profile/id-cards.spec.ts
index 52dbc6b4..488b1f1e 100644
--- a/frontend/app/src/app/modules/profile/id-cards.spec.ts
+++ b/frontend/app/src/app/modules/profile/id-cards.spec.ts
@@ -2,18 +2,18 @@ import {ConfigProvider} from '../config/config.provider';
import {IdCardsProvider} from './id-cards.provider';
import {HttpClient} from '@angular/common/http';
import {AuthHelperService} from '../auth/auth-helper.service';
+import {AuthProvider} from '../auth/auth.provider';
import {BehaviorSubject, firstValueFrom, of} from 'rxjs';
import {EncryptedStorageProvider} from '../storage/encrypted-storage.provider';
import {TestBed} from '@angular/core/testing';
import {TranslateService} from '@ngx-translate/core';
-import {StorageBackend, Requestor} from '@openid/appauth';
import {LoggerTestingModule} from 'ngx-logger/testing';
-import {StAppsWebHttpClient} from '../data/stapps-web-http-client.provider';
-import {SimpleBrowser} from '../../util/browser.factory';
class FakeAuth {
isLoggedIn$ = new BehaviorSubject(false);
+ user$ = new BehaviorSubject(undefined);
+
// eslint-disable-next-line @typescript-eslint/no-empty-function
getValidToken() {}
}
@@ -24,16 +24,14 @@ describe('IdCards', () => {
let encryptedStorageProvider: EncryptedStorageProvider;
let fakeAuth: FakeAuth;
- const authHelperServiceMock = jasmine.createSpyObj('AuthHelperService', ['getProvider']);
+ const authHelperServiceMock = jasmine.createSpyObj('AuthHelperService', ['getUserFromUserInfo']);
const translateServiceSpy = jasmine.createSpyObj('TranslateService', ['setDefaultLang', 'use']);
- let storageBackendSpy: jasmine.SpyObj;
- const simpleBrowserMock = jasmine.createSpyObj('SimpleBrowser', ['open']);
beforeEach(() => {
- storageBackendSpy = jasmine.createSpyObj('StorageBackend', ['getItem']);
+ fakeAuth = new FakeAuth();
configProviderSpy = jasmine.createSpyObj('ConfigProvider', ['config', 'getAnyValue']);
configProviderSpy.config = {
- app: {features: {extern: {idCards: {url: 'http://id-cards.local', authProvider: 'fakeAuth'}}}},
+ app: {features: {extern: {idCards: {url: 'http://id-cards.local', authProvider: 'default'}}}},
} as never;
configProviderSpy.getAnyValue = () =>
({
@@ -61,7 +59,6 @@ describe('IdCards', () => {
imports: [LoggerTestingModule],
providers: [
IdCardsProvider,
- StAppsWebHttpClient,
{
provide: TranslateService,
useValue: translateServiceSpy,
@@ -70,40 +67,29 @@ describe('IdCards', () => {
provide: EncryptedStorageProvider,
useValue: encryptedStorageProvider,
},
- IonicStorage,
{
provide: ConfigProvider,
useValue: configProviderSpy,
},
- Browser,
- {
- provide: StorageBackend,
- useValue: storageBackendSpy,
- },
{
provide: AuthHelperService,
useValue: authHelperServiceMock,
},
{
- provide: SimpleBrowser,
- useValue: simpleBrowserMock,
+ provide: AuthProvider,
+ useValue: fakeAuth,
},
{
provide: HttpClient,
useValue: httpClient,
},
- Requestor,
],
});
-
- fakeAuth = new FakeAuth();
- authHelperServiceMock.getProvider = jasmine.createSpy().and.returnValue(fakeAuth);
});
it('should emit undefined if not logged in', async () => {
const provider = TestBed.inject(IdCardsProvider);
expect(await firstValueFrom(provider.getIdCards())).toEqual([]);
- expect(authHelperServiceMock.getProvider).toHaveBeenCalledTimes(1);
});
it('should emit network result when logged in', async () => {
@@ -112,7 +98,6 @@ describe('IdCards', () => {
fakeAuth.getValidToken = jasmine.createSpy().and.resolveTo({accessToken: 'fake-token'});
const provider = TestBed.inject(IdCardsProvider);
expect(await firstValueFrom(provider.getIdCards())).toEqual(['abc' as never]);
- expect(authHelperServiceMock.getProvider).toHaveBeenCalledTimes(1);
// eslint-disable-next-line unicorn/no-null
expect(httpClient.get).toHaveBeenCalledOnceWith('http://id-cards.local', {
headers: {