@youversion/platform-core 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +8 -8
- package/CHANGELOG.md +33 -0
- package/dist/index.cjs +473 -346
- package/dist/index.d.cts +106 -134
- package/dist/index.d.ts +106 -134
- package/dist/index.js +473 -343
- package/package.json +4 -3
- package/src/SignInWithYouVersionPKCE.ts +122 -0
- package/src/SignInWithYouVersionResult.ts +40 -39
- package/src/URLBuilder.ts +0 -21
- package/src/Users.ts +375 -94
- package/src/YouVersionPlatformConfiguration.ts +69 -25
- package/src/YouVersionUserInfo.ts +6 -6
- package/src/__tests__/SignInWithYouVersionPKCE.test.ts +418 -0
- package/src/__tests__/SignInWithYouVersionResult.test.ts +28 -0
- package/src/__tests__/StorageStrategy.test.ts +0 -72
- package/src/__tests__/URLBuilder.test.ts +0 -100
- package/src/__tests__/Users.test.ts +737 -0
- package/src/__tests__/YouVersionPlatformConfiguration.test.ts +192 -30
- package/src/__tests__/YouVersionUserInfo.test.ts +347 -0
- package/src/__tests__/highlights.test.ts +12 -12
- package/src/__tests__/mocks/browser.ts +90 -0
- package/src/__tests__/mocks/configuration.ts +53 -0
- package/src/__tests__/mocks/jwt.ts +93 -0
- package/src/__tests__/mocks/tokens.ts +69 -0
- package/src/index.ts +0 -3
- package/src/types/auth.ts +1 -0
- package/tsconfig.build.json +1 -1
- package/tsconfig.json +1 -1
- package/src/AuthenticationStrategy.ts +0 -78
- package/src/WebAuthenticationStrategy.ts +0 -127
- package/src/__tests__/authentication.test.ts +0 -185
- package/src/authentication.ts +0 -27
|
@@ -0,0 +1,737 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
+
import { YouVersionAPIUsers } from '../Users';
|
|
3
|
+
import { YouVersionPlatformConfiguration } from '../YouVersionPlatformConfiguration';
|
|
4
|
+
import { SignInWithYouVersionPermission } from '../SignInWithYouVersionResult';
|
|
5
|
+
import { YouVersionUserInfo } from '../YouVersionUserInfo';
|
|
6
|
+
import type { SignInWithYouVersionPermissionValues } from '../types/auth';
|
|
7
|
+
import { setupBrowserMocks, cleanupBrowserMocks } from './mocks/browser';
|
|
8
|
+
|
|
9
|
+
const mockFetch = vi.fn();
|
|
10
|
+
|
|
11
|
+
describe('YouVersionAPIUsers', () => {
|
|
12
|
+
let mocks: ReturnType<typeof setupBrowserMocks>;
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
vi.clearAllMocks();
|
|
16
|
+
|
|
17
|
+
// Setup global mocks
|
|
18
|
+
mocks = setupBrowserMocks();
|
|
19
|
+
vi.stubGlobal('fetch', mockFetch);
|
|
20
|
+
|
|
21
|
+
// Override randomUUID for this test suite
|
|
22
|
+
mocks.crypto.randomUUID = vi.fn(() => 'test-installation-id');
|
|
23
|
+
|
|
24
|
+
// Reset location
|
|
25
|
+
mocks.window.location.href = '';
|
|
26
|
+
mocks.window.location.search = '';
|
|
27
|
+
|
|
28
|
+
// Setup YouVersionPlatformConfiguration
|
|
29
|
+
YouVersionPlatformConfiguration.appKey = 'test-app-key';
|
|
30
|
+
YouVersionPlatformConfiguration.apiHost = 'api.youversion.com';
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
cleanupBrowserMocks();
|
|
35
|
+
vi.unstubAllGlobals();
|
|
36
|
+
YouVersionPlatformConfiguration.appKey = null;
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe('signIn', () => {
|
|
40
|
+
it('should throw error when appKey is not set', async () => {
|
|
41
|
+
YouVersionPlatformConfiguration.appKey = null;
|
|
42
|
+
|
|
43
|
+
const permissions = new Set<SignInWithYouVersionPermissionValues>([
|
|
44
|
+
SignInWithYouVersionPermission.bibles,
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
await expect(
|
|
48
|
+
YouVersionAPIUsers.signIn(permissions, 'https://example.com/callback'),
|
|
49
|
+
).rejects.toThrow('YouVersionPlatformConfiguration.appKey must be set before calling signIn');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('should create authorization request and redirect on successful signIn', async () => {
|
|
53
|
+
vi.spyOn(crypto, 'getRandomValues').mockImplementation((array: ArrayBufferView) => {
|
|
54
|
+
if (array instanceof Uint8Array) {
|
|
55
|
+
for (let i = 0; i < array.length; i++) {
|
|
56
|
+
array[i] = i;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return array;
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
vi.spyOn(crypto.subtle, 'digest').mockResolvedValue(new Uint8Array(32).buffer);
|
|
63
|
+
mocks.btoa.mockReturnValue('mockBase64Value');
|
|
64
|
+
|
|
65
|
+
const permissions = new Set<SignInWithYouVersionPermissionValues>([
|
|
66
|
+
SignInWithYouVersionPermission.bibles,
|
|
67
|
+
SignInWithYouVersionPermission.highlights,
|
|
68
|
+
]);
|
|
69
|
+
const redirectURL = 'https://example.com/callback';
|
|
70
|
+
|
|
71
|
+
await YouVersionAPIUsers.signIn(permissions, redirectURL);
|
|
72
|
+
|
|
73
|
+
// Verify localStorage items stored
|
|
74
|
+
expect(mocks.localStorage.setItem).toHaveBeenCalledWith(
|
|
75
|
+
'youversion-auth-code-verifier',
|
|
76
|
+
expect.any(String),
|
|
77
|
+
);
|
|
78
|
+
expect(mocks.localStorage.setItem).toHaveBeenCalledWith(
|
|
79
|
+
'youversion-auth-redirect-uri',
|
|
80
|
+
redirectURL,
|
|
81
|
+
);
|
|
82
|
+
expect(mocks.localStorage.setItem).toHaveBeenCalledWith(
|
|
83
|
+
'youversion-auth-state',
|
|
84
|
+
expect.any(String),
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
// Verify redirect occurred
|
|
88
|
+
expect(mocks.window.location.href).toContain('https://api.youversion.com/auth/authorize');
|
|
89
|
+
|
|
90
|
+
vi.restoreAllMocks();
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe('handleAuthCallback', () => {
|
|
95
|
+
it('should return null when no state or error in URL', async () => {
|
|
96
|
+
mocks.window.location.search = '';
|
|
97
|
+
|
|
98
|
+
const result = await YouVersionAPIUsers.handleAuthCallback();
|
|
99
|
+
expect(result).toBeNull();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('should throw error when OAuth error is present', async () => {
|
|
103
|
+
mocks.window.location.search = '?error=access_denied&error_description=User denied access';
|
|
104
|
+
|
|
105
|
+
await expect(YouVersionAPIUsers.handleAuthCallback()).rejects.toThrow(
|
|
106
|
+
'OAuth authentication failed: User denied access',
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('should throw error when OAuth error is present without description', async () => {
|
|
111
|
+
mocks.window.location.search = '?error=server_error';
|
|
112
|
+
|
|
113
|
+
await expect(YouVersionAPIUsers.handleAuthCallback()).rejects.toThrow(
|
|
114
|
+
'OAuth authentication failed: server_error',
|
|
115
|
+
);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('should throw error when state parameter is invalid', async () => {
|
|
119
|
+
mocks.window.location.search = '?state=invalid-state&code=auth-code';
|
|
120
|
+
mocks.localStorage.getItem.mockImplementation((key: string) => {
|
|
121
|
+
if (key === 'youversion-auth-state') return 'valid-state';
|
|
122
|
+
return null;
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
await expect(YouVersionAPIUsers.handleAuthCallback()).rejects.toThrow(
|
|
126
|
+
'Invalid state parameter - possible CSRF attack',
|
|
127
|
+
);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('should redirect to server when state exists but no code', async () => {
|
|
131
|
+
mocks.window.location.href = 'https://example.com/callback?state=test-state';
|
|
132
|
+
mocks.window.location.search = '?state=test-state';
|
|
133
|
+
mocks.localStorage.getItem.mockImplementation((key: string) => {
|
|
134
|
+
if (key === 'youversion-auth-state') return 'test-state';
|
|
135
|
+
return null;
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// In test environment, the redirect continues execution, so expect the eventual error
|
|
139
|
+
await expect(YouVersionAPIUsers.handleAuthCallback()).rejects.toThrow();
|
|
140
|
+
|
|
141
|
+
// Verify that the redirect was attempted
|
|
142
|
+
expect(mocks.window.location.href).toBe(
|
|
143
|
+
'https://api.youversion.com/auth/callback?state=test-state',
|
|
144
|
+
);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('should throw error when required parameters are missing', async () => {
|
|
148
|
+
mocks.window.location.search = '?state=test-state&code=auth-code';
|
|
149
|
+
mocks.localStorage.getItem.mockImplementation((key: string) => {
|
|
150
|
+
if (key === 'youversion-auth-state') return 'test-state';
|
|
151
|
+
return null; // Missing other required parameters
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
await expect(YouVersionAPIUsers.handleAuthCallback()).rejects.toThrow(
|
|
155
|
+
'Missing required authentication parameters',
|
|
156
|
+
);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('should successfully exchange code for tokens', async () => {
|
|
160
|
+
const mockTokens = {
|
|
161
|
+
access_token: 'access-token-123',
|
|
162
|
+
expires_in: 3600,
|
|
163
|
+
id_token:
|
|
164
|
+
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyLCJlbWFpbCI6ImpvaG5AZXhhbXBsZS5jb20iLCJwcm9maWxlX3BpY3R1cmUiOiJodHRwczovL2V4YW1wbGUuY29tL2F2YXRhci5qcGcifQ.invalid-signature',
|
|
165
|
+
refresh_token: 'refresh-token-456',
|
|
166
|
+
scope: 'bibles highlights openid',
|
|
167
|
+
token_type: 'Bearer',
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const mockResponse = {
|
|
171
|
+
ok: true,
|
|
172
|
+
status: 200,
|
|
173
|
+
statusText: 'OK',
|
|
174
|
+
text: vi.fn().mockResolvedValue(JSON.stringify(mockTokens)),
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
mocks.window.location.search = '?state=test-state&code=auth-code';
|
|
178
|
+
mocks.window.location.href = 'https://example.com/callback?state=test-state&code=auth-code';
|
|
179
|
+
|
|
180
|
+
mocks.localStorage.getItem.mockImplementation((key: string) => {
|
|
181
|
+
switch (key) {
|
|
182
|
+
case 'youversion-auth-state':
|
|
183
|
+
return 'test-state';
|
|
184
|
+
case 'youversion-auth-code-verifier':
|
|
185
|
+
return 'code-verifier-123';
|
|
186
|
+
case 'youversion-auth-redirect-uri':
|
|
187
|
+
return 'https://example.com/callback';
|
|
188
|
+
default:
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
mockFetch.mockResolvedValue(mockResponse);
|
|
194
|
+
|
|
195
|
+
// Mock atob for JWT decoding
|
|
196
|
+
vi.mocked(atob).mockReturnValue(
|
|
197
|
+
JSON.stringify({
|
|
198
|
+
sub: '1234567890',
|
|
199
|
+
name: 'John Doe',
|
|
200
|
+
email: 'john@example.com',
|
|
201
|
+
profile_picture: 'https://example.com/avatar.jpg',
|
|
202
|
+
}),
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
// Mock YouVersionPlatformConfiguration.saveAuthData
|
|
206
|
+
const saveAuthDataSpy = vi.spyOn(YouVersionPlatformConfiguration, 'saveAuthData');
|
|
207
|
+
|
|
208
|
+
const result = await YouVersionAPIUsers.handleAuthCallback();
|
|
209
|
+
|
|
210
|
+
expect(result).toBeTruthy();
|
|
211
|
+
expect(result?.accessToken).toBe('access-token-123');
|
|
212
|
+
expect(result?.refreshToken).toBe('refresh-token-456');
|
|
213
|
+
expect(result?.permissions).toEqual(['bibles', 'highlights']);
|
|
214
|
+
expect(result?.yvpUserId).toBe('1234567890');
|
|
215
|
+
expect(result?.name).toBe('John Doe');
|
|
216
|
+
expect(result?.email).toBe('john@example.com');
|
|
217
|
+
|
|
218
|
+
// Verify saveAuthData was called
|
|
219
|
+
expect(saveAuthDataSpy).toHaveBeenCalled();
|
|
220
|
+
|
|
221
|
+
// Verify cleanup
|
|
222
|
+
expect(mocks.localStorage.removeItem).toHaveBeenCalledWith('youversion-auth-code-verifier');
|
|
223
|
+
expect(mocks.localStorage.removeItem).toHaveBeenCalledWith('youversion-auth-redirect-uri');
|
|
224
|
+
expect(mocks.localStorage.removeItem).toHaveBeenCalledWith('youversion-auth-state');
|
|
225
|
+
|
|
226
|
+
expect(mocks.window.history.replaceState).toHaveBeenCalledWith(
|
|
227
|
+
{},
|
|
228
|
+
'',
|
|
229
|
+
'https://example.com/callback',
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
saveAuthDataSpy.mockRestore();
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('should handle token exchange failure', async () => {
|
|
236
|
+
const mockResponse = {
|
|
237
|
+
ok: false,
|
|
238
|
+
status: 400,
|
|
239
|
+
statusText: 'Bad Request',
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
mocks.window.location.search = '?state=test-state&code=auth-code';
|
|
243
|
+
mocks.localStorage.getItem.mockImplementation((key: string) => {
|
|
244
|
+
switch (key) {
|
|
245
|
+
case 'youversion-auth-state':
|
|
246
|
+
return 'test-state';
|
|
247
|
+
case 'youversion-auth-code-verifier':
|
|
248
|
+
return 'code-verifier-123';
|
|
249
|
+
case 'youversion-auth-redirect-uri':
|
|
250
|
+
return 'https://example.com/callback';
|
|
251
|
+
default:
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
mockFetch.mockResolvedValue(mockResponse);
|
|
257
|
+
|
|
258
|
+
await expect(YouVersionAPIUsers.handleAuthCallback()).rejects.toThrow(
|
|
259
|
+
'Token exchange failed: 400 Bad Request',
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
// Verify cleanup on error
|
|
263
|
+
expect(mocks.localStorage.removeItem).toHaveBeenCalledWith('youversion-auth-code-verifier');
|
|
264
|
+
expect(mocks.localStorage.removeItem).toHaveBeenCalledWith('youversion-auth-redirect-uri');
|
|
265
|
+
expect(mocks.localStorage.removeItem).toHaveBeenCalledWith('youversion-auth-state');
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
describe('obtainLocation', () => {
|
|
270
|
+
it('should redirect to server callback with correct parameters', () => {
|
|
271
|
+
YouVersionPlatformConfiguration.apiHost = 'api-test.youversion.com';
|
|
272
|
+
|
|
273
|
+
const callbackURL = 'https://example.com/callback?state=test-state&user=123';
|
|
274
|
+
const state = 'test-state';
|
|
275
|
+
|
|
276
|
+
// Access private method
|
|
277
|
+
// @ts-expect-error - accessing private method for testing
|
|
278
|
+
YouVersionAPIUsers.obtainLocation(callbackURL, state);
|
|
279
|
+
|
|
280
|
+
expect(mocks.window.location.href).toBe(
|
|
281
|
+
'https://api-test.youversion.com/auth/callback?state=test-state&user=123',
|
|
282
|
+
);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it('should throw error when state parameter is invalid', () => {
|
|
286
|
+
const callbackURL = 'https://example.com/callback?state=invalid-state';
|
|
287
|
+
const state = 'valid-state';
|
|
288
|
+
|
|
289
|
+
expect(() => {
|
|
290
|
+
// @ts-expect-error - accessing private method for testing
|
|
291
|
+
YouVersionAPIUsers.obtainLocation(callbackURL, state);
|
|
292
|
+
}).toThrow('Invalid state parameter');
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
describe('extractSignInResult', () => {
|
|
297
|
+
it('should extract sign-in result from tokens', () => {
|
|
298
|
+
const fixedDate = new Date(2025, 2, 11, 12, 0, 0);
|
|
299
|
+
vi.setSystemTime(fixedDate);
|
|
300
|
+
const tokens = {
|
|
301
|
+
access_token: 'access-token-123',
|
|
302
|
+
expires_in: 3600,
|
|
303
|
+
id_token:
|
|
304
|
+
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyLCJlbWFpbCI6ImpvaG5AZXhhbXBsZS5jb20iLCJwcm9maWxlX3BpY3R1cmUiOiJodHRwczovL2V4YW1wbGUuY29tL2F2YXRhci5qcGcifQ.invalid',
|
|
305
|
+
refresh_token: 'refresh-token-456',
|
|
306
|
+
scope: 'bibles highlights openid',
|
|
307
|
+
token_type: 'Bearer',
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
// Mock JWT decoding
|
|
311
|
+
vi.mocked(atob).mockReturnValue(
|
|
312
|
+
JSON.stringify({
|
|
313
|
+
sub: '1234567890',
|
|
314
|
+
name: 'John Doe',
|
|
315
|
+
email: 'john@example.com',
|
|
316
|
+
profile_picture: 'https://example.com/avatar.jpg',
|
|
317
|
+
}),
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
// @ts-expect-error - accessing private method for testing
|
|
321
|
+
const result = YouVersionAPIUsers.extractSignInResult(tokens);
|
|
322
|
+
|
|
323
|
+
expect(result.accessToken).toBe('access-token-123');
|
|
324
|
+
expect(result.expiryDate).toStrictEqual(new Date(fixedDate.getTime() + 60 * 60 * 1000));
|
|
325
|
+
expect(result.refreshToken).toBe('refresh-token-456');
|
|
326
|
+
expect(result.permissions).toEqual(['bibles', 'highlights']);
|
|
327
|
+
expect(result.yvpUserId).toBe('1234567890');
|
|
328
|
+
expect(result.name).toBe('John Doe');
|
|
329
|
+
expect(result.email).toBe('john@example.com');
|
|
330
|
+
expect(result.profilePicture).toBe('https://example.com/avatar.jpg');
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it('should filter out unknown permissions', () => {
|
|
334
|
+
const tokens = {
|
|
335
|
+
access_token: 'token',
|
|
336
|
+
expires_in: 3600,
|
|
337
|
+
id_token:
|
|
338
|
+
'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.e30.Et9HFtf9R3GEMA0IICOfFMVXY7kkTX1wr4qCyhIf58U',
|
|
339
|
+
refresh_token: 'refresh',
|
|
340
|
+
scope: 'bibles unknown_permission highlights invalid_scope',
|
|
341
|
+
token_type: 'Bearer',
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
// Mock JWT decoding
|
|
345
|
+
vi.mocked(atob).mockReturnValue(JSON.stringify({ sub: 'user123' }));
|
|
346
|
+
|
|
347
|
+
// @ts-expect-error - accessing private method for testing
|
|
348
|
+
const result = YouVersionAPIUsers.extractSignInResult(tokens);
|
|
349
|
+
|
|
350
|
+
expect(result.permissions).toEqual(['bibles', 'highlights']);
|
|
351
|
+
});
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
describe('decodeJWT', () => {
|
|
355
|
+
it('should decode valid JWT token', () => {
|
|
356
|
+
const payload = { sub: '123', name: 'John' };
|
|
357
|
+
const base64Payload = String(mocks.btoa(JSON.stringify(payload)) || 'mockbase64');
|
|
358
|
+
const token = `header.${base64Payload}.signature`;
|
|
359
|
+
|
|
360
|
+
vi.mocked(atob).mockReturnValue(JSON.stringify(payload));
|
|
361
|
+
|
|
362
|
+
// @ts-expect-error - accessing private method for testing
|
|
363
|
+
const result = YouVersionAPIUsers.decodeJWT(token);
|
|
364
|
+
|
|
365
|
+
expect(result).toEqual(payload);
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it('should return empty object for invalid token format', () => {
|
|
369
|
+
// @ts-expect-error - accessing private method for testing
|
|
370
|
+
const result = YouVersionAPIUsers.decodeJWT('invalid.token');
|
|
371
|
+
|
|
372
|
+
expect(result).toEqual({});
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it('should handle base64 padding correctly', () => {
|
|
376
|
+
const payload = { sub: '123' };
|
|
377
|
+
const base64Payload = 'eyJzdWIiOiIxMjMifQ'; // Base64 without padding
|
|
378
|
+
const token = `header.${base64Payload}.signature`;
|
|
379
|
+
|
|
380
|
+
vi.mocked(atob).mockReturnValue(JSON.stringify(payload));
|
|
381
|
+
|
|
382
|
+
// @ts-expect-error - accessing private method for testing
|
|
383
|
+
const result = YouVersionAPIUsers.decodeJWT(token);
|
|
384
|
+
|
|
385
|
+
expect(result).toEqual(payload);
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
it('should return empty object when atob throws', () => {
|
|
389
|
+
const token = 'header.invalid-base64.signature';
|
|
390
|
+
vi.mocked(atob).mockImplementation(() => {
|
|
391
|
+
throw new Error('Invalid base64');
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
// @ts-expect-error - accessing private method for testing
|
|
395
|
+
const result = YouVersionAPIUsers.decodeJWT(token);
|
|
396
|
+
|
|
397
|
+
expect(result).toEqual({});
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it('should return empty object when JSON.parse throws', () => {
|
|
401
|
+
const token = 'header.dmFsaWRiYXNlNjQ.signature';
|
|
402
|
+
vi.mocked(atob).mockReturnValue('invalid json');
|
|
403
|
+
|
|
404
|
+
// @ts-expect-error - accessing private method for testing
|
|
405
|
+
const result = YouVersionAPIUsers.decodeJWT(token);
|
|
406
|
+
|
|
407
|
+
expect(result).toEqual({});
|
|
408
|
+
});
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
describe('signOut', () => {
|
|
412
|
+
it('should call setAccessToken with null', () => {
|
|
413
|
+
const setAccessTokenSpy = vi.spyOn(YouVersionPlatformConfiguration, 'clearAuthTokens');
|
|
414
|
+
|
|
415
|
+
YouVersionAPIUsers.signOut();
|
|
416
|
+
|
|
417
|
+
expect(setAccessTokenSpy).toHaveBeenCalled();
|
|
418
|
+
|
|
419
|
+
setAccessTokenSpy.mockRestore();
|
|
420
|
+
});
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
describe('userInfo', () => {
|
|
424
|
+
it('should throw error for invalid access token', () => {
|
|
425
|
+
expect(() => YouVersionAPIUsers.userInfo('')).toThrow(
|
|
426
|
+
'Invalid access token: must be a non-empty string',
|
|
427
|
+
);
|
|
428
|
+
|
|
429
|
+
expect(() => {
|
|
430
|
+
// @ts-expect-error - Testing invalid input type
|
|
431
|
+
YouVersionAPIUsers.userInfo(null);
|
|
432
|
+
}).toThrow('Invalid access token: must be a non-empty string');
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
it('should decode user info from valid JWT token', () => {
|
|
436
|
+
const claims = {
|
|
437
|
+
sub: 'user123',
|
|
438
|
+
name: 'John Doe',
|
|
439
|
+
profile_picture: 'https://example.com/avatar.jpg',
|
|
440
|
+
email: 'john@example.com',
|
|
441
|
+
};
|
|
442
|
+
|
|
443
|
+
vi.mocked(atob).mockReturnValue(JSON.stringify(claims));
|
|
444
|
+
|
|
445
|
+
const result = YouVersionAPIUsers.userInfo('valid.jwt.token');
|
|
446
|
+
|
|
447
|
+
expect(result).toBeInstanceOf(YouVersionUserInfo);
|
|
448
|
+
expect(result.userId).toBe('user123');
|
|
449
|
+
expect(result.name).toBe('John Doe');
|
|
450
|
+
expect(result.email).toBe('john@example.com');
|
|
451
|
+
expect(result.avatarUrl).toStrictEqual(new URL('https://example.com/avatar.jpg'));
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it('should throw error for empty JWT claims', () => {
|
|
455
|
+
vi.mocked(atob).mockReturnValue('{}');
|
|
456
|
+
|
|
457
|
+
expect(() => YouVersionAPIUsers.userInfo('valid.jwt.token')).toThrow(
|
|
458
|
+
'Invalid JWT token: Unable to decode token payload',
|
|
459
|
+
);
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
it('should throw error when JWT decoding fails', () => {
|
|
463
|
+
vi.mocked(atob).mockImplementation(() => {
|
|
464
|
+
throw new Error('Invalid base64');
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
expect(() => YouVersionAPIUsers.userInfo('invalid.jwt.token')).toThrow(
|
|
468
|
+
'Invalid JWT token: Unable to decode token payload',
|
|
469
|
+
);
|
|
470
|
+
});
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
describe('refreshTokens', () => {
|
|
474
|
+
beforeEach(() => {
|
|
475
|
+
vi.clearAllMocks();
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
it('should throw error when no refresh token available', async () => {
|
|
479
|
+
mocks.localStorage.getItem.mockImplementation((key: string) => {
|
|
480
|
+
if (key === 'refreshToken') return null;
|
|
481
|
+
if (key === 'idToken') return 'id-token-123';
|
|
482
|
+
return null;
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
await expect(YouVersionAPIUsers.refreshTokens()).rejects.toThrow(
|
|
486
|
+
'No refresh token or id token available',
|
|
487
|
+
);
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
it('should throw error when no id token available', async () => {
|
|
491
|
+
mocks.localStorage.getItem.mockImplementation((key: string) => {
|
|
492
|
+
if (key === 'refreshToken') return 'refresh-token-123';
|
|
493
|
+
if (key === 'idToken') return null;
|
|
494
|
+
return null;
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
await expect(YouVersionAPIUsers.refreshTokens()).rejects.toThrow(
|
|
498
|
+
'No refresh token or id token available',
|
|
499
|
+
);
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
it('should throw error when appKey is not set', async () => {
|
|
503
|
+
YouVersionPlatformConfiguration.appKey = null;
|
|
504
|
+
|
|
505
|
+
mocks.localStorage.getItem.mockImplementation((key: string) => {
|
|
506
|
+
if (key === 'refreshToken') return 'refresh-token-123';
|
|
507
|
+
if (key === 'idToken') return 'id-token-123';
|
|
508
|
+
return null;
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
await expect(YouVersionAPIUsers.refreshTokens()).rejects.toThrow(
|
|
512
|
+
'YouVersionPlatformConfiguration.appKey must be set before refreshing tokens',
|
|
513
|
+
);
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
it('should successfully refresh tokens and preserve existing id_token', async () => {
|
|
517
|
+
const originalAccessToken = 'old-access-token';
|
|
518
|
+
const originalRefreshToken = 'old-refresh-token';
|
|
519
|
+
const existingIdToken = 'existing-id-token';
|
|
520
|
+
|
|
521
|
+
const mockRefreshResponse = {
|
|
522
|
+
access_token: 'new-access-token',
|
|
523
|
+
expires_in: 3600,
|
|
524
|
+
refresh_token: 'new-refresh-token',
|
|
525
|
+
scope: 'bibles highlights openid',
|
|
526
|
+
token_type: 'Bearer',
|
|
527
|
+
};
|
|
528
|
+
|
|
529
|
+
const mockResponse = {
|
|
530
|
+
ok: true,
|
|
531
|
+
status: 200,
|
|
532
|
+
statusText: 'OK',
|
|
533
|
+
json: vi.fn().mockResolvedValue(mockRefreshResponse),
|
|
534
|
+
};
|
|
535
|
+
|
|
536
|
+
mocks.localStorage.getItem.mockImplementation((key: string) => {
|
|
537
|
+
if (key === 'refreshToken') return originalRefreshToken;
|
|
538
|
+
if (key === 'idToken') return existingIdToken;
|
|
539
|
+
if (key === 'accessToken') return originalAccessToken;
|
|
540
|
+
return null;
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
mockFetch.mockResolvedValue(mockResponse);
|
|
544
|
+
|
|
545
|
+
const saveAuthDataSpy = vi.spyOn(YouVersionPlatformConfiguration, 'saveAuthData');
|
|
546
|
+
|
|
547
|
+
const result = await YouVersionAPIUsers.refreshTokens();
|
|
548
|
+
|
|
549
|
+
expect(result).toBeTruthy();
|
|
550
|
+
|
|
551
|
+
// Assert that access_token and refresh_token are new (different from original)
|
|
552
|
+
expect(result?.accessToken).toBe('new-access-token');
|
|
553
|
+
expect(result?.accessToken).not.toBe(originalAccessToken);
|
|
554
|
+
expect(result?.refreshToken).toBe('new-refresh-token');
|
|
555
|
+
expect(result?.refreshToken).not.toBe(originalRefreshToken);
|
|
556
|
+
|
|
557
|
+
// Assert that id_token is preserved (same as original)
|
|
558
|
+
expect(result?.idToken).toBe(existingIdToken);
|
|
559
|
+
|
|
560
|
+
expect(result?.permissions).toEqual(['bibles', 'highlights']);
|
|
561
|
+
|
|
562
|
+
// Verify the refresh token request was made correctly
|
|
563
|
+
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
564
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
565
|
+
expect.objectContaining({
|
|
566
|
+
method: 'POST',
|
|
567
|
+
url: 'https://api.youversion.com/auth/token',
|
|
568
|
+
}),
|
|
569
|
+
);
|
|
570
|
+
|
|
571
|
+
const [calledRequest] = mockFetch.mock.calls[0] as [Request];
|
|
572
|
+
expect(calledRequest).toBeInstanceOf(Request);
|
|
573
|
+
expect(calledRequest.method).toBe('POST');
|
|
574
|
+
expect(calledRequest.url).toBe('https://api.youversion.com/auth/token');
|
|
575
|
+
expect(calledRequest.headers.get('content-type')).toBe('application/x-www-form-urlencoded');
|
|
576
|
+
const bodyText = await calledRequest.clone().text();
|
|
577
|
+
const body = new URLSearchParams(bodyText);
|
|
578
|
+
expect(body.get('grant_type')).toBe('refresh_token');
|
|
579
|
+
|
|
580
|
+
// Verify saveAuthData was called with new tokens but existing id_token
|
|
581
|
+
expect(saveAuthDataSpy).toHaveBeenCalledWith(
|
|
582
|
+
'new-access-token',
|
|
583
|
+
'new-refresh-token',
|
|
584
|
+
existingIdToken,
|
|
585
|
+
expect.any(Date),
|
|
586
|
+
);
|
|
587
|
+
|
|
588
|
+
saveAuthDataSpy.mockRestore();
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
it('should handle refresh token request failure', async () => {
|
|
592
|
+
const mockResponse = {
|
|
593
|
+
ok: false,
|
|
594
|
+
status: 401,
|
|
595
|
+
statusText: 'Unauthorized',
|
|
596
|
+
};
|
|
597
|
+
|
|
598
|
+
mocks.localStorage.getItem.mockImplementation((key: string) => {
|
|
599
|
+
if (key === 'refreshToken') return 'refresh-token-123';
|
|
600
|
+
if (key === 'idToken') return 'id-token-123';
|
|
601
|
+
return null;
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
mockFetch.mockResolvedValue(mockResponse);
|
|
605
|
+
|
|
606
|
+
await expect(YouVersionAPIUsers.refreshTokens()).rejects.toThrow(
|
|
607
|
+
'Token refresh failed: 401 Unauthorized',
|
|
608
|
+
);
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
it('should handle network errors during refresh', async () => {
|
|
612
|
+
mocks.localStorage.getItem.mockImplementation((key: string) => {
|
|
613
|
+
if (key === 'refreshToken') return 'refresh-token-123';
|
|
614
|
+
if (key === 'idToken') return 'id-token-123';
|
|
615
|
+
return null;
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
mockFetch.mockRejectedValue(new Error('Network error'));
|
|
619
|
+
|
|
620
|
+
await expect(YouVersionAPIUsers.refreshTokens()).rejects.toThrow(
|
|
621
|
+
'Token refresh failed: Network error',
|
|
622
|
+
);
|
|
623
|
+
});
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
describe('isTokenExpired', () => {
|
|
627
|
+
beforeEach(() => {
|
|
628
|
+
vi.clearAllMocks();
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
it('should return true when no expiry date is available', () => {
|
|
632
|
+
mocks.localStorage.getItem.mockReturnValue(null);
|
|
633
|
+
|
|
634
|
+
const result = YouVersionAPIUsers.isTokenExpired();
|
|
635
|
+
|
|
636
|
+
expect(result).toBe(true);
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
it('should return false when token is not expired', () => {
|
|
640
|
+
const futureDate = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes from now
|
|
641
|
+
mocks.localStorage.getItem.mockImplementation((key: string) => {
|
|
642
|
+
if (key === 'expiryDate') return futureDate.toISOString();
|
|
643
|
+
return null;
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
const result = YouVersionAPIUsers.isTokenExpired();
|
|
647
|
+
|
|
648
|
+
expect(result).toBe(false);
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
it('should return true when token is expired', () => {
|
|
652
|
+
const pastDate = new Date(Date.now() - 10 * 60 * 1000); // 10 minutes ago
|
|
653
|
+
mocks.localStorage.getItem.mockImplementation((key: string) => {
|
|
654
|
+
if (key === 'expiryDate') return pastDate.toISOString();
|
|
655
|
+
return null;
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
const result = YouVersionAPIUsers.isTokenExpired();
|
|
659
|
+
|
|
660
|
+
expect(result).toBe(true);
|
|
661
|
+
});
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
describe('refreshTokenIfNeeded', () => {
|
|
665
|
+
beforeEach(() => {
|
|
666
|
+
vi.clearAllMocks();
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
it('should return true when token is not expired', async () => {
|
|
670
|
+
const futureDate = new Date(Date.now() + 10 * 60 * 1000);
|
|
671
|
+
mocks.localStorage.getItem.mockImplementation((key: string) => {
|
|
672
|
+
if (key === 'expiryDate') return futureDate.toISOString();
|
|
673
|
+
return null;
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
const result = await YouVersionAPIUsers.refreshTokenIfNeeded();
|
|
677
|
+
|
|
678
|
+
expect(result).toBe(true);
|
|
679
|
+
expect(mockFetch).not.toHaveBeenCalled();
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
it('should refresh tokens when expired and return true on success', async () => {
|
|
683
|
+
const pastDate = new Date(Date.now() - 10 * 60 * 1000);
|
|
684
|
+
mocks.localStorage.getItem.mockImplementation((key: string) => {
|
|
685
|
+
if (key === 'expiryDate') return pastDate.toISOString();
|
|
686
|
+
if (key === 'refreshToken') return 'refresh-token-123';
|
|
687
|
+
if (key === 'idToken') return 'id-token-123';
|
|
688
|
+
return null;
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
const mockRefreshResponse = {
|
|
692
|
+
access_token: 'new-access-token',
|
|
693
|
+
expires_in: 3600,
|
|
694
|
+
refresh_token: 'new-refresh-token',
|
|
695
|
+
scope: 'bibles highlights openid',
|
|
696
|
+
token_type: 'Bearer',
|
|
697
|
+
};
|
|
698
|
+
|
|
699
|
+
mockFetch.mockResolvedValue({
|
|
700
|
+
ok: true,
|
|
701
|
+
status: 200,
|
|
702
|
+
statusText: 'OK',
|
|
703
|
+
json: vi.fn().mockResolvedValue(mockRefreshResponse),
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
const result = await YouVersionAPIUsers.refreshTokenIfNeeded();
|
|
707
|
+
|
|
708
|
+
expect(result).toBe(true);
|
|
709
|
+
expect(mockFetch).toHaveBeenCalled();
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
it('should return false and clear tokens when refresh fails', async () => {
|
|
713
|
+
const pastDate = new Date(Date.now() - 10 * 60 * 1000);
|
|
714
|
+
mocks.localStorage.getItem.mockImplementation((key: string) => {
|
|
715
|
+
if (key === 'expiryDate') return pastDate.toISOString();
|
|
716
|
+
if (key === 'refreshToken') return 'refresh-token-123';
|
|
717
|
+
if (key === 'idToken') return 'id-token-123';
|
|
718
|
+
return null;
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
mockFetch.mockResolvedValue({
|
|
722
|
+
ok: false,
|
|
723
|
+
status: 401,
|
|
724
|
+
statusText: 'Unauthorized',
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
const clearAuthTokensSpy = vi.spyOn(YouVersionPlatformConfiguration, 'clearAuthTokens');
|
|
728
|
+
|
|
729
|
+
const result = await YouVersionAPIUsers.refreshTokenIfNeeded();
|
|
730
|
+
|
|
731
|
+
expect(result).toBe(false);
|
|
732
|
+
expect(clearAuthTokensSpy).toHaveBeenCalled();
|
|
733
|
+
|
|
734
|
+
clearAuthTokensSpy.mockRestore();
|
|
735
|
+
});
|
|
736
|
+
});
|
|
737
|
+
});
|