Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .eslintignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ node_modules/
lib/
ios/
android/
coverage/
16 changes: 16 additions & 0 deletions jest-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,19 @@ jest.mock('react-native-encrypted-storage', () => ({
removeItem: jest.fn(() => Promise.resolve()),
clear: jest.fn(() => Promise.resolve()),
}));

jest.mock('@sentry/react-native', () => ({
ReactNativeClient: jest.fn().mockReturnValue({
init: jest.fn(),
}),
Scope: jest.fn().mockReturnValue({
setClient: jest.fn(),
setExtra: jest.fn(),
addBreadcrumb: jest.fn(),
captureException: jest.fn(),
}),
}));

jest.mock('@sentry/react-native/dist/js/integrations/default', () => ({
getDefaultIntegrations: jest.fn().mockReturnValue([]),
}));
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@
"version": "0.44.1"
},
"dependencies": {
"@sentry/react-native": "^6.3.0",
"lodash": "^4.17.21"
}
}
21 changes: 18 additions & 3 deletions src/Contentpass.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { ContentpassState } from './types/ContentpassState';
import OidcAuthStateStorage from './OidcAuthStateStorage';
import * as FetchContentpassTokenModule from './utils/fetchContentpassToken';
import { SCOPES } from './consts/oidcConsts';
import * as SentryIntegrationModule from './sentryIntegration';

const config: ContentpassConfig = {
propertyId: 'propertyId-1',
Expand Down Expand Up @@ -39,6 +40,7 @@ describe('Contentpass', () => {
let contentpass: Contentpass;
let authorizeSpy: jest.SpyInstance;
let refreshSpy: jest.SpyInstance;
let reportErrorSpy: jest.SpyInstance;
let fetchContentpassTokenSpy: jest.SpyInstance;
let oidcAuthStorageMock: OidcAuthStateStorage;

Expand All @@ -50,6 +52,9 @@ describe('Contentpass', () => {
refreshSpy = jest
.spyOn(AppAuthModule, 'refresh')
.mockResolvedValue(EXAMPLE_REFRESH_RESULT);
reportErrorSpy = jest
.spyOn(SentryIntegrationModule, 'reportError')
.mockReturnValue(undefined);

oidcAuthStorageMock = {
storeOidcAuthState: jest.fn(),
Expand Down Expand Up @@ -166,13 +171,18 @@ describe('Contentpass', () => {

it('should change contentpass state to error when authorize throws an error', async () => {
const contentpassStates: ContentpassState[] = [];
authorizeSpy.mockRejectedValue(new Error('Authorize error'));
const error = new Error('Authorize error');
authorizeSpy.mockRejectedValue(error);
contentpass.registerObserver((state) => {
contentpassStates.push(state);
});

await contentpass.authenticate();

expect(reportErrorSpy).toHaveBeenCalledWith(error, {
msg: 'Failed to authorize',
});

expect(contentpassStates).toHaveLength(2);
expect(contentpassStates[1]).toEqual({
state: 'ERROR',
Expand Down Expand Up @@ -291,7 +301,8 @@ describe('Contentpass', () => {
).getTime();
const expectedDelay = expirationDate - NOW;

refreshSpy.mockRejectedValue(new Error('Refresh error'));
const refreshError = new Error('Refresh error');
refreshSpy.mockRejectedValue(refreshError);

await jest.advanceTimersByTimeAsync(expectedDelay);
expect(contentpassStates).toHaveLength(2);
Expand All @@ -308,7 +319,11 @@ describe('Contentpass', () => {
});

// after 6 retries the state should change to error
await jest.advanceTimersByTimeAsync(180001);
await jest.advanceTimersByTimeAsync(120001);
expect(reportErrorSpy).toHaveBeenCalledTimes(7);
expect(reportErrorSpy).toHaveBeenCalledWith(refreshError, {
msg: 'Failed to refresh token after 6 retries',
});
expect(contentpassStates).toHaveLength(3);
expect(contentpassStates[2]).toEqual({
state: 'UNAUTHENTICATED',
Expand Down
18 changes: 10 additions & 8 deletions src/Contentpass.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { RefreshTokenStrategy } from './types/RefreshTokenStrategy';
import fetchContentpassToken from './utils/fetchContentpassToken';
import validateSubscription from './utils/validateSubscription';
import type { ContentpassConfig } from './types/ContentpassConfig';
import { reportError, setSentryExtraAttribute } from './sentryIntegration';

export type ContentpassObserver = (state: ContentpassState) => void;

Expand All @@ -33,6 +34,7 @@ export default class Contentpass {
constructor(config: ContentpassConfig) {
this.authStateStorage = new OidcAuthStateStorage(config.propertyId);
this.config = config;
setSentryExtraAttribute('propertyId', config.propertyId);
this.initialiseAuthState();
}

Expand All @@ -52,7 +54,7 @@ export default class Contentpass {
},
});
} catch (err: any) {
// FIXME: logger for error
reportError(err, { msg: 'Failed to authorize' });

this.changeContentpassState({
state: ContentpassStateType.ERROR,
Expand Down Expand Up @@ -166,7 +168,7 @@ export default class Contentpass {

private refreshToken = async (counter: number) => {
if (!this.oidcAuthState?.refreshToken) {
// FIXME: logger for error
reportError(new Error('No Refresh Token in oidcAuthState provided'));
return;
}

Expand All @@ -183,17 +185,17 @@ export default class Contentpass {
}
);
await this.onNewAuthState(refreshResult);
} catch (err) {
} catch (err: any) {
await this.onRefreshTokenError(counter, err);
}
};

// @ts-expect-error remove when err starts being used
// eslint-disable-next-line @typescript-eslint/no-unused-vars
private onRefreshTokenError = async (counter: number, err: unknown) => {
// FIXME: logger for error
private onRefreshTokenError = async (counter: number, err: Error) => {
reportError(err, {
msg: `Failed to refresh token after ${counter} retries`,
});
// FIXME: add handling for specific error to not retry in every case
if (counter <= REFRESH_TOKEN_RETRIES) {
if (counter < REFRESH_TOKEN_RETRIES) {
const delay = counter * 1000 * 10;
await new Promise((resolve) => setTimeout(resolve, delay));
await this.refreshToken(counter + 1);
Expand Down
3 changes: 0 additions & 3 deletions src/index.test.tsx

This file was deleted.

108 changes: 108 additions & 0 deletions src/sentryIntegration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import * as Sentry from '@sentry/react-native';
import { defaultStackParser, makeFetchTransport } from '@sentry/react';
import * as SentryReactNativeModule from '@sentry/react-native';
import { reportError, setSentryExtraAttribute } from './sentryIntegration';

jest.mock('@sentry/react-native', () => {
const scope = {
setClient: jest.fn(),
addBreadcrumb: jest.fn(),
captureException: jest.fn(),
setExtra: jest.fn(),
};
return {
ReactNativeClient: jest.fn().mockImplementation(() => ({
init: jest.fn(),
captureException: jest.fn(),
})),
Scope: jest.fn().mockImplementation(() => scope),

// Only for internal testing
__test_getScope: () => scope,
};
});

describe('sentryScope', () => {
let addBreadcrumbMock: jest.Mock;
let captureExceptionMock: jest.Mock;

beforeEach(() => {
addBreadcrumbMock = jest.fn();
captureExceptionMock = jest.fn();
jest.spyOn(SentryReactNativeModule, 'Scope').mockReturnValue({
setClient: jest.fn(),
addBreadcrumb: addBreadcrumbMock,
captureException: captureExceptionMock,
} as any);
});

afterEach(() => {
jest.restoreAllMocks();
jest.resetAllMocks();
});

it('should initialise sentry scope with correct options', () => {
expect(Sentry.ReactNativeClient).toHaveBeenCalledWith({
attachStacktrace: true,
autoInitializeNativeSdk: false,
dsn: 'https://[email protected]/8',
enableAppStartTracking: false,
enableAutoPerformanceTracing: false,
enableCaptureFailedRequests: false,
enableNative: false,
enableNativeCrashHandling: false,
enableNativeFramesTracking: false,
enableNativeNagger: false,
enableNdk: false,
enableStallTracking: true,
enableUserInteractionTracing: false,
enableWatchdogTerminationTracking: false,
maxQueueSize: 30,
parentSpanIsAlwaysRootSpan: true,
patchGlobalPromise: true,
sendClientReports: true,
integrations: [],
stackParser: defaultStackParser,
transport: makeFetchTransport,
});
});

describe('reportError', () => {
it('should add breadcrumb with message if provided', () => {
const err = new Error('test');
const msg = 'test message';
// @ts-ignore
const { addBreadcrumb, captureException } = Sentry.__test_getScope();
reportError(err, { msg });

expect(addBreadcrumb).toHaveBeenCalledWith({
category: 'Error',
message: msg,
level: 'log',
});
expect(captureException).toHaveBeenCalledWith(err);
});

it('should not add breadcrumb if message is not provided', () => {
const err = new Error('test');
// @ts-ignore
const { addBreadcrumb, captureException } = Sentry.__test_getScope();
reportError(err);

expect(addBreadcrumb).not.toHaveBeenCalled();
expect(captureException).toHaveBeenCalledWith(err);
});
});

describe('setSentryExtraAttribute', () => {
it('should set extra attribute', () => {
const key = 'testKey';
const value = 'testValue';
// @ts-ignore
const { setExtra } = Sentry.__test_getScope();
setSentryExtraAttribute(key, value);

expect(setExtra).toHaveBeenCalledWith(key, value);
});
});
});
59 changes: 59 additions & 0 deletions src/sentryIntegration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import * as Sentry from '@sentry/react-native';
import { defaultStackParser, makeFetchTransport } from '@sentry/react';
import { getDefaultIntegrations } from '@sentry/react-native/dist/js/integrations/default';

// as it's only the open source package, we want to have minimal sentry configuration here to not override sentry instance,
// which can be used in the application
const options = {
attachStacktrace: true,
autoInitializeNativeSdk: false,
dsn: 'https://[email protected]/8',
enableAppStartTracking: false,
enableAutoPerformanceTracing: false,
enableCaptureFailedRequests: false,
enableNative: false,
enableNativeCrashHandling: false,
enableNativeFramesTracking: false,
enableNativeNagger: false,
enableNdk: false,
enableStallTracking: true,
enableUserInteractionTracing: false,
enableWatchdogTerminationTracking: false,
maxQueueSize: 30,
parentSpanIsAlwaysRootSpan: true,
patchGlobalPromise: true,
sendClientReports: true,
stackParser: defaultStackParser,
transport: makeFetchTransport,
integrations: [],
};

const sentryClient = new Sentry.ReactNativeClient({
...options,
integrations: getDefaultIntegrations(options),
});

const sentryScope = new Sentry.Scope();
sentryScope.setClient(sentryClient);

sentryClient.init();

type ReportErrorOptions = {
msg?: string;
};

export const reportError = (err: Error, { msg }: ReportErrorOptions = {}) => {
if (msg) {
sentryScope.addBreadcrumb({
category: 'Error',
message: msg,
level: 'log',
});
}

sentryScope.captureException(err);
};

export const setSentryExtraAttribute = (key: string, value: string) => {
sentryScope.setExtra(key, value);
};
2 changes: 1 addition & 1 deletion src/utils/parseContentpassToken.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,6 @@ describe('parseContentpassToken', () => {

expect(() => {
parseContentpassToken(invalidToken);
}).toThrow('Invalid token');
}).toThrow('Invalid token, token should have at least 3 parts');
});
});
2 changes: 1 addition & 1 deletion src/utils/parseContentpassToken.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export default function parseContentpassToken(
): ParsedToken {
const tokenParts = contentpassToken.split('.');
if (tokenParts.length < 3) {
throw new Error('Invalid token');
throw new Error('Invalid token, token should have at least 3 parts');
}

const header = JSON.parse(atob(tokenParts[0]!));
Expand Down
15 changes: 13 additions & 2 deletions src/utils/validateSubscription.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import parseContentpassToken, * as ParseContentpassTokenModule from './parseContentpassToken';
import * as SentryIntegrationModule from '../sentryIntegration';
import validateSubscription from './validateSubscription';

const EXAMPLE_CONTENTPASS_TOKEN: ReturnType<typeof parseContentpassToken> = {
Expand All @@ -18,10 +19,16 @@ const EXAMPLE_CONTENTPASS_TOKEN: ReturnType<typeof parseContentpassToken> = {

describe('validateSubscription', () => {
let parseContentpassTokenSpy: jest.SpyInstance;
let reportErrorSpy: jest.SpyInstance;

beforeEach(() => {
parseContentpassTokenSpy = jest
.spyOn(ParseContentpassTokenModule, 'default')
.mockReturnValue(EXAMPLE_CONTENTPASS_TOKEN);

reportErrorSpy = jest
.spyOn(SentryIntegrationModule, 'reportError')
.mockReturnValue(undefined);
});

afterEach(() => {
Expand All @@ -36,13 +43,17 @@ describe('validateSubscription', () => {
expect(result).toBe(true);
});

it('should return false if the token is invalid', () => {
it('should return false and report error if the token is invalid', () => {
const error = new Error('Invalid token');
parseContentpassTokenSpy.mockImplementation(() => {
throw new Error('Invalid token');
throw error;
});
const result = validateSubscription('example_contentpass_token');

expect(result).toBe(false);
expect(reportErrorSpy).toHaveBeenCalledWith(error, {
msg: 'Failed to validate subscription',
});
});

it('should return false if the user is not authenticated', () => {
Expand Down
Loading