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"