diff --git a/.gitignore b/.gitignore index cb2deee..3aded9b 100644 --- a/.gitignore +++ b/.gitignore @@ -82,3 +82,5 @@ lib/ # React Native Codegen ios/generated android/generated + +/coverage/ diff --git a/jest-setup.ts b/jest-setup.ts new file mode 100644 index 0000000..8f96f5d --- /dev/null +++ b/jest-setup.ts @@ -0,0 +1,13 @@ +import '@testing-library/react-native/extend-expect'; + +jest.mock('react-native-app-auth', () => ({ + authorize: jest.fn(), + refresh: jest.fn(), +})); + +jest.mock('react-native-encrypted-storage', () => ({ + setItem: jest.fn(() => Promise.resolve()), + getItem: jest.fn(() => Promise.resolve(null)), + removeItem: jest.fn(() => Promise.resolve()), + clear: jest.fn(() => Promise.resolve()), +})); diff --git a/jest.config.json b/jest.config.json index 9ecdc33..eb9af2d 100644 --- a/jest.config.json +++ b/jest.config.json @@ -1,5 +1,17 @@ { "preset": "react-native", + "setupFilesAfterEnv": ["./jest-setup.ts"], + "transform": { + "^.+\\.(js)$": [ + "babel-jest", + { + "plugins": [ + "babel-plugin-syntax-hermes-parser" + ] + } + ], + "^.+\\.(ts|tsx)$": "babel-jest" + }, "modulePathIgnorePatterns": [ "./example/node_modules", "./expoExample/node_modules", diff --git a/package.json b/package.json index 19ebb73..881d936 100644 --- a/package.json +++ b/package.json @@ -70,9 +70,12 @@ "@commitlint/config-conventional": "^17.0.2", "@evilmartians/lefthook": "^1.5.0", "@react-native-community/cli": "15.0.1", + "@react-native/babel-preset": "0.76.3", "@react-native/eslint-config": "^0.73.1", "@release-it/conventional-changelog": "^9.0.2", + "@testing-library/react-native": "^12.9.0", "@types/jest": "^29.5.5", + "@types/lodash": "^4.17.13", "@types/react": "^18.2.44", "commitlint": "^17.0.2", "del-cli": "^5.1.0", @@ -86,6 +89,7 @@ "react-native-app-auth": "^8.0.0", "react-native-builder-bob": "^0.32.1", "react-native-encrypted-storage": "^4.0.3", + "react-test-renderer": "18.3.1", "release-it": "^17.10.0", "turbo": "^1.10.7", "typescript": "^5.2.2" @@ -135,5 +139,8 @@ "type": "module-legacy", "languages": "kotlin-objc", "version": "0.44.1" + }, + "dependencies": { + "lodash": "^4.17.21" } } diff --git a/src/Contentpass.test.ts b/src/Contentpass.test.ts new file mode 100644 index 0000000..f71cd64 --- /dev/null +++ b/src/Contentpass.test.ts @@ -0,0 +1,419 @@ +import type { ContentpassConfig } from './types/ContentpassConfig'; +import Contentpass from './Contentpass'; +import type { AuthorizeResult } from 'react-native-app-auth'; +import * as AppAuthModule from 'react-native-app-auth'; +import * as OidcAuthStateStorageModule from './OidcAuthStateStorage'; +import type { ContentpassState } from './types/ContentpassState'; +import OidcAuthStateStorage from './OidcAuthStateStorage'; +import * as FetchContentpassTokenModule from './utils/fetchContentpassToken'; +import { SCOPES } from './consts/oidcConsts'; + +const config: ContentpassConfig = { + propertyId: 'propertyId-1', + redirectUrl: 'de.test.net://oauth', + issuer: 'https://issuer.net', +}; + +const NOW = new Date('2024-12-02T11:53:56.272Z').getTime(); + +const EXAMPLE_AUTH_RESULT: AuthorizeResult = { + accessToken: + 'eyJhbGciOiJSUzI1NiIsInR5cCI6ImF0K2p3dCIsImtpZCI6IjY5NzUwYTZjLTNmYjctNDUyNi05NWY4LTVhZmYxMmIyZjFjOSJ9.eyJqdGkiOiIwNXdQZk83SFNvcVBjUlE3ci00TmEiLCJzdWIiOiI4OWNiNjZkNi05NzgzLTRkMjktODQ0Zi0zYjc0MWUxYmQxNjMiLCJpYXQiOjE3MzMxMzc3MzAsImV4cCI6MTczMzE0MTMzMCwic2NvcGUiOiJvcGVuaWQgY29udGVudHBhc3Mgb2ZmbGluZV9hY2Nlc3MiLCJjbGllbnRfaWQiOiJjYzNmYzRhZC1jYmU1LTRkMDktYmY4NS1hNDk3OTY2MDNiMTkiLCJpc3MiOiJodHRwczovL215LmNvbnRlbnRwYXNzLmRldiIsImF1ZCI6ImNjM2ZjNGFkLWNiZTUtNGQwOS1iZjg1LWE0OTc5NjYwM2IxOSJ9.F7NdahJ2xB9CuhXkZXKZH2F20Az1MlWb4GUrYpK1ZdY_nbFNZFjUSNubVxlilGzgevLXl4yh2ANr7e3Kbl6dc-1AwzGfBJRNc2Zch9UHRIqZk_4uOIMgNtDOvBqzC9aZAS--MM3wEdriTEIBdubuMLSiKDPq2AgKAminL7fu6aSAFAhqe0ynVtB8IwUaAfoDHH0XtnKVaBQz03CNmepwaXJ4UEk2Ko8o_AW8eLjOX85ITegNSCCiFGW-zDmELF1mgRxzLDEuUAKyX3p8rtlT-DJdJD6u-gwEWW6g27WBlB4qnIm2tsjZrPjKL4-ZC4jabwKdZNes8D5kahQuFfoRSw', + accessTokenExpirationDate: '2024-12-02T12:08:50Z', + authorizeAdditionalParameters: { iss: 'https://my.demo.dev' }, + idToken: + 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjY5NzUwYTZjLTNmYjctNDUyNi05NWY4LTVhZmYxMmIyZjFjOSJ9.eyJzdWIiOiI4OWNiNjZkNi05NzgzLTRkMjktODQ0Zi0zYjc0MWUxYmQxNjMiLCJzdWJzY3JpcHRpb25zIjpbXSwiY2FuY2VsbGVkU3Vic2NyaXB0aW9ucyI6W10sImlzY3AiOnRydWUsImlzQWRtaW4iOmZhbHNlLCJub25jZSI6InRtbVluZjc1WUNXS0hDSmZaN1RSUGFVVUFheDZ1RVVPcFM4QjZVTW5EWGsiLCJhdF9oYXNoIjoiN1hFcjJBaWZtMWZtaG94QXV2TG9TdyIsImF1ZCI6ImNjM2ZjNGFkLWNiZTUtNGQwOS1iZjg1LWE0OTc5NjYwM2IxOSIsImV4cCI6MTczMzMxNDEzMCwiaWF0IjoxNzMzMTM3NzMwLCJpc3MiOiJodHRwczovL215LmNvbnRlbnRwYXNzLmRldiJ9.ZC3QZN7OTmgLXqBouHzzmhGKWR9vTvJ_OrNB6cM5A90TTD-jPRx5-5lXoZEUuI1LGUMu3TDGphTzr945Ck0C5ArRACiilJy2RHUZPh_cEgdMXQLuMxwBT1Uw5uQAXXMdjjUV2GV3a7KNxJmcWkueHqvJBDmIK5iLUwDuhks_Qs6mE6XpPDdJSToIIaM2x5J2eD5XNaY6YcE93Uum6Qwbu_NrpWE77OwzA2NWqBnpPnTJ-U4BcZcAt1u_j9X-EZygWUF34cZvyZb6REFBDA3IuxGSZ-ZVmVKoPvOpgNoxoNf0l5Cg-0WqE7mgY1M9aFiirwnEbDHYeqYgoPopm3KoKw', + refreshToken: 'vSYGtY9lMOsVQaolJJO_TwTjtO0UrjS1Ie5HQ4Yg4WQ', + scopes: [], + tokenAdditionalParameters: {}, + tokenType: 'Bearer', + authorizationCode: '', +}; + +const EXAMPLE_REFRESH_RESULT = { + ...EXAMPLE_AUTH_RESULT, + accessTokenExpirationDate: '2024-12-03T10:00:50Z', +}; + +describe('Contentpass', () => { + let contentpass: Contentpass; + let authorizeSpy: jest.SpyInstance; + let refreshSpy: jest.SpyInstance; + let fetchContentpassTokenSpy: jest.SpyInstance; + let oidcAuthStorageMock: OidcAuthStateStorage; + + beforeEach(() => { + jest.useFakeTimers({ now: NOW }); + authorizeSpy = jest + .spyOn(AppAuthModule, 'authorize') + .mockResolvedValue(EXAMPLE_AUTH_RESULT); + refreshSpy = jest + .spyOn(AppAuthModule, 'refresh') + .mockResolvedValue(EXAMPLE_REFRESH_RESULT); + + oidcAuthStorageMock = { + storeOidcAuthState: jest.fn(), + getOidcAuthState: jest.fn(), + clearOidcAuthState: jest.fn(), + } as any as OidcAuthStateStorage; + + jest + .spyOn(OidcAuthStateStorageModule, 'default') + .mockReturnValue(oidcAuthStorageMock); + + fetchContentpassTokenSpy = jest + .spyOn(FetchContentpassTokenModule, 'default') + .mockResolvedValue( + 'eyJhbGciOiJSUzI1NiIsImtpZCI6IjY5NzUwYTZjLTNmYjctNDUyNi05NWY4LTVhZmYxMmIyZjFjOSJ9.eyJhdXRoIjp0cnVlLCJ0eXBlIjoiY3AiLCJwbGFucyI6WyIwYWNhZTkxNy1iZTk5LTQ4ZWEtYjhmMS0yMGZhNjhhNDdkM2EiLCI0NDIxNjI4Yy05NjA2LTRjMDEtOGU1ZC1jMmE5YmNhNjhhYjQiLCI3ZThkZTBjYy0zZTk3LTQ5YTItODgxZC05ZmZiNWI4NDE1MTUiLCJhNDcyMWRiNS02N2RmLTQxNDUtYmJiZi1jYmQwOWY3ZTAzOTciLCJjNGQzYjBmNS05ODlhLTRmN2ItOGFjNy0zZDhmZmE5NTcxN2YiLCI2NGRkOTkwNS05NmUxLTRmYjItOTgwZC01MDdmMTYzNzVmZTkiXSwiYXVkIjoiY2MzZmM0YWQiLCJpYXQiOjE3MzMxMzU2ODEsImV4cCI6MTczMzMxMjA4MX0.CMtH7HRLf2HVgw3_cZRN0en8tml_SQKM73iLGJAp72-vJuRJaq85xBp6Jgy9WD3L7x4itRlBAYZxX8tLxZGogU0WP4_dMGFQ2QlcwKshwJygwRM1YqvxGWX2Az_KxEMc2QGHvpE1qe2MAr_xOU7VFfc0-vWxFc3hRzpAM5j7YHctj2t1v6h9-M7V2Hkcn37569QmtgU8gJkUxXsgUTufbb1ikjjjAvnjvTluHJo51_utbimpUbCk3EFxXVCVEI_pAqiZQXNninUQ6dbSujLb3L2UlEdQzLeUiBdYroeFzSyruLrR841ledLQ5ZP2OqzF5oUMuAGVOOhmgGdwGMCDRQ' + ); + + contentpass = new Contentpass(config); + }); + + afterEach(() => { + jest.resetAllMocks(); + jest.restoreAllMocks(); + jest.useRealTimers(); + }); + + describe('constructor', () => { + it('should initialise contentpass state', () => { + const contentpassStates: ContentpassState[] = []; + contentpass.registerObserver((state) => { + contentpassStates.push(state); + }); + expect(contentpassStates).toHaveLength(1); + expect(contentpassStates[0]).toEqual({ + state: 'UNAUTHENTICATED', + hasValidSubscription: false, + }); + }); + + it('should initialise with previous state if exists in storage', async () => { + (oidcAuthStorageMock.getOidcAuthState as jest.Mock).mockResolvedValue( + EXAMPLE_AUTH_RESULT + ); + contentpass = new Contentpass(config); + const contentpassStates: ContentpassState[] = []; + contentpass.registerObserver((state) => { + contentpassStates.push(state); + }); + + await jest.advanceTimersByTimeAsync(100); + + expect(contentpassStates).toHaveLength(2); + expect(contentpassStates[0]).toEqual({ + state: 'INITIALISING', + }); + expect(contentpassStates[1]).toEqual({ + state: 'AUTHENTICATED', + hasValidSubscription: true, + }); + }); + + it('should refresh token instantly if the token from previous state expired', async () => { + (oidcAuthStorageMock.getOidcAuthState as jest.Mock).mockResolvedValue({ + ...EXAMPLE_AUTH_RESULT, + accessTokenExpirationDate: '2024-12-02T11:53:56.272Z', + }); + contentpass = new Contentpass(config); + const contentpassStates: ContentpassState[] = []; + contentpass.registerObserver((state) => { + contentpassStates.push(state); + }); + + expect(contentpassStates).toHaveLength(1); + expect(contentpassStates[0]).toEqual({ + state: 'INITIALISING', + }); + + await jest.advanceTimersByTimeAsync(100); + + expect(refreshSpy).toHaveBeenCalledTimes(1); + expect(refreshSpy).toHaveBeenCalledWith( + { + clientId: 'propertyId-1', + redirectUrl: 'de.test.net://oauth', + issuer: 'https://issuer.net', + scopes: SCOPES, + }, + { refreshToken: EXAMPLE_AUTH_RESULT.refreshToken } + ); + expect(contentpassStates).toHaveLength(2); + expect(contentpassStates[1]).toEqual({ + state: 'AUTHENTICATED', + hasValidSubscription: true, + }); + }); + }); + + describe('authenticate', () => { + it('should call authorize with the correct parameters', async () => { + await contentpass.authenticate(); + + expect(authorizeSpy).toHaveBeenCalledWith({ + additionalParameters: { + cp_property: 'propertyId-1', + cp_route: 'login', + prompt: 'consent', + }, + clientId: 'propertyId-1', + issuer: 'https://issuer.net', + redirectUrl: 'de.test.net://oauth', + scopes: ['openid', 'offline_access', 'contentpass'], + }); + }); + + it('should change contentpass state to error when authorize throws an error', async () => { + const contentpassStates: ContentpassState[] = []; + authorizeSpy.mockRejectedValue(new Error('Authorize error')); + contentpass.registerObserver((state) => { + contentpassStates.push(state); + }); + + await contentpass.authenticate(); + + expect(contentpassStates).toHaveLength(2); + expect(contentpassStates[1]).toEqual({ + state: 'ERROR', + error: 'Authorize error', + }); + }); + + it('should fetch contentpass token and validate it after successful authorize', async () => { + const contentpassStates: ContentpassState[] = []; + contentpass.registerObserver((state) => { + contentpassStates.push(state); + }); + + await contentpass.authenticate(); + + expect(oidcAuthStorageMock.storeOidcAuthState).toHaveBeenCalledWith( + EXAMPLE_AUTH_RESULT + ); + expect(contentpassStates).toHaveLength(2); + expect(contentpassStates[1]).toEqual({ + state: 'AUTHENTICATED', + hasValidSubscription: true, + }); + }); + + it('should set error state if fetching contentpass token fails after successful authorize', async () => { + const contentpassStates: ContentpassState[] = []; + contentpass.registerObserver((state) => { + contentpassStates.push(state); + }); + fetchContentpassTokenSpy.mockRejectedValue(new Error('Fetch error')); + + await contentpass.authenticate(); + + expect(oidcAuthStorageMock.storeOidcAuthState).toHaveBeenCalledWith( + EXAMPLE_AUTH_RESULT + ); + expect(contentpassStates).toHaveLength(2); + expect(contentpassStates[1]).toEqual({ + state: 'ERROR', + error: 'Fetch error', + }); + }); + + it('should setup a refresh token timer', async () => { + const contentpassStates: ContentpassState[] = []; + contentpass.registerObserver((state) => { + contentpassStates.push(state); + }); + + await contentpass.authenticate(); + + const expirationDate = new Date( + EXAMPLE_AUTH_RESULT.accessTokenExpirationDate + ).getTime(); + const expectedDelay = expirationDate - NOW; + + expect(refreshSpy).toBeCalledTimes(0); + jest.advanceTimersByTime(expectedDelay); + expect(refreshSpy).toBeCalledTimes(1); + expect(refreshSpy).toBeCalledWith( + { + clientId: 'propertyId-1', + redirectUrl: 'de.test.net://oauth', + issuer: 'https://issuer.net', + scopes: SCOPES, + }, + { refreshToken: EXAMPLE_AUTH_RESULT.refreshToken } + ); + + expect(contentpassStates).toHaveLength(2); + expect(contentpassStates[1]).toEqual({ + state: 'AUTHENTICATED', + hasValidSubscription: true, + }); + }); + + it('should not setup a refresh token timer if no expiration date is available', async () => { + const contentpassStates: ContentpassState[] = []; + contentpass.registerObserver((state) => { + contentpassStates.push(state); + }); + + authorizeSpy.mockResolvedValue({ + ...EXAMPLE_AUTH_RESULT, + accessTokenExpirationDate: undefined, + }); + + await contentpass.authenticate(); + + expect(refreshSpy).toBeCalledTimes(0); + + expect(contentpassStates).toHaveLength(2); + expect(contentpassStates[1]).toEqual({ + state: 'AUTHENTICATED', + hasValidSubscription: true, + }); + }); + + it('should set error state if refresh token fails', async () => { + const contentpassStates: ContentpassState[] = []; + contentpass.registerObserver((state) => { + contentpassStates.push(state); + }); + + await contentpass.authenticate(); + + expect(contentpassStates).toHaveLength(2); + expect(contentpassStates[1]).toEqual({ + state: 'AUTHENTICATED', + hasValidSubscription: true, + }); + + const expirationDate = new Date( + EXAMPLE_AUTH_RESULT.accessTokenExpirationDate + ).getTime(); + const expectedDelay = expirationDate - NOW; + + refreshSpy.mockRejectedValue(new Error('Refresh error')); + + await jest.advanceTimersByTimeAsync(expectedDelay); + expect(contentpassStates).toHaveLength(2); + expect(contentpassStates[1]).toEqual({ + state: 'AUTHENTICATED', + hasValidSubscription: true, + }); + + await jest.advanceTimersByTimeAsync(30000); + expect(contentpassStates).toHaveLength(2); + expect(contentpassStates[1]).toEqual({ + state: 'AUTHENTICATED', + hasValidSubscription: true, + }); + + // after 6 retries the state should change to error + await jest.advanceTimersByTimeAsync(180001); + expect(contentpassStates).toHaveLength(3); + expect(contentpassStates[2]).toEqual({ + state: 'UNAUTHENTICATED', + hasValidSubscription: false, + }); + }); + }); + + describe('registerObserver', () => { + it('should call the observer with the current state', () => { + const observer = jest.fn(); + contentpass.registerObserver(observer); + + expect(observer).toHaveBeenCalledWith({ + state: 'UNAUTHENTICATED', + hasValidSubscription: false, + }); + }); + + it('should call the observer with the new state when the state changes', async () => { + const observer = jest.fn(); + contentpass.registerObserver(observer); + + await contentpass.authenticate(); + + expect(observer).toHaveBeenLastCalledWith({ + state: 'AUTHENTICATED', + hasValidSubscription: true, + }); + }); + + it('should not add the same observer twice', () => { + const observer = jest.fn(); + contentpass.registerObserver(observer); + contentpass.registerObserver(observer); + + expect(observer).toHaveBeenCalledTimes(1); + }); + }); + + describe('unregisterObserver', () => { + it('should remove the observer', () => { + const observer = jest.fn(); + contentpass.registerObserver(observer); + expect(observer).toHaveBeenCalledTimes(1); + + contentpass.unregisterObserver(observer); + + contentpass.authenticate(); + + expect(observer).toHaveBeenCalledTimes(1); + }); + }); + + describe('logout', () => { + it('should clear the auth state and change the state', async () => { + const contentpassStates: ContentpassState[] = []; + contentpass.registerObserver((state) => { + contentpassStates.push(state); + }); + + await contentpass.authenticate(); + + expect(contentpassStates[1]).toEqual({ + state: 'AUTHENTICATED', + hasValidSubscription: true, + }); + + await contentpass.logout(); + + expect(oidcAuthStorageMock.clearOidcAuthState).toHaveBeenCalled(); + expect(contentpassStates).toHaveLength(3); + expect(contentpassStates[2]).toEqual({ + state: 'UNAUTHENTICATED', + hasValidSubscription: false, + }); + }); + }); + + describe('recoverFromError', () => { + it('should change the state to INITIALISING and call initialiseAuthState', async () => { + const contentpassStates: ContentpassState[] = []; + contentpass.registerObserver((state) => { + contentpassStates.push(state); + }); + + authorizeSpy.mockRejectedValue(new Error('Authorize error')); + + await contentpass.authenticate(); + + expect(contentpassStates[1]).toEqual({ + state: 'ERROR', + error: 'Authorize error', + }); + + await contentpass.recoverFromError(); + + expect(contentpassStates).toHaveLength(4); + expect(contentpassStates[2]).toEqual({ + state: 'INITIALISING', + }); + expect(contentpassStates[3]).toEqual({ + state: 'UNAUTHENTICATED', + hasValidSubscription: false, + }); + }); + }); +}); diff --git a/src/Contentpass.ts b/src/Contentpass.ts index ba31fc7..0e07996 100644 --- a/src/Contentpass.ts +++ b/src/Contentpass.ts @@ -1,3 +1,4 @@ +import { isEqual } from 'lodash'; import OidcAuthStateStorage, { type OidcAuthState, } from './OidcAuthStateStorage'; @@ -112,7 +113,8 @@ export default class Contentpass { await this.authStateStorage.storeOidcAuthState(authState); const strategy = this.setupRefreshTimer(); - if (strategy !== RefreshTokenStrategy.TIMER_SET) { + // if instant refresh, no need to check subscription as it will happen in the refresh + if (strategy === RefreshTokenStrategy.INSTANTLY) { return; } @@ -164,6 +166,7 @@ export default class Contentpass { private refreshToken = async (counter: number) => { if (!this.oidcAuthState?.refreshToken) { + // FIXME: logger for error return; } @@ -197,17 +200,15 @@ export default class Contentpass { return; } - this.changeContentpassState({ - state: ContentpassStateType.UNAUTHENTICATED, - hasValidSubscription: false, - }); - await this.authStateStorage.clearOidcAuthState(); + await this.logout(); }; private changeContentpassState = (state: ContentpassState) => { + if (isEqual(this.contentpassState, state)) { + return; + } + this.contentpassState = state; this.contentpassStateObservers.forEach((observer) => observer(state)); - - return this.contentpassState; }; } diff --git a/src/OidcAuthStateStorage.test.ts b/src/OidcAuthStateStorage.test.ts new file mode 100644 index 0000000..e6b0a12 --- /dev/null +++ b/src/OidcAuthStateStorage.test.ts @@ -0,0 +1,62 @@ +import EncryptedStorage from 'react-native-encrypted-storage'; +import OidcAuthStateStorage, { + type OidcAuthState, +} from './OidcAuthStateStorage'; + +describe('OidcAuthStateStorage', () => { + const CLIENT_ID = 'test-client-id'; + const EXPECTED_STORAGE_KEY = `de.contentpass.${CLIENT_ID}-OIDCAuthState`; + + let storage: OidcAuthStateStorage; + const mockAuthState: OidcAuthState = { + accessToken: 'test-access-token', + accessTokenExpirationDate: '2023-12-31T23:59:59Z', + idToken: 'test-id-token', + refreshToken: 'test-refresh-token', + tokenType: 'Bearer', + }; + + beforeEach(() => { + storage = new OidcAuthStateStorage(CLIENT_ID); + }); + + afterEach(() => { + jest.resetAllMocks(); + jest.restoreAllMocks(); + }); + + it('should store OIDC auth state', async () => { + await storage.storeOidcAuthState(mockAuthState); + + expect(EncryptedStorage.setItem).toHaveBeenCalledWith( + EXPECTED_STORAGE_KEY, + '{"accessToken":"test-access-token","accessTokenExpirationDate":"2023-12-31T23:59:59Z","idToken":"test-id-token","refreshToken":"test-refresh-token","tokenType":"Bearer"}' + ); + }); + + it('should get OIDC auth state', async () => { + (EncryptedStorage.getItem as jest.Mock).mockResolvedValue( + '{"accessToken":"test-access-token","accessTokenExpirationDate":"2023-12-31T23:59:59Z","idToken":"test-id-token","refreshToken":"test-refresh-token","tokenType":"Bearer"}' + ); + const result = await storage.getOidcAuthState(); + + expect(EncryptedStorage.getItem).toHaveBeenCalledWith(EXPECTED_STORAGE_KEY); + expect(result).toEqual(mockAuthState); + }); + + it('should return undefined if no OIDC auth state is found', async () => { + (EncryptedStorage.getItem as jest.Mock).mockResolvedValue(null); + const result = await storage.getOidcAuthState(); + + expect(EncryptedStorage.getItem).toHaveBeenCalledWith(EXPECTED_STORAGE_KEY); + expect(result).toBeUndefined(); + }); + + it('should clear OIDC auth state', async () => { + await storage.clearOidcAuthState(); + + expect(EncryptedStorage.removeItem).toHaveBeenCalledWith( + EXPECTED_STORAGE_KEY + ); + }); +}); diff --git a/src/sdkContext/ContentpassSdkProvder.test.tsx b/src/sdkContext/ContentpassSdkProvder.test.tsx new file mode 100644 index 0000000..f80f8b9 --- /dev/null +++ b/src/sdkContext/ContentpassSdkProvder.test.tsx @@ -0,0 +1,30 @@ +import { ContentpassSdkProvider } from './ContentpassSdkProvider'; +import type { ContentpassConfig } from '../types/ContentpassConfig'; +import { Text } from 'react-native'; +import { render, screen } from '@testing-library/react-native'; +import Contentpass from '../Contentpass'; + +jest.mock('../Contentpass'); + +describe('ContentpassSdkProvider', () => { + const mockConfig: ContentpassConfig = { + issuer: 'https://my.contentpass.me', + propertyId: 'my-property-id', + redirectUrl: 'de.contentpass.test://oauth', + }; + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('initializes Contentpass SDK with the given configuration', () => { + render( + + Test Child + + ); + + expect(Contentpass).toHaveBeenCalledWith(mockConfig); + expect(screen.getByTestId('child')).toHaveTextContent('Test Child'); + }); +}); diff --git a/src/sdkContext/useContentpassSdk.test.tsx b/src/sdkContext/useContentpassSdk.test.tsx new file mode 100644 index 0000000..9629d5a --- /dev/null +++ b/src/sdkContext/useContentpassSdk.test.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { renderHook } from '@testing-library/react-native'; +import useContentpassSdk from './useContentpassSdk'; +import { contentpassSdkContext } from './ContentpassSdkProvider'; +import Contentpass from '../Contentpass'; + +describe('useContentpassSdk', () => { + afterEach(() => { + jest.resetAllMocks(); + jest.restoreAllMocks(); + }); + + it('should return the contentpassSdk from the context', () => { + const contentpassSdk = 'contentpassSdk'; + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + const { result } = renderHook(() => useContentpassSdk(), { wrapper }); + + expect(result.current).toBe(contentpassSdk); + }); + + it('should throw an error if used outside of a ContentpassSdkProvider', async () => { + // mock console.error to prevent error output in test + jest.spyOn(console, 'error').mockImplementation(() => {}); + + expect(() => { + renderHook(() => useContentpassSdk()); + }).toThrow( + 'useContentpassSdk must be used within a ContentpassSdkProvider' + ); + }); +}); diff --git a/src/types/ContentpassConfig.ts b/src/types/ContentpassConfig.ts index e76b326..03dacd6 100644 --- a/src/types/ContentpassConfig.ts +++ b/src/types/ContentpassConfig.ts @@ -1,3 +1,5 @@ +/* istanbul ignore file */ + export type ContentpassConfig = { propertyId: string; redirectUrl: string; diff --git a/src/types/ContentpassState.ts b/src/types/ContentpassState.ts index 34f0d84..70d42ef 100644 --- a/src/types/ContentpassState.ts +++ b/src/types/ContentpassState.ts @@ -1,3 +1,5 @@ +/* istanbul ignore file */ + export enum ContentpassStateType { INITIALISING = 'INITIALISING', UNAUTHENTICATED = 'UNAUTHENTICATED', diff --git a/src/types/RefreshTokenStrategy.ts b/src/types/RefreshTokenStrategy.ts index 6fe1923..3b88551 100644 --- a/src/types/RefreshTokenStrategy.ts +++ b/src/types/RefreshTokenStrategy.ts @@ -1,3 +1,5 @@ +/* istanbul ignore file */ + export enum RefreshTokenStrategy { INSTANTLY = 'INSTANTLY', TIMER_SET = 'TIMER_SET', diff --git a/src/utils/fetchContentpassToken.test.ts b/src/utils/fetchContentpassToken.test.ts new file mode 100644 index 0000000..2430f61 --- /dev/null +++ b/src/utils/fetchContentpassToken.test.ts @@ -0,0 +1,34 @@ +import fetchContentpassToken from './fetchContentpassToken'; + +describe('fetchContentpassToken', () => { + afterEach(() => { + jest.resetAllMocks(); + jest.restoreAllMocks(); + }); + + it('should return the contentpass token', async () => { + jest.spyOn(global, 'fetch').mockResolvedValue({ + json: jest + .fn() + .mockResolvedValue({ contentpass_token: 'example_contentpass_token' }), + } as any); + + const result = await fetchContentpassToken({ + idToken: '123456', + propertyId: '987654321', + issuer: 'https://issuer.com', + }); + + expect(result).toBe('example_contentpass_token'); + expect(global.fetch).toHaveBeenCalledWith( + 'https://issuer.com/auth/oidc/token', + { + body: 'grant_type=contentpass_token&subject_token=123456&client_id=987654321', + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + } + ); + }); +}); diff --git a/src/utils/parseContentpassToken.test.ts b/src/utils/parseContentpassToken.test.ts new file mode 100644 index 0000000..b70b1d4 --- /dev/null +++ b/src/utils/parseContentpassToken.test.ts @@ -0,0 +1,41 @@ +import parseContentpassToken from './parseContentpassToken'; + +describe('parseContentpassToken', () => { + it('should parse the contentpass token', () => { + const exampleToken = + 'eyJhbGciOiJSUzI1NiIsImtpZCI6IjY5NzUwYTZjLTNmYjctNDUyNi05NWY4LTVhZmYxMmIyZjFjOSJ9.eyJhdXRoIjp0cnVlLCJ0eXBlIjoiY3AiLCJwbGFucyI6WyIwYWNhZTkxNy1iZTk5LTQ4ZWEtYjhmMS0yMGZhNjhhNDdkM2EiLCI0NDIxNjI4Yy05NjA2LTRjMDEtOGU1ZC1jMmE5YmNhNjhhYjQiLCI3ZThkZTBjYy0zZTk3LTQ5YTItODgxZC05ZmZiNWI4NDE1MTUiLCJhNDcyMWRiNS02N2RmLTQxNDUtYmJiZi1jYmQwOWY3ZTAzOTciLCJjNGQzYjBmNS05ODlhLTRmN2ItOGFjNy0zZDhmZmE5NTcxN2YiLCI2NGRkOTkwNS05NmUxLTRmYjItOTgwZC01MDdmMTYzNzVmZTkiXSwiYXVkIjoiY2MzZmM0YWQiLCJpYXQiOjE3MzMxMzU2ODEsImV4cCI6MTczMzMxMjA4MX0.CMtH7HRLf2HVgw3_cZRN0en8tml_SQKM73iLGJAp72-vJuRJaq85xBp6Jgy9WD3L7x4itRlBAYZxX8tLxZGogU0WP4_dMGFQ2QlcwKshwJygwRM1YqvxGWX2Az_KxEMc2QGHvpE1qe2MAr_xOU7VFfc0-vWxFc3hRzpAM5j7YHctj2t1v6h9-M7V2Hkcn37569QmtgU8gJkUxXsgUTufbb1ikjjjAvnjvTluHJo51_utbimpUbCk3EFxXVCVEI_pAqiZQXNninUQ6dbSujLb3L2UlEdQzLeUiBdYroeFzSyruLrR841ledLQ5ZP2OqzF5oUMuAGVOOhmgGdwGMCDRQ'; + + const result = parseContentpassToken(exampleToken); + + expect(result).toEqual({ + body: { + aud: 'cc3fc4ad', + auth: true, + exp: 1733312081, + iat: 1733135681, + plans: [ + '0acae917-be99-48ea-b8f1-20fa68a47d3a', + '4421628c-9606-4c01-8e5d-c2a9bca68ab4', + '7e8de0cc-3e97-49a2-881d-9ffb5b841515', + 'a4721db5-67df-4145-bbbf-cbd09f7e0397', + 'c4d3b0f5-989a-4f7b-8ac7-3d8ffa95717f', + '64dd9905-96e1-4fb2-980d-507f16375fe9', + ], + type: 'cp', + }, + header: { + alg: 'RS256', + kid: '69750a6c-3fb7-4526-95f8-5aff12b2f1c9', + }, + }); + }); + + it('should throw an error if the token is invalid', () => { + const invalidToken = + 'eyJhdXRoIjp0cnVlLCJ0eXBlIjoiY3AiLCJwbGFucyI6WyIwYWNhZTkxNy1iZTk5LTQ4ZWEtYjhmMS0yMGZhNjhhNDdkM2EiLCI0NDIxNjI4Yy05NjA2LTRjMDEtOGU1ZC1jMmE5YmNhNjhhYjQiLCI3ZThkZTBjYy0zZTk3LTQ5YTItODgxZC05ZmZiNWI4NDE1MTUiLCJhNDcyMWRiNS02N2RmLTQxNDUtYmJiZi1jYmQwOWY3ZTAzOTciLCJjNGQzYjBmNS05ODlhLTRmN2ItOGFjNy0zZDhmZmE5NTcxN2YiLCI2NGRkOTkwNS05NmUxLTRmYjItOTgwZC01MDdmMTYzNzVmZTkiXSwiYXVkIjoiY2MzZmM0YWQiLCJpYXQiOjE3MzMxMzU2ODEsImV4cCI6MTczMzMxMjA4MX0.CMtH7HRLf2HVgw3_cZRN0en8tml_SQKM73iLGJAp72-vJuRJaq85xBp6Jgy9WD3L7x4itRlBAYZxX8tLxZGogU0WP4_dMGFQ2QlcwKshwJygwRM1YqvxGWX2Az_KxEMc2QGHvpE1qe2MAr_xOU7VFfc0-vWxFc3hRzpAM5j7YHctj2t1v6h9-M7V2Hkcn37569QmtgU8gJkUxXsgUTufbb1ikjjjAvnjvTluHJo51_utbimpUbCk3EFxXVCVEI_pAqiZQXNninUQ6dbSujLb3L2UlEdQzLeUiBdYroeFzSyruLrR841ledLQ5ZP2OqzF5oUMuAGVOOhmgGdwGMCDRQ'; + + expect(() => { + parseContentpassToken(invalidToken); + }).toThrow('Invalid token'); + }); +}); diff --git a/src/utils/parseContentpassToken.ts b/src/utils/parseContentpassToken.ts index 572e8cc..184d78e 100644 --- a/src/utils/parseContentpassToken.ts +++ b/src/utils/parseContentpassToken.ts @@ -1,4 +1,21 @@ -export default function parseContentpassToken(contentpassToken: string) { +type ParsedToken = { + body: { + aud: string; + auth: boolean; + exp: number; + iat: number; + plans: string[]; + type: string; + }; + header: { + alg: string; + kid: string; + }; +}; + +export default function parseContentpassToken( + contentpassToken: string +): ParsedToken { const tokenParts = contentpassToken.split('.'); if (tokenParts.length < 3) { throw new Error('Invalid token'); diff --git a/src/utils/validateSubscription.test.ts b/src/utils/validateSubscription.test.ts new file mode 100644 index 0000000..09f1e3a --- /dev/null +++ b/src/utils/validateSubscription.test.ts @@ -0,0 +1,73 @@ +import parseContentpassToken, * as ParseContentpassTokenModule from './parseContentpassToken'; +import validateSubscription from './validateSubscription'; + +const EXAMPLE_CONTENTPASS_TOKEN: ReturnType = { + body: { + aud: 'cc3fc4ad', + auth: true, + exp: 1733312081, + iat: 1733135681, + type: 'cp', + plans: ['planId1'], + }, + header: { + alg: 'RS256', + kid: 'kid', + }, +}; + +describe('validateSubscription', () => { + let parseContentpassTokenSpy: jest.SpyInstance; + beforeEach(() => { + parseContentpassTokenSpy = jest + .spyOn(ParseContentpassTokenModule, 'default') + .mockReturnValue(EXAMPLE_CONTENTPASS_TOKEN); + }); + + afterEach(() => { + jest.resetAllMocks(); + jest.restoreAllMocks(); + }); + + it('should return true if the token is valid', () => { + parseContentpassTokenSpy.mockReturnValue(EXAMPLE_CONTENTPASS_TOKEN); + const result = validateSubscription('example_contentpass_token'); + + expect(result).toBe(true); + }); + + it('should return false if the token is invalid', () => { + parseContentpassTokenSpy.mockImplementation(() => { + throw new Error('Invalid token'); + }); + const result = validateSubscription('example_contentpass_token'); + + expect(result).toBe(false); + }); + + it('should return false if the user is not authenticated', () => { + parseContentpassTokenSpy.mockReturnValue({ + ...EXAMPLE_CONTENTPASS_TOKEN, + body: { + ...EXAMPLE_CONTENTPASS_TOKEN.body, + auth: false, + }, + }); + const result = validateSubscription('example_contentpass_token'); + + expect(result).toBe(false); + }); + + it('should return false if the user has no plans', () => { + parseContentpassTokenSpy.mockReturnValue({ + ...EXAMPLE_CONTENTPASS_TOKEN, + body: { + ...EXAMPLE_CONTENTPASS_TOKEN.body, + plans: [], + }, + }); + const result = validateSubscription('example_contentpass_token'); + + expect(result).toBe(false); + }); +}); diff --git a/src/utils/validateSubscription.ts b/src/utils/validateSubscription.ts index 6be5035..9a08f38 100644 --- a/src/utils/validateSubscription.ts +++ b/src/utils/validateSubscription.ts @@ -1,7 +1,11 @@ import parseContentpassToken from './parseContentpassToken'; export default function validateSubscription(contentpassToken: string) { - const { body } = parseContentpassToken(contentpassToken); - - return !!body.auth && !!body.plans.length; + try { + const { body } = parseContentpassToken(contentpassToken); + return !!body.auth && !!body.plans.length; + } catch (err) { + // FIXME: logger for error + return false; + } } diff --git a/yarn.lock b/yarn.lock index 52cdcaa..a1f53af 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3650,6 +3650,25 @@ __metadata: languageName: node linkType: hard +"@testing-library/react-native@npm:^12.9.0": + version: 12.9.0 + resolution: "@testing-library/react-native@npm:12.9.0" + dependencies: + jest-matcher-utils: ^29.7.0 + pretty-format: ^29.7.0 + redent: ^3.0.0 + peerDependencies: + jest: ">=28.0.0" + react: ">=16.8.0" + react-native: ">=0.59" + react-test-renderer: ">=16.8.0" + peerDependenciesMeta: + jest: + optional: true + checksum: 88115b22c127f39b2e1e8098dc1c93ea9c7393800a24f4f380bed64425cc685f98cad5b56b9cb48d85f0dbed1f0f208d0de44137c6e789c98161ff2715f70646 + languageName: node + linkType: hard + "@tootallnate/quickjs-emscripten@npm:^0.23.0": version: 0.23.0 resolution: "@tootallnate/quickjs-emscripten@npm:0.23.0" @@ -3777,6 +3796,13 @@ __metadata: languageName: node linkType: hard +"@types/lodash@npm:^4.17.13": + version: 4.17.13 + resolution: "@types/lodash@npm:4.17.13" + checksum: d0bf8fbd950be71946e0076b30fd40d492293baea75f05931b6b5b906fd62583708c6229abdb95b30205ad24ce1ed2f48bc9d419364f682320edd03405cc0c7e + languageName: node + linkType: hard + "@types/minimist@npm:^1.2.0, @types/minimist@npm:^1.2.2": version: 1.2.5 resolution: "@types/minimist@npm:1.2.5" @@ -11917,11 +11943,11 @@ __metadata: linkType: hard "prettier@npm:^3.0.3": - version: 3.3.3 - resolution: "prettier@npm:3.3.3" + version: 3.4.1 + resolution: "prettier@npm:3.4.1" bin: prettier: bin/prettier.cjs - checksum: bc8604354805acfdde6106852d14b045bb20827ad76a5ffc2455b71a8257f94de93f17f14e463fe844808d2ccc87248364a5691488a3304f1031326e62d9276e + checksum: f83ae83e38ae38f42c0b174833f58f820ed6eb063abfc5aa6725e8f9c1d626b54b1cb9d595cace525f8d59de89e186285f6bbcb460dc644ea9d8a7823cc54aca languageName: node linkType: hard @@ -12165,6 +12191,13 @@ __metadata: languageName: node linkType: hard +"react-is@npm:^16.12.0 || ^17.0.0 || ^18.0.0, react-is@npm:^18.0.0, react-is@npm:^18.3.1": + version: 18.3.1 + resolution: "react-is@npm:18.3.1" + checksum: e20fe84c86ff172fc8d898251b7cc2c43645d108bf96d0b8edf39b98f9a2cae97b40520ee7ed8ee0085ccc94736c4886294456033304151c3f94978cec03df21 + languageName: node + linkType: hard + "react-is@npm:^16.13.1": version: 16.13.1 resolution: "react-is@npm:16.13.1" @@ -12179,13 +12212,6 @@ __metadata: languageName: node linkType: hard -"react-is@npm:^18.0.0": - version: 18.3.1 - resolution: "react-is@npm:18.3.1" - checksum: e20fe84c86ff172fc8d898251b7cc2c43645d108bf96d0b8edf39b98f9a2cae97b40520ee7ed8ee0085ccc94736c4886294456033304151c3f94978cec03df21 - languageName: node - linkType: hard - "react-native-app-auth@npm:^8.0.0": version: 8.0.0 resolution: "react-native-app-auth@npm:8.0.0" @@ -12304,9 +12330,12 @@ __metadata: "@commitlint/config-conventional": ^17.0.2 "@evilmartians/lefthook": ^1.5.0 "@react-native-community/cli": 15.0.1 + "@react-native/babel-preset": 0.76.3 "@react-native/eslint-config": ^0.73.1 "@release-it/conventional-changelog": ^9.0.2 + "@testing-library/react-native": ^12.9.0 "@types/jest": ^29.5.5 + "@types/lodash": ^4.17.13 "@types/react": ^18.2.44 commitlint: ^17.0.2 del-cli: ^5.1.0 @@ -12314,12 +12343,14 @@ __metadata: eslint-config-prettier: ^9.0.0 eslint-plugin-prettier: ^5.0.1 jest: ^29.7.0 + lodash: ^4.17.21 prettier: ^3.0.3 react: 18.3.1 react-native: 0.76.2 react-native-app-auth: ^8.0.0 react-native-builder-bob: ^0.32.1 react-native-encrypted-storage: ^4.0.3 + react-test-renderer: 18.3.1 release-it: ^17.10.0 turbo: ^1.10.7 typescript: ^5.2.2 @@ -12456,6 +12487,31 @@ __metadata: languageName: node linkType: hard +"react-shallow-renderer@npm:^16.15.0": + version: 16.15.0 + resolution: "react-shallow-renderer@npm:16.15.0" + dependencies: + object-assign: ^4.1.1 + react-is: ^16.12.0 || ^17.0.0 || ^18.0.0 + peerDependencies: + react: ^16.0.0 || ^17.0.0 || ^18.0.0 + checksum: 6052c7e3e9627485120ebd8257f128aad8f56386fe8d42374b7743eac1be457c33506d153c7886b4e32923c0c352d402ab805ef9ca02dbcd8393b2bdeb6e5af8 + languageName: node + linkType: hard + +"react-test-renderer@npm:18.3.1": + version: 18.3.1 + resolution: "react-test-renderer@npm:18.3.1" + dependencies: + react-is: ^18.3.1 + react-shallow-renderer: ^16.15.0 + scheduler: ^0.23.2 + peerDependencies: + react: ^18.3.1 + checksum: e8e58e738835fab3801afb63f6bfe0fcf6e68ea39619fae5bdf47feefc36b1e4acb48c9dd139c7533611466eff1dfce6ffdda4b317e06aee663dda7d91438f26 + languageName: node + linkType: hard + "react@npm:18.3.1": version: 18.3.1 resolution: "react@npm:18.3.1" @@ -13093,6 +13149,15 @@ __metadata: languageName: node linkType: hard +"scheduler@npm:^0.23.2": + version: 0.23.2 + resolution: "scheduler@npm:0.23.2" + dependencies: + loose-envify: ^1.1.0 + checksum: 3e82d1f419e240ef6219d794ff29c7ee415fbdc19e038f680a10c067108e06284f1847450a210b29bbaf97b9d8a97ced5f624c31c681248ac84c80d56ad5a2c4 + languageName: node + linkType: hard + "selfsigned@npm:^2.4.1": version: 2.4.1 resolution: "selfsigned@npm:2.4.1"