@mediaviz/sdk 0.1.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.
Files changed (52) hide show
  1. package/MediaViz.js +126 -0
  2. package/_oauth.js +3 -0
  3. package/admin.js +93 -0
  4. package/ai_model_credits.js +22 -0
  5. package/company.js +54 -0
  6. package/curated_albums.js +85 -0
  7. package/custom_albums.js +78 -0
  8. package/dist/sdk.cjs +1976 -0
  9. package/dist/sdk.esm.js +1947 -0
  10. package/dist/sdk.umd.js +1982 -0
  11. package/email_tokens.js +64 -0
  12. package/errors.js +81 -0
  13. package/health.js +20 -0
  14. package/index.js +21 -0
  15. package/keywords.js +123 -0
  16. package/oauth/.prettierrc +6 -0
  17. package/oauth/README.md +76 -0
  18. package/oauth/browser-smoke-test.html +45 -0
  19. package/oauth/implementation_plan.json +106 -0
  20. package/oauth/package-lock.json +5236 -0
  21. package/oauth/package.json +28 -0
  22. package/oauth/rollup.config.js +21 -0
  23. package/oauth/smoke-test.js +27 -0
  24. package/oauth/spec.md +187 -0
  25. package/oauth/src/__tests__/browser-smoke-test.test.js +38 -0
  26. package/oauth/src/__tests__/client.test.js +556 -0
  27. package/oauth/src/__tests__/errors.test.js +73 -0
  28. package/oauth/src/__tests__/http.test.js +102 -0
  29. package/oauth/src/__tests__/index.test.js +53 -0
  30. package/oauth/src/__tests__/package-fields.test.js +29 -0
  31. package/oauth/src/__tests__/pkce.test.js +55 -0
  32. package/oauth/src/__tests__/rollup-build.test.js +58 -0
  33. package/oauth/src/__tests__/smoke-test.test.js +26 -0
  34. package/oauth/src/__tests__/types.test.js +29 -0
  35. package/oauth/src/client.js +180 -0
  36. package/oauth/src/errors.js +32 -0
  37. package/oauth/src/http.js +52 -0
  38. package/oauth/src/index.js +7 -0
  39. package/oauth/src/pkce.js +50 -0
  40. package/oauth/src/types.js +67 -0
  41. package/oauth_authorization.js +53 -0
  42. package/oauth_clients.js +18 -0
  43. package/oauth_login.js +24 -0
  44. package/oauth_token.js +30 -0
  45. package/package.json +27 -0
  46. package/person.js +54 -0
  47. package/photos.js +106 -0
  48. package/photoupload.js +55 -0
  49. package/projects.js +191 -0
  50. package/rollup.config.js +12 -0
  51. package/search.js +99 -0
  52. package/users.js +137 -0
@@ -0,0 +1,556 @@
1
+ 'use strict';
2
+
3
+ const { OAuthClient } = require('../client');
4
+ const { OAuthError } = require('../errors');
5
+
6
+ const config = {
7
+ baseUrl: 'https://auth.example.com',
8
+ clientId: 'test-client-id',
9
+ clientSecret: 'test-client-secret',
10
+ redirectUri: 'https://myapp.com/callback',
11
+ };
12
+
13
+ function makeFetchMock(status, body) {
14
+ return jest.fn().mockResolvedValue({
15
+ ok: status >= 200 && status < 300,
16
+ status,
17
+ json: () => Promise.resolve(body),
18
+ });
19
+ }
20
+
21
+ describe('OAuthClient', () => {
22
+ let client;
23
+
24
+ beforeEach(() => {
25
+ client = new OAuthClient(config);
26
+ });
27
+
28
+ afterEach(() => {
29
+ jest.restoreAllMocks();
30
+ });
31
+
32
+ describe('registerClient', () => {
33
+ it('POSTs JSON to /oauth/clients and returns response', async () => {
34
+ const regResponse = {
35
+ client_id: 'new-id',
36
+ client_name: 'My App',
37
+ client_type: 'confidential',
38
+ redirect_uris: ['https://myapp.com/callback'],
39
+ client_secret: 'secret-123',
40
+ };
41
+ const mockFetch = makeFetchMock(201, regResponse);
42
+ jest.spyOn(global, 'fetch').mockImplementation(mockFetch);
43
+
44
+ const result = await OAuthClient.registerClient({
45
+ baseUrl: 'https://auth.example.com',
46
+ clientName: 'My App',
47
+ clientType: 'confidential',
48
+ redirectUris: ['https://myapp.com/callback'],
49
+ isFirstParty: false,
50
+ });
51
+
52
+ expect(result).toEqual(regResponse);
53
+ const [url, options] = mockFetch.mock.calls[0];
54
+ expect(url).toBe('https://auth.example.com/oauth/clients');
55
+ expect(options.method).toBe('POST');
56
+ expect(options.headers['Content-Type']).toBe('application/json');
57
+ const body = JSON.parse(options.body);
58
+ expect(body).toEqual({
59
+ client_name: 'My App',
60
+ client_type: 'confidential',
61
+ redirect_uris: ['https://myapp.com/callback'],
62
+ is_first_party: false,
63
+ });
64
+ });
65
+
66
+ it('strips trailing slash from baseUrl', async () => {
67
+ const mockFetch = makeFetchMock(201, { client_id: 'id' });
68
+ jest.spyOn(global, 'fetch').mockImplementation(mockFetch);
69
+
70
+ await OAuthClient.registerClient({
71
+ baseUrl: 'https://auth.example.com/',
72
+ clientName: 'App',
73
+ clientType: 'public',
74
+ redirectUris: ['https://app.com/cb'],
75
+ isFirstParty: true,
76
+ });
77
+
78
+ expect(mockFetch.mock.calls[0][0]).toBe('https://auth.example.com/oauth/clients');
79
+ });
80
+
81
+ it('throws OAuthError on non-2xx', async () => {
82
+ const mockFetch = makeFetchMock(400, { error: 'invalid_request', error_description: 'Missing name' });
83
+ jest.spyOn(global, 'fetch').mockImplementation(mockFetch);
84
+
85
+ await expect(OAuthClient.registerClient({
86
+ baseUrl: 'https://auth.example.com',
87
+ clientName: '',
88
+ clientType: 'confidential',
89
+ redirectUris: ['https://app.com/cb'],
90
+ isFirstParty: false,
91
+ })).rejects.toBeInstanceOf(OAuthError);
92
+ });
93
+ });
94
+
95
+ describe('generateAuthorizationUrl', () => {
96
+ it('returns url, state, and code_verifier', async () => {
97
+ const result = await client.generateAuthorizationUrl();
98
+ expect(result).toHaveProperty('url');
99
+ expect(result).toHaveProperty('state');
100
+ expect(result).toHaveProperty('code_verifier');
101
+ });
102
+
103
+ it('url contains required OAuth params', async () => {
104
+ const result = await client.generateAuthorizationUrl();
105
+ const url = new URL(result.url);
106
+ expect(url.searchParams.get('response_type')).toBe('code');
107
+ expect(url.searchParams.get('client_id')).toBe(config.clientId);
108
+ expect(url.searchParams.get('redirect_uri')).toBe(config.redirectUri);
109
+ expect(url.searchParams.get('code_challenge_method')).toBe('S256');
110
+ expect(url.searchParams.get('state')).toBe(result.state);
111
+ expect(url.searchParams.get('code_challenge')).toBeTruthy();
112
+ });
113
+
114
+ it('url base matches baseUrl + /oauth/authorize', async () => {
115
+ const result = await client.generateAuthorizationUrl();
116
+ expect(result.url).toMatch(/^https:\/\/auth\.example\.com\/oauth\/authorize/);
117
+ });
118
+
119
+ it('code_verifier is 64 chars', async () => {
120
+ const result = await client.generateAuthorizationUrl();
121
+ expect(result.code_verifier).toHaveLength(64);
122
+ });
123
+
124
+ it('state is 32 chars', async () => {
125
+ const result = await client.generateAuthorizationUrl();
126
+ expect(result.state).toHaveLength(32);
127
+ });
128
+
129
+ it('uses provided state', async () => {
130
+ const result = await client.generateAuthorizationUrl('my-state');
131
+ expect(result.state).toBe('my-state');
132
+ const url = new URL(result.url);
133
+ expect(url.searchParams.get('state')).toBe('my-state');
134
+ });
135
+ });
136
+
137
+ describe('exchangeCode', () => {
138
+ it('POSTs to /oauth/token with correct params and returns TokenResponse', async () => {
139
+ const tokenResponse = {
140
+ access_token: 'access-tok',
141
+ token_type: 'bearer',
142
+ expires_in: 3600,
143
+ refresh_token: 'refresh-tok',
144
+ };
145
+ const mockFetch = makeFetchMock(200, tokenResponse);
146
+ jest.spyOn(global, 'fetch').mockImplementation(mockFetch);
147
+
148
+ const result = await client.exchangeCode('auth-code', 'verifier123');
149
+
150
+ expect(result).toEqual(tokenResponse);
151
+ const [url, options] = mockFetch.mock.calls[0];
152
+ expect(url).toBe('https://auth.example.com/oauth/token');
153
+ expect(options.method).toBe('POST');
154
+ const body = new URLSearchParams(options.body);
155
+ expect(body.get('grant_type')).toBe('authorization_code');
156
+ expect(body.get('code')).toBe('auth-code');
157
+ expect(body.get('code_verifier')).toBe('verifier123');
158
+ expect(body.get('redirect_uri')).toBe(config.redirectUri);
159
+ expect(body.get('client_id')).toBe(config.clientId);
160
+ expect(body.get('client_secret')).toBe(config.clientSecret);
161
+ });
162
+
163
+ it('uses provided redirectUri when given', async () => {
164
+ const mockFetch = makeFetchMock(200, { access_token: 'a', token_type: 'bearer', expires_in: 3600, refresh_token: 'r' });
165
+ jest.spyOn(global, 'fetch').mockImplementation(mockFetch);
166
+
167
+ await client.exchangeCode('code', 'verifier', 'https://other.com/cb');
168
+
169
+ const [, options] = mockFetch.mock.calls[0];
170
+ const body = new URLSearchParams(options.body);
171
+ expect(body.get('redirect_uri')).toBe('https://other.com/cb');
172
+ });
173
+
174
+ it('throws OAuthError on non-2xx', async () => {
175
+ const mockFetch = makeFetchMock(400, { error: 'invalid_grant', error_description: 'Bad code' });
176
+ jest.spyOn(global, 'fetch').mockImplementation(mockFetch);
177
+
178
+ await expect(client.exchangeCode('bad-code', 'verifier')).rejects.toBeInstanceOf(OAuthError);
179
+ });
180
+ });
181
+
182
+ describe('refreshAccessToken', () => {
183
+ it('POSTs to /oauth/token with grant_type=refresh_token', async () => {
184
+ const tokenResponse = {
185
+ access_token: 'new-access',
186
+ token_type: 'bearer',
187
+ expires_in: 3600,
188
+ refresh_token: 'new-refresh',
189
+ };
190
+ const mockFetch = makeFetchMock(200, tokenResponse);
191
+ jest.spyOn(global, 'fetch').mockImplementation(mockFetch);
192
+
193
+ const result = await client.refreshAccessToken('old-refresh');
194
+
195
+ expect(result).toEqual(tokenResponse);
196
+ const [url, options] = mockFetch.mock.calls[0];
197
+ expect(url).toBe('https://auth.example.com/oauth/token');
198
+ const body = new URLSearchParams(options.body);
199
+ expect(body.get('grant_type')).toBe('refresh_token');
200
+ expect(body.get('refresh_token')).toBe('old-refresh');
201
+ expect(body.get('client_id')).toBe(config.clientId);
202
+ expect(body.get('client_secret')).toBe(config.clientSecret);
203
+ });
204
+
205
+ it('throws OAuthError on non-2xx', async () => {
206
+ const mockFetch = makeFetchMock(401, { error: 'invalid_grant', error_description: 'Expired' });
207
+ jest.spyOn(global, 'fetch').mockImplementation(mockFetch);
208
+
209
+ await expect(client.refreshAccessToken('bad-refresh')).rejects.toBeInstanceOf(OAuthError);
210
+ });
211
+ });
212
+
213
+ describe('revokeToken', () => {
214
+ it('POSTs to /oauth/revoke with token', async () => {
215
+ const mockFetch = makeFetchMock(200, {});
216
+ jest.spyOn(global, 'fetch').mockImplementation(mockFetch);
217
+
218
+ await expect(client.revokeToken('some-token')).resolves.toBeUndefined();
219
+
220
+ const [url, options] = mockFetch.mock.calls[0];
221
+ expect(url).toBe('https://auth.example.com/oauth/revoke');
222
+ const body = new URLSearchParams(options.body);
223
+ expect(body.get('token')).toBe('some-token');
224
+ });
225
+
226
+ it('includes token_type_hint when provided', async () => {
227
+ const mockFetch = makeFetchMock(200, {});
228
+ jest.spyOn(global, 'fetch').mockImplementation(mockFetch);
229
+
230
+ await client.revokeToken('tok', 'access_token');
231
+
232
+ const [, options] = mockFetch.mock.calls[0];
233
+ const body = new URLSearchParams(options.body);
234
+ expect(body.get('token_type_hint')).toBe('access_token');
235
+ });
236
+
237
+ it('omits token_type_hint when not provided', async () => {
238
+ const mockFetch = makeFetchMock(200, {});
239
+ jest.spyOn(global, 'fetch').mockImplementation(mockFetch);
240
+
241
+ await client.revokeToken('tok');
242
+
243
+ const [, options] = mockFetch.mock.calls[0];
244
+ const body = new URLSearchParams(options.body);
245
+ expect(body.get('token_type_hint')).toBeNull();
246
+ });
247
+ });
248
+
249
+ describe('request', () => {
250
+ const freshTokens = {
251
+ access_token: 'new-access',
252
+ token_type: 'bearer',
253
+ expires_in: 3600,
254
+ refresh_token: 'new-refresh',
255
+ };
256
+
257
+ it('returns data with updatedTokens null on 2xx (no 401)', async () => {
258
+ jest.spyOn(global, 'fetch').mockResolvedValue({
259
+ ok: true, status: 200, json: () => Promise.resolve({ user: 'alice' }),
260
+ });
261
+
262
+ const result = await client.request('/me', 'GET', 'access-tok', 'refresh-tok');
263
+
264
+ expect(fetch).toHaveBeenCalledWith('https://auth.example.com/me', expect.anything());
265
+ expect(result.data).toEqual({ user: 'alice' });
266
+ expect(result.updatedTokens).toBeNull();
267
+ });
268
+
269
+ it('attaches Authorization header on initial request', async () => {
270
+ const mockFetch = jest.fn().mockResolvedValue({
271
+ ok: true, status: 200, json: () => Promise.resolve({}),
272
+ });
273
+ jest.spyOn(global, 'fetch').mockImplementation(mockFetch);
274
+
275
+ await client.request('/me', 'GET', 'my-token', 'refresh-tok');
276
+
277
+ expect(mockFetch.mock.calls[0][0]).toBe('https://auth.example.com/me');
278
+ expect(mockFetch.mock.calls[0][1].headers.Authorization).toBe('Bearer my-token');
279
+ });
280
+
281
+ it('returns data and updatedTokens on 401 → refresh → retry success', async () => {
282
+ jest.spyOn(global, 'fetch')
283
+ .mockResolvedValueOnce({ ok: false, status: 401, json: () => Promise.resolve({}) })
284
+ .mockResolvedValueOnce({ ok: true, status: 200, json: () => Promise.resolve(freshTokens) })
285
+ .mockResolvedValueOnce({ ok: true, status: 200, json: () => Promise.resolve({ user: 'alice' }) });
286
+
287
+ const result = await client.request('/me', 'GET', 'old-access', 'refresh-tok');
288
+
289
+ expect(result.data).toEqual({ user: 'alice' });
290
+ expect(result.updatedTokens).toEqual(freshTokens);
291
+ });
292
+
293
+ it('propagates OAuthError when refresh fails on 401', async () => {
294
+ jest.spyOn(global, 'fetch')
295
+ .mockResolvedValueOnce({ ok: false, status: 401, json: () => Promise.resolve({}) })
296
+ .mockResolvedValueOnce({
297
+ ok: false, status: 400,
298
+ json: () => Promise.resolve({ error: 'invalid_grant', error_description: 'Token expired' }),
299
+ });
300
+
301
+ await expect(
302
+ client.request('/me', 'GET', 'old-access', 'bad-refresh')
303
+ ).rejects.toBeInstanceOf(OAuthError);
304
+ });
305
+
306
+ it('throws OAuthError when retry after refresh also returns 401', async () => {
307
+ jest.spyOn(global, 'fetch')
308
+ .mockResolvedValueOnce({ ok: false, status: 401, json: () => Promise.resolve({}) })
309
+ .mockResolvedValueOnce({ ok: true, status: 200, json: () => Promise.resolve(freshTokens) })
310
+ .mockResolvedValueOnce({
311
+ ok: false, status: 401,
312
+ json: () => Promise.resolve({ error: 'invalid_token', error_description: 'Token invalid' }),
313
+ });
314
+
315
+ await expect(
316
+ client.request('/me', 'GET', 'old-access', 'refresh-tok')
317
+ ).rejects.toBeInstanceOf(OAuthError);
318
+ });
319
+
320
+ it('uses new access token in Authorization header on retry', async () => {
321
+ const mockFetch = jest.fn()
322
+ .mockResolvedValueOnce({ ok: false, status: 401, json: () => Promise.resolve({}) })
323
+ .mockResolvedValueOnce({ ok: true, status: 200, json: () => Promise.resolve(freshTokens) })
324
+ .mockResolvedValueOnce({ ok: true, status: 200, json: () => Promise.resolve({}) });
325
+ jest.spyOn(global, 'fetch').mockImplementation(mockFetch);
326
+
327
+ await client.request('/me', 'GET', 'old-access', 'refresh-tok');
328
+
329
+ expect(mockFetch.mock.calls[0][0]).toBe('https://auth.example.com/me');
330
+ expect(mockFetch.mock.calls[0][1].headers.Authorization).toBe('Bearer old-access');
331
+ expect(mockFetch.mock.calls[2][0]).toBe('https://auth.example.com/me');
332
+ expect(mockFetch.mock.calls[2][1].headers.Authorization).toBe('Bearer new-access');
333
+ });
334
+
335
+ it('coalesces concurrent refreshes with same refresh_token into one network call', async () => {
336
+ let releaseRefresh;
337
+ const refreshGate = new Promise((resolve) => { releaseRefresh = resolve; });
338
+ let initialCount = 0;
339
+
340
+ const mockFetch = jest.fn().mockImplementation(async (url) => {
341
+ if (url === 'https://auth.example.com/oauth/token') {
342
+ await refreshGate;
343
+ return { ok: true, status: 200, json: () => Promise.resolve(freshTokens) };
344
+ }
345
+ initialCount += 1;
346
+ if (initialCount <= 5) {
347
+ return { ok: false, status: 401, json: () => Promise.resolve({}) };
348
+ }
349
+ return { ok: true, status: 200, json: () => Promise.resolve({ user: 'alice' }) };
350
+ });
351
+ jest.spyOn(global, 'fetch').mockImplementation(mockFetch);
352
+
353
+ const results = Promise.all(
354
+ Array.from({ length: 5 }, () =>
355
+ client.request('/me', 'GET', 'old-access', 'refresh-tok')
356
+ )
357
+ );
358
+ // Let all 5 initial 401s resolve and reach the refresh coalescing point.
359
+ await new Promise((resolve) => setImmediate(resolve));
360
+ releaseRefresh();
361
+
362
+ const all = await results;
363
+ expect(all).toHaveLength(5);
364
+ all.forEach((r) => {
365
+ expect(r.data).toEqual({ user: 'alice' });
366
+ expect(r.updatedTokens).toEqual(freshTokens);
367
+ });
368
+
369
+ const refreshCalls = mockFetch.mock.calls.filter(
370
+ ([u]) => u === 'https://auth.example.com/oauth/token'
371
+ );
372
+ expect(refreshCalls).toHaveLength(1);
373
+ });
374
+
375
+ it('does not coalesce refreshes for different refresh_tokens', async () => {
376
+ let initialCount = 0;
377
+ const mockFetch = jest.fn().mockImplementation(async (url) => {
378
+ if (url === 'https://auth.example.com/oauth/token') {
379
+ return { ok: true, status: 200, json: () => Promise.resolve(freshTokens) };
380
+ }
381
+ initialCount += 1;
382
+ if (initialCount <= 2) {
383
+ return { ok: false, status: 401, json: () => Promise.resolve({}) };
384
+ }
385
+ return { ok: true, status: 200, json: () => Promise.resolve({ ok: true }) };
386
+ });
387
+ jest.spyOn(global, 'fetch').mockImplementation(mockFetch);
388
+
389
+ await Promise.all([
390
+ client.request('/me', 'GET', 'old-access', 'refresh-A'),
391
+ client.request('/me', 'GET', 'old-access', 'refresh-B'),
392
+ ]);
393
+
394
+ const refreshCalls = mockFetch.mock.calls.filter(
395
+ ([u]) => u === 'https://auth.example.com/oauth/token'
396
+ );
397
+ expect(refreshCalls).toHaveLength(2);
398
+ });
399
+
400
+ it('clears in-flight entry after failed refresh so subsequent calls can retry', async () => {
401
+ let refreshCount = 0;
402
+ const mockFetch = jest.fn().mockImplementation(async (url) => {
403
+ if (url === 'https://auth.example.com/oauth/token') {
404
+ refreshCount += 1;
405
+ if (refreshCount === 1) {
406
+ return {
407
+ ok: false, status: 400,
408
+ json: () => Promise.resolve({ error: 'invalid_grant', error_description: 'Refresh token not found.' }),
409
+ };
410
+ }
411
+ return { ok: true, status: 200, json: () => Promise.resolve(freshTokens) };
412
+ }
413
+ // Attempt 1 fires 2 concurrent /me calls (both 401). Attempt 2 fires a 3rd /me
414
+ // (401 → triggers refresh) followed by a 4th /me retry (200). So calls 1-3 are
415
+ // 401 and call 4 is 200. mockFetch.mock.calls already includes the in-flight call.
416
+ const meCalls = mockFetch.mock.calls.filter(([u]) => u === 'https://auth.example.com/me').length;
417
+ if (meCalls <= 3) {
418
+ return { ok: false, status: 401, json: () => Promise.resolve({}) };
419
+ }
420
+ return { ok: true, status: 200, json: () => Promise.resolve({ ok: true }) };
421
+ });
422
+ jest.spyOn(global, 'fetch').mockImplementation(mockFetch);
423
+
424
+ // Two concurrent callers share the dead refresh token and both fail.
425
+ const settled = await Promise.allSettled([
426
+ client.request('/me', 'GET', 'old-access', 'dead-refresh'),
427
+ client.request('/me', 'GET', 'old-access', 'dead-refresh'),
428
+ ]);
429
+ settled.forEach((r) => {
430
+ expect(r.status).toBe('rejected');
431
+ expect(r.reason).toBeInstanceOf(OAuthError);
432
+ });
433
+
434
+ // The in-flight entry must be cleared — a later call with a new refresh_token succeeds.
435
+ const result = await client.request('/me', 'GET', 'old-access', 'fresh-refresh');
436
+ expect(result.data).toEqual({ ok: true });
437
+ expect(refreshCount).toBe(2);
438
+ });
439
+
440
+ it('invokes onRefreshSuccess with new tokens before retry succeeds', async () => {
441
+ const mockFetch = jest.fn()
442
+ .mockResolvedValueOnce({ ok: false, status: 401, json: () => Promise.resolve({}) })
443
+ .mockResolvedValueOnce({ ok: true, status: 200, json: () => Promise.resolve(freshTokens) })
444
+ .mockResolvedValueOnce({ ok: true, status: 200, json: () => Promise.resolve({ user: 'alice' }) });
445
+ jest.spyOn(global, 'fetch').mockImplementation(mockFetch);
446
+ const order = [];
447
+ const onRefreshSuccess = jest.fn((tokens) => {
448
+ order.push('callback');
449
+ expect(tokens).toEqual(freshTokens);
450
+ // mockFetch has been called twice (initial 401 + refresh 200) but not yet for retry.
451
+ expect(mockFetch).toHaveBeenCalledTimes(2);
452
+ });
453
+
454
+ await client.request('/me', 'GET', 'old-access', 'refresh-tok', undefined, onRefreshSuccess);
455
+ order.push('done');
456
+
457
+ expect(onRefreshSuccess).toHaveBeenCalledTimes(1);
458
+ expect(order).toEqual(['callback', 'done']);
459
+ });
460
+
461
+ it('invokes onRefreshSuccess even when retry body fails to parse as JSON', async () => {
462
+ const mockFetch = jest.fn()
463
+ .mockResolvedValueOnce({ ok: false, status: 401, json: () => Promise.resolve({}) })
464
+ .mockResolvedValueOnce({ ok: true, status: 200, json: () => Promise.resolve(freshTokens) })
465
+ .mockResolvedValueOnce({ ok: true, status: 200, json: () => Promise.reject(new SyntaxError('Unexpected end of JSON input')) });
466
+ jest.spyOn(global, 'fetch').mockImplementation(mockFetch);
467
+ const onRefreshSuccess = jest.fn();
468
+
469
+ await expect(
470
+ client.request('/me', 'GET', 'old-access', 'refresh-tok', undefined, onRefreshSuccess)
471
+ ).rejects.toBeInstanceOf(SyntaxError);
472
+
473
+ // The rotated tokens MUST have been delivered to the caller before the parse failure.
474
+ expect(onRefreshSuccess).toHaveBeenCalledTimes(1);
475
+ expect(onRefreshSuccess).toHaveBeenCalledWith(freshTokens);
476
+ });
477
+
478
+ it('invokes onRefreshSuccess even when retry returns 5xx', async () => {
479
+ const mockFetch = jest.fn()
480
+ .mockResolvedValueOnce({ ok: false, status: 401, json: () => Promise.resolve({}) })
481
+ .mockResolvedValueOnce({ ok: true, status: 200, json: () => Promise.resolve(freshTokens) })
482
+ .mockResolvedValueOnce({
483
+ ok: false, status: 500,
484
+ json: () => Promise.resolve({ error: 'server_error', error_description: 'oops' }),
485
+ });
486
+ jest.spyOn(global, 'fetch').mockImplementation(mockFetch);
487
+ const onRefreshSuccess = jest.fn();
488
+
489
+ await expect(
490
+ client.request('/me', 'GET', 'old-access', 'refresh-tok', undefined, onRefreshSuccess)
491
+ ).rejects.toBeInstanceOf(OAuthError);
492
+
493
+ expect(onRefreshSuccess).toHaveBeenCalledTimes(1);
494
+ expect(onRefreshSuccess).toHaveBeenCalledWith(freshTokens);
495
+ });
496
+
497
+ it('does not invoke onRefreshSuccess when no refresh occurs', async () => {
498
+ jest.spyOn(global, 'fetch').mockResolvedValue({
499
+ ok: true, status: 200, json: () => Promise.resolve({ user: 'alice' }),
500
+ });
501
+ const onRefreshSuccess = jest.fn();
502
+
503
+ await client.request('/me', 'GET', 'access-tok', 'refresh-tok', undefined, onRefreshSuccess);
504
+
505
+ expect(onRefreshSuccess).not.toHaveBeenCalled();
506
+ });
507
+
508
+ it('omitting onRefreshSuccess remains valid (backward compatible)', async () => {
509
+ jest.spyOn(global, 'fetch')
510
+ .mockResolvedValueOnce({ ok: false, status: 401, json: () => Promise.resolve({}) })
511
+ .mockResolvedValueOnce({ ok: true, status: 200, json: () => Promise.resolve(freshTokens) })
512
+ .mockResolvedValueOnce({ ok: true, status: 200, json: () => Promise.resolve({ user: 'alice' }) });
513
+
514
+ const result = await client.request('/me', 'GET', 'old-access', 'refresh-tok');
515
+
516
+ expect(result.data).toEqual({ user: 'alice' });
517
+ expect(result.updatedTokens).toEqual(freshTokens);
518
+ });
519
+
520
+ it('sends body as JSON with Content-Type header for non-GET', async () => {
521
+ const mockFetch = jest.fn().mockResolvedValue({
522
+ ok: true, status: 200, json: () => Promise.resolve({ created: true }),
523
+ });
524
+ jest.spyOn(global, 'fetch').mockImplementation(mockFetch);
525
+
526
+ await client.request('/items', 'POST', 'access-tok', 'refresh-tok', { name: 'test' });
527
+
528
+ const [, options] = mockFetch.mock.calls[0];
529
+ expect(options.headers['Content-Type']).toBe('application/json');
530
+ expect(options.body).toBe(JSON.stringify({ name: 'test' }));
531
+ });
532
+ });
533
+
534
+ describe('decodeAccessToken', () => {
535
+ it('decodes JWT payload without signature verification', () => {
536
+ const payload = { user_id: 'u1', client_id: 'c1', jti: 'j1', iat: 1000, exp: 2000 };
537
+ const encoded = Buffer.from(JSON.stringify(payload)).toString('base64url');
538
+ const jwt = `header.${encoded}.signature`;
539
+
540
+ const result = client.decodeAccessToken(jwt);
541
+
542
+ expect(result).toEqual(payload);
543
+ });
544
+
545
+ it('throws OAuthError on malformed JWT (no dot separator)', () => {
546
+ expect(() => client.decodeAccessToken('notajwt')).toThrow(OAuthError);
547
+ });
548
+
549
+ it('handles base64url encoded payload needing padding', () => {
550
+ const payload = { user_id: 'user-abc' };
551
+ const encoded = Buffer.from(JSON.stringify(payload)).toString('base64url');
552
+ const jwt = `h.${encoded}.s`;
553
+ expect(client.decodeAccessToken(jwt)).toMatchObject({ user_id: 'user-abc' });
554
+ });
555
+ });
556
+ });
@@ -0,0 +1,73 @@
1
+ 'use strict';
2
+
3
+ const { OAuthError } = require('../errors');
4
+
5
+ describe('OAuthError', () => {
6
+ test('is instanceof Error', () => {
7
+ const err = new OAuthError('invalid_grant', 'token expired', 400);
8
+ expect(err).toBeInstanceOf(Error);
9
+ expect(err).toBeInstanceOf(OAuthError);
10
+ });
11
+
12
+ test('stores code, description, httpStatus', () => {
13
+ const err = new OAuthError('invalid_client', 'bad creds', 401);
14
+ expect(err.code).toBe('invalid_client');
15
+ expect(err.description).toBe('bad creds');
16
+ expect(err.httpStatus).toBe(401);
17
+ expect(err.message).toBe('bad creds');
18
+ });
19
+
20
+ test('body defaults to null when omitted from constructor', () => {
21
+ const err = new OAuthError('invalid_client', 'bad creds', 401);
22
+ expect(err.body).toBeNull();
23
+ });
24
+
25
+ test('constructor stores body when provided', () => {
26
+ const body = { error: 'invalid_client', request_id: 'abc-123' };
27
+ const err = new OAuthError('invalid_client', 'bad creds', 401, body);
28
+ expect(err.body).toBe(body);
29
+ });
30
+
31
+ test('fromResponse parses RFC 6749 body', () => {
32
+ const body = {
33
+ error: 'invalid_grant',
34
+ error_description: 'Authorization code expired',
35
+ };
36
+ const err = OAuthError.fromResponse(400, body);
37
+ expect(err.code).toBe('invalid_grant');
38
+ expect(err.description).toBe('Authorization code expired');
39
+ expect(err.httpStatus).toBe(400);
40
+ expect(err.body).toEqual(body);
41
+ expect(err).toBeInstanceOf(OAuthError);
42
+ });
43
+
44
+ test('fromResponse handles missing error_description', () => {
45
+ const body = { error: 'access_denied' };
46
+ const err = OAuthError.fromResponse(400, body);
47
+ expect(err.code).toBe('access_denied');
48
+ expect(err.description).toBe('');
49
+ expect(err.body).toEqual(body);
50
+ });
51
+
52
+ test('fromResponse falls back for non-RFC body (object without error)', () => {
53
+ const body = { message: 'crash' };
54
+ const err = OAuthError.fromResponse(500, body);
55
+ expect(err.code).toBe('server_error');
56
+ expect(err.description).toBe('Unexpected server response');
57
+ expect(err.httpStatus).toBe(500);
58
+ expect(err.body).toEqual(body);
59
+ });
60
+
61
+ test('fromResponse falls back for null body', () => {
62
+ const err = OAuthError.fromResponse(502, null);
63
+ expect(err.code).toBe('server_error');
64
+ expect(err.httpStatus).toBe(502);
65
+ expect(err.body).toBeNull();
66
+ });
67
+
68
+ test('fromResponse falls back for string body', () => {
69
+ const err = OAuthError.fromResponse(503, 'Service Unavailable');
70
+ expect(err.code).toBe('server_error');
71
+ expect(err.body).toBe('Service Unavailable');
72
+ });
73
+ });