From d1ef5731c7937818269dfac3a110426462728eb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jovan=20Kruni=C4=87?= Date: Mon, 12 Oct 2026 15:54:21 +0200 Subject: [PATCH] WIP --- frontend/app/__mocks__/@capacitor/core.ts | 64 ++++++ .../modules/auth/auth-helper.service.spec.ts | 30 ++- .../auth/auth.provider.methods.spec.ts | 78 +++++++ .../app/modules/auth/auth.provider.methods.ts | 29 ++- .../app/modules/auth/auth.provider.spec.ts | 125 ++++++++++- .../app/src/app/modules/auth/auth.provider.ts | 199 +++++++++++++----- .../app/modules/auth/capacitor-requestor.ts | 23 +- .../src/app/modules/profile/id-cards.spec.ts | 31 +-- 8 files changed, 485 insertions(+), 94 deletions(-) create mode 100644 frontend/app/__mocks__/@capacitor/core.ts create mode 100644 frontend/app/src/app/modules/auth/auth.provider.methods.spec.ts 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: {