@youversion/platform-core 0.6.0 → 0.8.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 +46 -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 +2 -1
- 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,418 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
+
import { SignInWithYouVersionPKCEAuthorizationRequestBuilder } from '../SignInWithYouVersionPKCE';
|
|
3
|
+
import { YouVersionPlatformConfiguration } from '../YouVersionPlatformConfiguration';
|
|
4
|
+
import { SignInWithYouVersionPermission } from '../SignInWithYouVersionResult';
|
|
5
|
+
import type { SignInWithYouVersionPermissionValues } from '../types/auth';
|
|
6
|
+
import { setupBrowserMocks, cleanupBrowserMocks } from './mocks/browser';
|
|
7
|
+
|
|
8
|
+
describe('SignInWithYouVersionPKCEAuthorizationRequestBuilder', () => {
|
|
9
|
+
let mocks: ReturnType<typeof setupBrowserMocks>;
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
mocks = setupBrowserMocks();
|
|
13
|
+
|
|
14
|
+
// Reset YouVersionPlatformConfiguration
|
|
15
|
+
YouVersionPlatformConfiguration.appKey = 'test-app-key';
|
|
16
|
+
YouVersionPlatformConfiguration.apiHost = 'api-test.youversion.com';
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
vi.clearAllMocks();
|
|
21
|
+
cleanupBrowserMocks();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe('make', () => {
|
|
25
|
+
it('should generate authorization request with all required parameters', async () => {
|
|
26
|
+
// Mock crypto.getRandomValues to return predictable values
|
|
27
|
+
mocks.crypto.getRandomValues
|
|
28
|
+
.mockImplementationOnce((array: Uint8Array) => {
|
|
29
|
+
// Mock code verifier generation (32 bytes)
|
|
30
|
+
for (let i = 0; i < 32; i++) {
|
|
31
|
+
array[i] = i + 1;
|
|
32
|
+
}
|
|
33
|
+
return array;
|
|
34
|
+
})
|
|
35
|
+
.mockImplementationOnce((array: Uint8Array) => {
|
|
36
|
+
// Mock state generation (24 bytes)
|
|
37
|
+
for (let i = 0; i < 24; i++) {
|
|
38
|
+
array[i] = i + 100;
|
|
39
|
+
}
|
|
40
|
+
return array;
|
|
41
|
+
})
|
|
42
|
+
.mockImplementationOnce((array: Uint8Array) => {
|
|
43
|
+
// Mock nonce generation (24 bytes)
|
|
44
|
+
for (let i = 0; i < 24; i++) {
|
|
45
|
+
array[i] = i + 200;
|
|
46
|
+
}
|
|
47
|
+
return array;
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// Mock crypto.subtle.digest for code challenge
|
|
51
|
+
const mockDigest = new Uint8Array(32);
|
|
52
|
+
for (let i = 0; i < 32; i++) {
|
|
53
|
+
mockDigest[i] = i + 50;
|
|
54
|
+
}
|
|
55
|
+
mocks.crypto.subtle.digest.mockResolvedValue(mockDigest.buffer);
|
|
56
|
+
|
|
57
|
+
// Mock btoa for base64 encoding
|
|
58
|
+
mocks.btoa
|
|
59
|
+
.mockReturnValueOnce('codeVerifierBase64==') // Code verifier
|
|
60
|
+
.mockReturnValueOnce('codeChallengeBase64==') // Code challenge
|
|
61
|
+
.mockReturnValueOnce('stateBase64==') // State
|
|
62
|
+
.mockReturnValueOnce('nonceBase64=='); // Nonce
|
|
63
|
+
|
|
64
|
+
const permissions = new Set<SignInWithYouVersionPermissionValues>([
|
|
65
|
+
SignInWithYouVersionPermission.bibles,
|
|
66
|
+
SignInWithYouVersionPermission.highlights,
|
|
67
|
+
]);
|
|
68
|
+
const redirectURL = new URL('https://example.com/callback');
|
|
69
|
+
|
|
70
|
+
const result = await SignInWithYouVersionPKCEAuthorizationRequestBuilder.make(
|
|
71
|
+
'test-app-key',
|
|
72
|
+
permissions,
|
|
73
|
+
redirectURL,
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
// Verify parameters structure
|
|
77
|
+
expect(result).toHaveProperty('url');
|
|
78
|
+
expect(result).toHaveProperty('parameters');
|
|
79
|
+
expect(result.parameters).toHaveProperty('codeVerifier');
|
|
80
|
+
expect(result.parameters).toHaveProperty('codeChallenge');
|
|
81
|
+
expect(result.parameters).toHaveProperty('state');
|
|
82
|
+
expect(result.parameters).toHaveProperty('nonce');
|
|
83
|
+
|
|
84
|
+
// Verify URL structure
|
|
85
|
+
expect(result.url).toBeInstanceOf(URL);
|
|
86
|
+
expect(result.url.hostname).toBe('api-test.youversion.com');
|
|
87
|
+
expect(result.url.pathname).toBe('/auth/authorize');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('should generate unique parameters on each call', async () => {
|
|
91
|
+
// Mock crypto to return different values for each call
|
|
92
|
+
let callCount = 0;
|
|
93
|
+
mocks.crypto.getRandomValues.mockImplementation((array: Uint8Array) => {
|
|
94
|
+
for (let i = 0; i < array.length; i++) {
|
|
95
|
+
array[i] = callCount + i;
|
|
96
|
+
}
|
|
97
|
+
callCount += 10;
|
|
98
|
+
return array;
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
mocks.crypto.subtle.digest.mockResolvedValue(new Uint8Array(32).buffer);
|
|
102
|
+
mocks.btoa.mockImplementation((str: string) => `base64_${callCount}_${str.length}`);
|
|
103
|
+
|
|
104
|
+
const permissions = new Set<SignInWithYouVersionPermissionValues>();
|
|
105
|
+
const redirectURL = new URL('https://example.com/callback');
|
|
106
|
+
|
|
107
|
+
const result1 = await SignInWithYouVersionPKCEAuthorizationRequestBuilder.make(
|
|
108
|
+
'app-key',
|
|
109
|
+
permissions,
|
|
110
|
+
redirectURL,
|
|
111
|
+
);
|
|
112
|
+
const result2 = await SignInWithYouVersionPKCEAuthorizationRequestBuilder.make(
|
|
113
|
+
'app-key',
|
|
114
|
+
permissions,
|
|
115
|
+
redirectURL,
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
// Parameters should be different between calls
|
|
119
|
+
expect(result1.parameters.codeVerifier).not.toBe(result2.parameters.codeVerifier);
|
|
120
|
+
expect(result1.parameters.state).not.toBe(result2.parameters.state);
|
|
121
|
+
expect(result1.parameters.nonce).not.toBe(result2.parameters.nonce);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe('authorizeURL', () => {
|
|
126
|
+
beforeEach(() => {
|
|
127
|
+
// Setup mocks for btoa which are needed for these tests
|
|
128
|
+
mocks.crypto.getRandomValues.mockImplementation((array: Uint8Array) => {
|
|
129
|
+
for (let i = 0; i < array.length; i++) {
|
|
130
|
+
array[i] = i;
|
|
131
|
+
}
|
|
132
|
+
return array;
|
|
133
|
+
});
|
|
134
|
+
mocks.crypto.subtle.digest.mockResolvedValue(new Uint8Array(32).buffer);
|
|
135
|
+
mocks.btoa.mockImplementation((str: string) => Buffer.from(str).toString('base64'));
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('should build authorization URL with all required OAuth2 parameters', async () => {
|
|
139
|
+
const permissions = new Set<SignInWithYouVersionPermissionValues>([
|
|
140
|
+
SignInWithYouVersionPermission.bibles,
|
|
141
|
+
]);
|
|
142
|
+
const redirectURL = new URL('https://example.com/callback');
|
|
143
|
+
|
|
144
|
+
const result = await SignInWithYouVersionPKCEAuthorizationRequestBuilder.make(
|
|
145
|
+
'test-app-key',
|
|
146
|
+
permissions,
|
|
147
|
+
redirectURL,
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
const url = result.url;
|
|
151
|
+
const params = new URLSearchParams(url.search);
|
|
152
|
+
|
|
153
|
+
// Required OAuth2 parameters
|
|
154
|
+
expect(params.get('response_type')).toBe('code');
|
|
155
|
+
expect(params.get('client_id')).toBe('test-app-key');
|
|
156
|
+
expect(params.get('redirect_uri')).toBe('https://example.com/callback');
|
|
157
|
+
expect(params.get('code_challenge_method')).toBe('S256');
|
|
158
|
+
|
|
159
|
+
// PKCE parameters
|
|
160
|
+
expect(params.get('code_challenge')).toBeTruthy();
|
|
161
|
+
expect(params.get('state')).toBeTruthy();
|
|
162
|
+
expect(params.get('nonce')).toBeTruthy();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('should handle redirect URL with trailing slash', async () => {
|
|
166
|
+
const permissions = new Set<SignInWithYouVersionPermissionValues>();
|
|
167
|
+
const redirectURL = new URL('https://example.com/callback/');
|
|
168
|
+
|
|
169
|
+
const result = await SignInWithYouVersionPKCEAuthorizationRequestBuilder.make(
|
|
170
|
+
'test-app-key',
|
|
171
|
+
permissions,
|
|
172
|
+
redirectURL,
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
const params = new URLSearchParams(result.url.search);
|
|
176
|
+
expect(params.get('redirect_uri')).toBe('https://example.com/callback');
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('should include x-yvp-installation-id param', async () => {
|
|
180
|
+
const permissions = new Set<SignInWithYouVersionPermissionValues>();
|
|
181
|
+
const redirectURL = new URL('https://example.com/callback');
|
|
182
|
+
|
|
183
|
+
const result = await SignInWithYouVersionPKCEAuthorizationRequestBuilder.make(
|
|
184
|
+
'test-app-key',
|
|
185
|
+
permissions,
|
|
186
|
+
redirectURL,
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
const params = new URLSearchParams(result.url.search);
|
|
190
|
+
expect(params.get('x-yvp-installation-id')).not.toBeFalsy();
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('should create scope string with permissions and openid', async () => {
|
|
194
|
+
const permissions = new Set<SignInWithYouVersionPermissionValues>([
|
|
195
|
+
SignInWithYouVersionPermission.bibles,
|
|
196
|
+
SignInWithYouVersionPermission.highlights,
|
|
197
|
+
]);
|
|
198
|
+
const redirectURL = new URL('https://example.com/callback');
|
|
199
|
+
|
|
200
|
+
const result = await SignInWithYouVersionPKCEAuthorizationRequestBuilder.make(
|
|
201
|
+
'test-app-key',
|
|
202
|
+
permissions,
|
|
203
|
+
redirectURL,
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
const params = new URLSearchParams(result.url.search);
|
|
207
|
+
const scope = params.get('scope');
|
|
208
|
+
|
|
209
|
+
expect(scope).toContain('bibles');
|
|
210
|
+
expect(scope).toContain('highlights');
|
|
211
|
+
expect(scope).toContain('openid');
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('should sort permissions alphabetically', async () => {
|
|
215
|
+
const permissions = new Set<SignInWithYouVersionPermissionValues>([
|
|
216
|
+
SignInWithYouVersionPermission.votd,
|
|
217
|
+
SignInWithYouVersionPermission.bibles,
|
|
218
|
+
SignInWithYouVersionPermission.demographics,
|
|
219
|
+
]);
|
|
220
|
+
const redirectURL = new URL('https://example.com/callback');
|
|
221
|
+
|
|
222
|
+
const result = await SignInWithYouVersionPKCEAuthorizationRequestBuilder.make(
|
|
223
|
+
'test-app-key',
|
|
224
|
+
permissions,
|
|
225
|
+
redirectURL,
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
const params = new URLSearchParams(result.url.search);
|
|
229
|
+
const scope = params.get('scope');
|
|
230
|
+
|
|
231
|
+
// Should be sorted: bibles demographics votd openid
|
|
232
|
+
expect(scope).toBe('bibles demographics votd openid');
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('should add openid when not present', async () => {
|
|
236
|
+
const permissions = new Set<SignInWithYouVersionPermissionValues>([
|
|
237
|
+
SignInWithYouVersionPermission.bibles,
|
|
238
|
+
]);
|
|
239
|
+
const redirectURL = new URL('https://example.com/callback');
|
|
240
|
+
|
|
241
|
+
const result = await SignInWithYouVersionPKCEAuthorizationRequestBuilder.make(
|
|
242
|
+
'test-app-key',
|
|
243
|
+
permissions,
|
|
244
|
+
redirectURL,
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
const params = new URLSearchParams(result.url.search);
|
|
248
|
+
const scope = params.get('scope');
|
|
249
|
+
|
|
250
|
+
expect(scope).toBe('bibles openid');
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('should handle empty permissions set', async () => {
|
|
254
|
+
const permissions = new Set<SignInWithYouVersionPermissionValues>();
|
|
255
|
+
const redirectURL = new URL('https://example.com/callback');
|
|
256
|
+
|
|
257
|
+
const result = await SignInWithYouVersionPKCEAuthorizationRequestBuilder.make(
|
|
258
|
+
'test-app-key',
|
|
259
|
+
permissions,
|
|
260
|
+
redirectURL,
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
const params = new URLSearchParams(result.url.search);
|
|
264
|
+
const scope = params.get('scope');
|
|
265
|
+
|
|
266
|
+
expect(scope).toBe('openid');
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it('should not duplicate openid if already present', async () => {
|
|
270
|
+
// This test simulates if openid was somehow in the permissions set
|
|
271
|
+
const permissions = new Set<SignInWithYouVersionPermissionValues>([
|
|
272
|
+
'openid' as SignInWithYouVersionPermissionValues,
|
|
273
|
+
SignInWithYouVersionPermission.bibles,
|
|
274
|
+
]);
|
|
275
|
+
const redirectURL = new URL('https://example.com/callback');
|
|
276
|
+
|
|
277
|
+
const result = await SignInWithYouVersionPKCEAuthorizationRequestBuilder.make(
|
|
278
|
+
'test-app-key',
|
|
279
|
+
permissions,
|
|
280
|
+
redirectURL,
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
const params = new URLSearchParams(result.url.search);
|
|
284
|
+
const scope = params.get('scope');
|
|
285
|
+
|
|
286
|
+
// Should not have duplicate openid
|
|
287
|
+
const openidCount = (scope?.match(/openid/g) || []).length;
|
|
288
|
+
expect(openidCount).toBe(1);
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
describe('tokenURLRequest', () => {
|
|
293
|
+
it('should create POST request with correct parameters', async () => {
|
|
294
|
+
const code = 'auth-code-123';
|
|
295
|
+
const codeVerifier = 'code-verifier-456';
|
|
296
|
+
const redirectUri = 'https://example.com/callback';
|
|
297
|
+
|
|
298
|
+
const request = SignInWithYouVersionPKCEAuthorizationRequestBuilder.tokenURLRequest(
|
|
299
|
+
code,
|
|
300
|
+
codeVerifier,
|
|
301
|
+
redirectUri,
|
|
302
|
+
);
|
|
303
|
+
|
|
304
|
+
expect(request.method).toBe('POST');
|
|
305
|
+
expect(request.url).toBe('https://api-test.youversion.com/auth/token');
|
|
306
|
+
expect(request.headers.get('Content-Type')).toBe('application/x-www-form-urlencoded');
|
|
307
|
+
|
|
308
|
+
const body = await request.text();
|
|
309
|
+
const params = new URLSearchParams(body);
|
|
310
|
+
|
|
311
|
+
expect(params.get('grant_type')).toBe('authorization_code');
|
|
312
|
+
expect(params.get('code')).toBe(code);
|
|
313
|
+
expect(params.get('redirect_uri')).toBe(redirectUri);
|
|
314
|
+
expect(params.get('client_id')).toBe('test-app-key');
|
|
315
|
+
expect(params.get('code_verifier')).toBe(codeVerifier);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it('should handle empty app key gracefully', async () => {
|
|
319
|
+
YouVersionPlatformConfiguration.appKey = null;
|
|
320
|
+
|
|
321
|
+
const request = SignInWithYouVersionPKCEAuthorizationRequestBuilder.tokenURLRequest(
|
|
322
|
+
'code',
|
|
323
|
+
'verifier',
|
|
324
|
+
'https://example.com/callback',
|
|
325
|
+
);
|
|
326
|
+
|
|
327
|
+
expect(request).toBeInstanceOf(Request);
|
|
328
|
+
|
|
329
|
+
const body = await request.text();
|
|
330
|
+
const params = new URLSearchParams(body);
|
|
331
|
+
|
|
332
|
+
expect(params.get('client_id')).toBe('');
|
|
333
|
+
});
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
describe('randomness and security', () => {
|
|
337
|
+
beforeEach(() => {
|
|
338
|
+
// Setup mocks for btoa which are needed for these tests
|
|
339
|
+
mocks.crypto.getRandomValues.mockImplementation((array: Uint8Array) => {
|
|
340
|
+
for (let i = 0; i < array.length; i++) {
|
|
341
|
+
array[i] = i;
|
|
342
|
+
}
|
|
343
|
+
return array;
|
|
344
|
+
});
|
|
345
|
+
mocks.crypto.subtle.digest.mockResolvedValue(new Uint8Array(32).buffer);
|
|
346
|
+
mocks.btoa.mockImplementation((str: string) => Buffer.from(str).toString('base64'));
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it('should use crypto.getRandomValues for secure random generation', async () => {
|
|
350
|
+
const permissions = new Set<SignInWithYouVersionPermissionValues>();
|
|
351
|
+
const redirectURL = new URL('https://example.com/callback');
|
|
352
|
+
|
|
353
|
+
await SignInWithYouVersionPKCEAuthorizationRequestBuilder.make(
|
|
354
|
+
'test-app-key',
|
|
355
|
+
permissions,
|
|
356
|
+
redirectURL,
|
|
357
|
+
);
|
|
358
|
+
|
|
359
|
+
// Should call crypto.getRandomValues for code verifier, state, and nonce
|
|
360
|
+
expect(mocks.crypto.getRandomValues).toHaveBeenCalledTimes(3);
|
|
361
|
+
|
|
362
|
+
// Verify correct byte lengths
|
|
363
|
+
const calls = mocks.crypto.getRandomValues.mock.calls;
|
|
364
|
+
expect(calls[0]?.[0]).toHaveLength(32); // Code verifier
|
|
365
|
+
expect(calls[1]?.[0]).toHaveLength(24); // State
|
|
366
|
+
expect(calls[2]?.[0]).toHaveLength(24); // Nonce
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it('should use SHA-256 for code challenge', async () => {
|
|
370
|
+
const permissions = new Set<SignInWithYouVersionPermissionValues>();
|
|
371
|
+
const redirectURL = new URL('https://example.com/callback');
|
|
372
|
+
|
|
373
|
+
await SignInWithYouVersionPKCEAuthorizationRequestBuilder.make(
|
|
374
|
+
'test-app-key',
|
|
375
|
+
permissions,
|
|
376
|
+
redirectURL,
|
|
377
|
+
);
|
|
378
|
+
|
|
379
|
+
expect(mocks.crypto.subtle.digest).toHaveBeenCalledWith('SHA-256', expect.any(Uint8Array));
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it('should generate parameters with sufficient entropy', async () => {
|
|
383
|
+
// Use real crypto for this test to verify actual randomness
|
|
384
|
+
cleanupBrowserMocks();
|
|
385
|
+
|
|
386
|
+
const permissions = new Set<SignInWithYouVersionPermissionValues>();
|
|
387
|
+
const redirectURL = new URL('https://example.com/callback');
|
|
388
|
+
|
|
389
|
+
const result1 = await SignInWithYouVersionPKCEAuthorizationRequestBuilder.make(
|
|
390
|
+
'test-app-key',
|
|
391
|
+
permissions,
|
|
392
|
+
redirectURL,
|
|
393
|
+
);
|
|
394
|
+
const result2 = await SignInWithYouVersionPKCEAuthorizationRequestBuilder.make(
|
|
395
|
+
'test-app-key',
|
|
396
|
+
permissions,
|
|
397
|
+
redirectURL,
|
|
398
|
+
);
|
|
399
|
+
|
|
400
|
+
// All parameters should be different between calls
|
|
401
|
+
expect(result1.parameters.codeVerifier).not.toBe(result2.parameters.codeVerifier);
|
|
402
|
+
expect(result1.parameters.codeChallenge).not.toBe(result2.parameters.codeChallenge);
|
|
403
|
+
expect(result1.parameters.state).not.toBe(result2.parameters.state);
|
|
404
|
+
expect(result1.parameters.nonce).not.toBe(result2.parameters.nonce);
|
|
405
|
+
|
|
406
|
+
// Parameters should have reasonable length (base64url encoded)
|
|
407
|
+
expect(result1.parameters.codeVerifier.length).toBeGreaterThan(40);
|
|
408
|
+
expect(result1.parameters.state.length).toBeGreaterThan(30);
|
|
409
|
+
expect(result1.parameters.nonce.length).toBeGreaterThan(30);
|
|
410
|
+
|
|
411
|
+
// Parameters should be URL-safe (no +, /, or =)
|
|
412
|
+
expect(result1.parameters.codeVerifier).not.toMatch(/[+/=]/);
|
|
413
|
+
expect(result1.parameters.codeChallenge).not.toMatch(/[+/=]/);
|
|
414
|
+
expect(result1.parameters.state).not.toMatch(/[+/=]/);
|
|
415
|
+
expect(result1.parameters.nonce).not.toMatch(/[+/=]/);
|
|
416
|
+
});
|
|
417
|
+
});
|
|
418
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { SignInWithYouVersionResult } from '../SignInWithYouVersionResult';
|
|
3
|
+
|
|
4
|
+
describe('SignInWithYouVersionResult', () => {
|
|
5
|
+
it('sets all properties properly', () => {
|
|
6
|
+
const fixedDate = new Date(2025, 2, 11, 12, 0, 0);
|
|
7
|
+
vi.setSystemTime(fixedDate);
|
|
8
|
+
const result = new SignInWithYouVersionResult({
|
|
9
|
+
accessToken: 'test-access-token',
|
|
10
|
+
expiresIn: 3600,
|
|
11
|
+
refreshToken: 'test-refresh-token',
|
|
12
|
+
permissions: ['votd', 'bibles'],
|
|
13
|
+
yvpUserId: 'test-user-id',
|
|
14
|
+
name: 'test user',
|
|
15
|
+
profilePicture: 'https://this-is-a-test-picture.com',
|
|
16
|
+
email: 'test@example.com',
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
expect(result.accessToken).toBe('test-access-token');
|
|
20
|
+
expect(result.expiryDate).toStrictEqual(new Date(fixedDate.getTime() + 60 * 60 * 1000));
|
|
21
|
+
expect(result.refreshToken).toBe('test-refresh-token');
|
|
22
|
+
expect(result.permissions).toStrictEqual(['votd', 'bibles']);
|
|
23
|
+
expect(result.yvpUserId).toBe('test-user-id');
|
|
24
|
+
expect(result.name).toBe('test user');
|
|
25
|
+
expect(result.profilePicture).toBe('https://this-is-a-test-picture.com');
|
|
26
|
+
expect(result.email).toBe('test@example.com');
|
|
27
|
+
});
|
|
28
|
+
});
|
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
5
5
|
import { SessionStorageStrategy, MemoryStorageStrategy } from '../StorageStrategy';
|
|
6
|
-
import { WebAuthenticationStrategy } from '../WebAuthenticationStrategy';
|
|
7
6
|
|
|
8
7
|
describe('SessionStorageStrategy', () => {
|
|
9
8
|
let strategy: SessionStorageStrategy;
|
|
@@ -291,77 +290,6 @@ describe('MemoryStorageStrategy', () => {
|
|
|
291
290
|
});
|
|
292
291
|
});
|
|
293
292
|
|
|
294
|
-
describe('WebAuthenticationStrategy - Custom Storage', () => {
|
|
295
|
-
beforeEach(() => {
|
|
296
|
-
sessionStorage.clear();
|
|
297
|
-
});
|
|
298
|
-
|
|
299
|
-
afterEach(() => {
|
|
300
|
-
sessionStorage.clear();
|
|
301
|
-
});
|
|
302
|
-
|
|
303
|
-
it('should use SessionStorageStrategy by default', () => {
|
|
304
|
-
const strategy = new WebAuthenticationStrategy();
|
|
305
|
-
|
|
306
|
-
// The strategy should use sessionStorage internally (we can't access private field directly)
|
|
307
|
-
// But we can verify it works by checking that data persists in sessionStorage
|
|
308
|
-
// This is an indirect test
|
|
309
|
-
expect(strategy).toBeInstanceOf(WebAuthenticationStrategy);
|
|
310
|
-
});
|
|
311
|
-
|
|
312
|
-
it('should accept custom storage strategy in constructor', () => {
|
|
313
|
-
const customStorage = new MemoryStorageStrategy();
|
|
314
|
-
|
|
315
|
-
const strategy = new WebAuthenticationStrategy({
|
|
316
|
-
storage: customStorage,
|
|
317
|
-
});
|
|
318
|
-
|
|
319
|
-
expect(strategy).toBeInstanceOf(WebAuthenticationStrategy);
|
|
320
|
-
// The strategy should use the custom storage
|
|
321
|
-
});
|
|
322
|
-
|
|
323
|
-
it('should use custom MemoryStorageStrategy when provided', () => {
|
|
324
|
-
const memoryStorage = new MemoryStorageStrategy();
|
|
325
|
-
|
|
326
|
-
const strategy = new WebAuthenticationStrategy({
|
|
327
|
-
storage: memoryStorage,
|
|
328
|
-
});
|
|
329
|
-
|
|
330
|
-
expect(strategy).toBeInstanceOf(WebAuthenticationStrategy);
|
|
331
|
-
});
|
|
332
|
-
|
|
333
|
-
it('should support custom storage implementations', () => {
|
|
334
|
-
// Create a custom storage implementation
|
|
335
|
-
class CustomStorageStrategy {
|
|
336
|
-
private data = new Map<string, string>();
|
|
337
|
-
|
|
338
|
-
setItem(key: string, value: string): void {
|
|
339
|
-
this.data.set(key, value);
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
getItem(key: string): string | null {
|
|
343
|
-
return this.data.get(key) ?? null;
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
removeItem(key: string): void {
|
|
347
|
-
this.data.delete(key);
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
clear(): void {
|
|
351
|
-
this.data.clear();
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
const customStorage = new CustomStorageStrategy();
|
|
356
|
-
|
|
357
|
-
const strategy = new WebAuthenticationStrategy({
|
|
358
|
-
storage: customStorage,
|
|
359
|
-
});
|
|
360
|
-
|
|
361
|
-
expect(strategy).toBeInstanceOf(WebAuthenticationStrategy);
|
|
362
|
-
});
|
|
363
|
-
});
|
|
364
|
-
|
|
365
293
|
describe('StorageStrategy Interface Compliance', () => {
|
|
366
294
|
const testCases = [
|
|
367
295
|
{ name: 'SessionStorageStrategy', factory: () => new SessionStorageStrategy() },
|
|
@@ -154,79 +154,6 @@ describe('URLBuilder - Input Validation', () => {
|
|
|
154
154
|
});
|
|
155
155
|
});
|
|
156
156
|
|
|
157
|
-
describe('userURL - accessToken validation', () => {
|
|
158
|
-
it('should throw error for empty string accessToken', () => {
|
|
159
|
-
expect(() => {
|
|
160
|
-
URLBuilder.userURL('');
|
|
161
|
-
}).toThrow('accessToken must be a non-empty string');
|
|
162
|
-
});
|
|
163
|
-
|
|
164
|
-
it('should throw error for whitespace-only accessToken', () => {
|
|
165
|
-
expect(() => {
|
|
166
|
-
URLBuilder.userURL(' ');
|
|
167
|
-
}).toThrow('accessToken must be a non-empty string');
|
|
168
|
-
});
|
|
169
|
-
|
|
170
|
-
it('should throw error for tab/newline-only accessToken', () => {
|
|
171
|
-
expect(() => {
|
|
172
|
-
URLBuilder.userURL('\t\n ');
|
|
173
|
-
}).toThrow('accessToken must be a non-empty string');
|
|
174
|
-
});
|
|
175
|
-
|
|
176
|
-
it('should throw descriptive error message', () => {
|
|
177
|
-
try {
|
|
178
|
-
URLBuilder.userURL('');
|
|
179
|
-
expect.fail('Should have thrown an error');
|
|
180
|
-
} catch (error) {
|
|
181
|
-
expect(error).toBeInstanceOf(Error);
|
|
182
|
-
expect((error as Error).message).toContain('accessToken');
|
|
183
|
-
expect((error as Error).message).toContain('non-empty string');
|
|
184
|
-
}
|
|
185
|
-
});
|
|
186
|
-
|
|
187
|
-
it('should accept valid non-empty accessToken', () => {
|
|
188
|
-
const url = URLBuilder.userURL('valid-access-token-123');
|
|
189
|
-
|
|
190
|
-
expect(url).toBeInstanceOf(URL);
|
|
191
|
-
expect(url.hostname.endsWith('.youversion.com')).toBe(true);
|
|
192
|
-
expect(url.pathname).toBe('/auth/me');
|
|
193
|
-
expect(url.searchParams.get('lat')).toBe('valid-access-token-123');
|
|
194
|
-
});
|
|
195
|
-
|
|
196
|
-
it('should accept accessToken with special characters', () => {
|
|
197
|
-
const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.test.signature';
|
|
198
|
-
const url = URLBuilder.userURL(token);
|
|
199
|
-
|
|
200
|
-
expect(url.searchParams.get('lat')).toBe(token);
|
|
201
|
-
});
|
|
202
|
-
});
|
|
203
|
-
|
|
204
|
-
describe('userURL - URL construction', () => {
|
|
205
|
-
it('should construct correct base URL and pathname', () => {
|
|
206
|
-
const url = URLBuilder.userURL('test-token');
|
|
207
|
-
|
|
208
|
-
expect(url.protocol).toBe('https:');
|
|
209
|
-
expect(url.hostname.endsWith('.youversion.com')).toBe(true);
|
|
210
|
-
expect(url.pathname).toBe('/auth/me');
|
|
211
|
-
});
|
|
212
|
-
|
|
213
|
-
it('should include access token in lat query parameter', () => {
|
|
214
|
-
const token = 'my-access-token-abc123';
|
|
215
|
-
const url = URLBuilder.userURL(token);
|
|
216
|
-
|
|
217
|
-
expect(url.searchParams.get('lat')).toBe(token);
|
|
218
|
-
});
|
|
219
|
-
|
|
220
|
-
it('should properly encode special characters in token', () => {
|
|
221
|
-
const tokenWithSpecialChars = 'token+with/special=chars';
|
|
222
|
-
const url = URLBuilder.userURL(tokenWithSpecialChars);
|
|
223
|
-
|
|
224
|
-
// URLSearchParams automatically encodes special characters
|
|
225
|
-
expect(url.searchParams.get('lat')).toBe(tokenWithSpecialChars);
|
|
226
|
-
expect(url.toString()).toContain('token%2Bwith%2Fspecial%3Dchars');
|
|
227
|
-
});
|
|
228
|
-
});
|
|
229
|
-
|
|
230
157
|
describe('Error handling', () => {
|
|
231
158
|
it('should throw errors instead of returning null for invalid appKey', () => {
|
|
232
159
|
// Verify that the method throws, not returns null
|
|
@@ -243,20 +170,6 @@ describe('URLBuilder - Input Validation', () => {
|
|
|
243
170
|
expect(returnValue).toBeUndefined();
|
|
244
171
|
});
|
|
245
172
|
|
|
246
|
-
it('should throw errors instead of returning null for invalid accessToken', () => {
|
|
247
|
-
let threwError = false;
|
|
248
|
-
let returnValue: any;
|
|
249
|
-
|
|
250
|
-
try {
|
|
251
|
-
returnValue = URLBuilder.userURL('');
|
|
252
|
-
} catch {
|
|
253
|
-
threwError = true;
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
expect(threwError).toBe(true);
|
|
257
|
-
expect(returnValue).toBeUndefined();
|
|
258
|
-
});
|
|
259
|
-
|
|
260
173
|
it('should wrap URL construction errors with descriptive message for authURL', () => {
|
|
261
174
|
// This is hard to trigger, but we can at least verify the pattern exists
|
|
262
175
|
// by checking that valid inputs don't trigger the catch block
|
|
@@ -264,12 +177,6 @@ describe('URLBuilder - Input Validation', () => {
|
|
|
264
177
|
URLBuilder.authURL('valid-app-key');
|
|
265
178
|
}).not.toThrow(/Failed to construct auth URL/);
|
|
266
179
|
});
|
|
267
|
-
|
|
268
|
-
it('should wrap URL construction errors with descriptive message for userURL', () => {
|
|
269
|
-
expect(() => {
|
|
270
|
-
URLBuilder.userURL('valid-token');
|
|
271
|
-
}).not.toThrow(/Failed to construct user URL/);
|
|
272
|
-
});
|
|
273
180
|
});
|
|
274
181
|
|
|
275
182
|
describe('Return type validation', () => {
|
|
@@ -279,12 +186,5 @@ describe('URLBuilder - Input Validation', () => {
|
|
|
279
186
|
expect(result).toBeInstanceOf(URL);
|
|
280
187
|
expect(result).not.toBeNull();
|
|
281
188
|
});
|
|
282
|
-
|
|
283
|
-
it('userURL should return URL object (not null)', () => {
|
|
284
|
-
const result = URLBuilder.userURL('test-token');
|
|
285
|
-
|
|
286
|
-
expect(result).toBeInstanceOf(URL);
|
|
287
|
-
expect(result).not.toBeNull();
|
|
288
|
-
});
|
|
289
189
|
});
|
|
290
190
|
});
|