cursedbelt-server 4.13.0 → 4.14.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.
@@ -0,0 +1,742 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { mkdtempSync, writeFileSync } from 'node:fs';
3
+ import { homedir, tmpdir } from 'node:os';
4
+ import path from 'node:path';
5
+ import {
6
+ buildConsentUrl,
7
+ CANONICAL_CREDENTIAL_KEYS,
8
+ createGoogleTokenMinter,
9
+ describeScopeFailure,
10
+ exchangeAuthCode,
11
+ expandTilde,
12
+ type FetchLike,
13
+ GOOGLE_AUTH_ENDPOINT,
14
+ GOOGLE_REVOKE_ENDPOINT,
15
+ GOOGLE_TOKEN_ENDPOINT,
16
+ parseEnvFile,
17
+ probeCredentialExchange,
18
+ readGoogleCredentials,
19
+ readSecret,
20
+ refreshAccessToken,
21
+ resolveGoogleCredentials,
22
+ revokeGoogleToken,
23
+ } from './googleOAuth';
24
+
25
+ /**
26
+ * The union of the two app-side suites this module was lifted from (task 099), plus a failure
27
+ * path for every public function that can fail.
28
+ */
29
+
30
+ /** A consumer with history: the canonical name first, a historical alias behind it. */
31
+ const ALIASED_KEYS = {
32
+ clientId: ['GOOGLE_OAUTH_CLIENT_ID', 'GMAIL_CLIENT_ID'],
33
+ clientSecret: ['GOOGLE_OAUTH_CLIENT_SECRET', 'GMAIL_CLIENT_SECRET'],
34
+ refreshToken: ['GOOGLE_OAUTH_REFRESH_TOKEN', 'GMAIL_REFRESH_TOKEN'],
35
+ };
36
+
37
+ const secretsFileWith = (body: string): string => {
38
+ const dir = mkdtempSync(path.join(tmpdir(), 'google-oauth-'));
39
+ const file = path.join(dir, 'app.env');
40
+ writeFileSync(file, body);
41
+ return file;
42
+ };
43
+
44
+ const NO_FILE = path.join(tmpdir(), 'google-oauth-no-such-dir', 'missing.env');
45
+
46
+ const tokenResponse = (over: Record<string, unknown> = {}) =>
47
+ Response.json({ access_token: 'at-1', expires_in: 3600, scope: 's1 s2', ...over });
48
+
49
+ const refusal = () =>
50
+ Response.json({ error: 'invalid_grant', error_description: 'Token has been expired or revoked.' }, { status: 400 });
51
+
52
+ const offline: FetchLike = async () => {
53
+ throw new Error('getaddrinfo ENOTFOUND oauth2.googleapis.com');
54
+ };
55
+
56
+ // ─── endpoints ───────────────────────────────────────────────────────────────
57
+
58
+ describe('endpoints', () => {
59
+ test("are Google's, over https", () => {
60
+ for (const url of [GOOGLE_TOKEN_ENDPOINT, GOOGLE_AUTH_ENDPOINT, GOOGLE_REVOKE_ENDPOINT]) {
61
+ const u = new URL(url);
62
+ expect(u.protocol).toBe('https:');
63
+ expect(u.hostname.endsWith('.googleapis.com') || u.hostname === 'accounts.google.com').toBe(true);
64
+ }
65
+ });
66
+ });
67
+
68
+ // ─── env file ────────────────────────────────────────────────────────────────
69
+
70
+ describe('parseEnvFile', () => {
71
+ test('tolerates comments, quotes, blanks and `export`', () => {
72
+ expect(parseEnvFile('# note\n\nexport A="one"\nB=\'two\'\nC=three\nnot a line\n')).toEqual({
73
+ A: 'one',
74
+ B: 'two',
75
+ C: 'three',
76
+ });
77
+ });
78
+
79
+ test('CRLF line endings do not leak a carriage return into a value', () => {
80
+ expect(parseEnvFile('A=one\r\nB="two"\r\n')).toEqual({ A: 'one', B: 'two' });
81
+ });
82
+
83
+ test('garbage parses to nothing rather than throwing', () => {
84
+ expect(parseEnvFile('=\nlower=case\n # A=commented\n\u0000\n')).toEqual({});
85
+ });
86
+ });
87
+
88
+ describe('expandTilde', () => {
89
+ test('expands a leading ~/ and leaves everything else alone', () => {
90
+ expect(expandTilde('~/x/y.env')).toBe(path.join(homedir(), 'x/y.env'));
91
+ expect(expandTilde('/abs/y.env')).toBe('/abs/y.env');
92
+ expect(expandTilde('rel/~/y.env')).toBe('rel/~/y.env');
93
+ });
94
+ });
95
+
96
+ describe('readSecret', () => {
97
+ test('env beats the file, and the file answers when env is silent', () => {
98
+ const file = secretsFileWith('MAPS_KEY=from-file\nHOME_ADDR="1 Main St"\n');
99
+ expect(readSecret('MAPS_KEY', { env: { MAPS_KEY: ' from-env ' }, secretsFile: file })).toBe('from-env');
100
+ expect(readSecret('HOME_ADDR', { env: {}, secretsFile: file })).toBe('1 Main St');
101
+ });
102
+
103
+ test('a missing key, a missing file and no file at all are all "" — never a throw', () => {
104
+ const file = secretsFileWith('A=1\n');
105
+ expect(readSecret('B', { env: {}, secretsFile: file })).toBe('');
106
+ expect(readSecret('A', { env: {}, secretsFile: NO_FILE })).toBe('');
107
+ expect(readSecret('A', { env: {} })).toBe('');
108
+ });
109
+
110
+ test('a whitespace-only env value does not shadow the file', () => {
111
+ const file = secretsFileWith('A=real\n');
112
+ expect(readSecret('A', { env: { A: ' ' }, secretsFile: file })).toBe('real');
113
+ });
114
+ });
115
+
116
+ // ─── credentials ─────────────────────────────────────────────────────────────
117
+
118
+ describe('readGoogleCredentials', () => {
119
+ test('a PARTIAL credential is the same as none', () => {
120
+ const file = secretsFileWith('GOOGLE_OAUTH_CLIENT_ID=cid\nGOOGLE_OAUTH_CLIENT_SECRET=sec\n');
121
+ expect(readGoogleCredentials({ env: {}, secretsFile: file })).toBeNull();
122
+ });
123
+
124
+ test('an unreadable secrets file is no credential, not a throw', () => {
125
+ expect(readGoogleCredentials({ env: {}, secretsFile: NO_FILE })).toBeNull();
126
+ });
127
+
128
+ test('reads the canonical names by default', () => {
129
+ expect(CANONICAL_CREDENTIAL_KEYS.refreshToken).toEqual(['GOOGLE_OAUTH_REFRESH_TOKEN']);
130
+ expect(
131
+ readGoogleCredentials({
132
+ env: {
133
+ GOOGLE_OAUTH_CLIENT_ID: 'cid',
134
+ GOOGLE_OAUTH_CLIENT_SECRET: 'sec',
135
+ GOOGLE_OAUTH_REFRESH_TOKEN: 'rt',
136
+ GMAIL_REFRESH_TOKEN: 'not-listed',
137
+ },
138
+ }),
139
+ ).toEqual({ clientId: 'cid', clientSecret: 'sec', refreshToken: 'rt' });
140
+ });
141
+
142
+ test('env beats the file, and an earlier key name beats a later alias', () => {
143
+ const file = secretsFileWith(
144
+ 'GOOGLE_OAUTH_CLIENT_ID=from-file\nGMAIL_CLIENT_ID=alias\n' +
145
+ 'GOOGLE_OAUTH_CLIENT_SECRET=sec\nGOOGLE_OAUTH_REFRESH_TOKEN=rt\n',
146
+ );
147
+ const creds = readGoogleCredentials({
148
+ env: { GOOGLE_OAUTH_CLIENT_SECRET: 'from-env' },
149
+ secretsFile: file,
150
+ keys: {
151
+ clientId: ['GOOGLE_OAUTH_CLIENT_ID', 'GMAIL_CLIENT_ID'],
152
+ clientSecret: ['GOOGLE_OAUTH_CLIENT_SECRET'],
153
+ refreshToken: ['GOOGLE_OAUTH_REFRESH_TOKEN'],
154
+ },
155
+ });
156
+ expect(creds).toEqual({ clientId: 'from-file', clientSecret: 'from-env', refreshToken: 'rt' });
157
+ });
158
+
159
+ test('an alias is used when the canonical name is absent', () => {
160
+ const file = secretsFileWith('GMAIL_CLIENT_ID=cid\nGMAIL_CLIENT_SECRET=sec\nGMAIL_REFRESH_TOKEN=rt\n');
161
+ expect(readGoogleCredentials({ env: {}, secretsFile: file, keys: ALIASED_KEYS })).toEqual({
162
+ clientId: 'cid',
163
+ clientSecret: 'sec',
164
+ refreshToken: 'rt',
165
+ });
166
+ });
167
+ });
168
+
169
+ describe('resolveGoogleCredentials — WHICH name carried it', () => {
170
+ /**
171
+ * 🔴 The measured bug, in one test. Two refresh tokens for the identical client in one file;
172
+ * the preferred name wins by being PRESENT and nothing anywhere said the other one existed.
173
+ */
174
+ test('names the winner and lists the spellings it shadowed', () => {
175
+ const file = secretsFileWith(
176
+ 'GOOGLE_OAUTH_CLIENT_ID=cid\nGOOGLE_OAUTH_CLIENT_SECRET=sec\n' +
177
+ 'GOOGLE_OAUTH_REFRESH_TOKEN=dead\nGMAIL_REFRESH_TOKEN=live\n',
178
+ );
179
+ const r = resolveGoogleCredentials({ env: {}, secretsFile: file, keys: ALIASED_KEYS });
180
+ expect(r.credentials?.refreshToken).toBe('dead');
181
+ expect(r.used.refreshToken).toEqual({ name: 'GOOGLE_OAUTH_REFRESH_TOKEN', from: 'file' });
182
+ expect(r.shadowed.refreshToken).toEqual(['GMAIL_REFRESH_TOKEN']);
183
+ });
184
+
185
+ test("says an override came from the ENV, not from the owner's 0600 file", () => {
186
+ const file = secretsFileWith(
187
+ 'GOOGLE_OAUTH_CLIENT_ID=cid\nGOOGLE_OAUTH_CLIENT_SECRET=sec\nGOOGLE_OAUTH_REFRESH_TOKEN=rt\n',
188
+ );
189
+ const r = resolveGoogleCredentials({
190
+ env: { GOOGLE_OAUTH_REFRESH_TOKEN: 'from-env' },
191
+ secretsFile: file,
192
+ keys: ALIASED_KEYS,
193
+ });
194
+ expect(r.used.refreshToken).toEqual({ name: 'GOOGLE_OAUTH_REFRESH_TOKEN', from: 'env' });
195
+ });
196
+
197
+ test('a partial credential names every spelling that supplies nothing', () => {
198
+ const file = secretsFileWith('GOOGLE_OAUTH_CLIENT_ID=cid\nGOOGLE_OAUTH_CLIENT_SECRET=sec\n');
199
+ const r = resolveGoogleCredentials({ env: {}, secretsFile: file, keys: ALIASED_KEYS });
200
+ expect(r.credentials).toBeNull();
201
+ expect(r.used.refreshToken).toBeNull();
202
+ expect(r.missing).toEqual(['GOOGLE_OAUTH_REFRESH_TOKEN', 'GMAIL_REFRESH_TOKEN']);
203
+ });
204
+
205
+ test('nothing at all names all three halves as missing', () => {
206
+ const r = resolveGoogleCredentials({ env: {}, secretsFile: NO_FILE });
207
+ expect(r.credentials).toBeNull();
208
+ expect(r.missing).toEqual(['GOOGLE_OAUTH_CLIENT_ID', 'GOOGLE_OAUTH_CLIENT_SECRET', 'GOOGLE_OAUTH_REFRESH_TOKEN']);
209
+ });
210
+ });
211
+
212
+ // ─── consent ─────────────────────────────────────────────────────────────────
213
+
214
+ describe('buildConsentUrl', () => {
215
+ const url = (over: Partial<Parameters<typeof buildConsentUrl>[0]> = {}): URL =>
216
+ new URL(
217
+ buildConsentUrl({
218
+ clientId: 'cid',
219
+ redirectUri: 'https://app.example.com/api/oauth/callback',
220
+ scopes: ['https://www.googleapis.com/auth/youtube.readonly'],
221
+ state: 'st-1',
222
+ ...over,
223
+ }),
224
+ );
225
+
226
+ test("targets Google's consent endpoint and carries the caller's state verbatim", () => {
227
+ const u = url();
228
+ expect(`${u.origin}${u.pathname}`).toBe(GOOGLE_AUTH_ENDPOINT);
229
+ expect(u.searchParams.get('state')).toBe('st-1');
230
+ expect(u.searchParams.get('client_id')).toBe('cid');
231
+ expect(u.searchParams.get('redirect_uri')).toBe('https://app.example.com/api/oauth/callback');
232
+ expect(u.searchParams.get('response_type')).toBe('code');
233
+ expect(u.searchParams.get('include_granted_scopes')).toBe('true');
234
+ });
235
+
236
+ /**
237
+ * The two parameters without which the grant dies quietly: no `offline` means no refresh
238
+ * token at all, and no `consent` means Google declines to re-issue one on a SECOND connect.
239
+ */
240
+ test('always asks for offline access, and forces a fresh refresh token by default', () => {
241
+ const u = url();
242
+ expect(u.searchParams.get('access_type')).toBe('offline');
243
+ expect(u.searchParams.get('prompt')).toBe('consent');
244
+ });
245
+
246
+ test('an explicit prompt is honoured', () => {
247
+ expect(url({ prompt: 'select_account' }).searchParams.get('prompt')).toBe('select_account');
248
+ });
249
+
250
+ test('scopes are space-joined, as Google expects', () => {
251
+ expect(url({ scopes: ['a', 'b'] }).searchParams.get('scope')).toBe('a b');
252
+ });
253
+ });
254
+
255
+ describe('exchangeAuthCode', () => {
256
+ const exchange = (fetchImpl: FetchLike): ReturnType<typeof exchangeAuthCode> =>
257
+ exchangeAuthCode({
258
+ clientId: 'cid',
259
+ clientSecret: 'sec',
260
+ redirectUri: 'https://app.example.com/cb',
261
+ code: 'the-code',
262
+ fetchImpl,
263
+ });
264
+
265
+ test('posts the code as form-encoded to the token endpoint and returns the tokens', async () => {
266
+ let sentBody = '';
267
+ let sentUrl = '';
268
+ const result = await exchange(async (url, init) => {
269
+ sentUrl = url;
270
+ sentBody = String(init?.body ?? '');
271
+ return Response.json({ access_token: 'at', refresh_token: 'rt', expires_in: 1234, scope: 's1 s2' });
272
+ });
273
+ expect(result).toEqual({
274
+ status: 'ok',
275
+ accessToken: 'at',
276
+ refreshToken: 'rt',
277
+ expiresInSeconds: 1234,
278
+ grantedScopes: ['s1', 's2'],
279
+ });
280
+ expect(sentUrl).toBe(GOOGLE_TOKEN_ENDPOINT);
281
+ const params = new URLSearchParams(sentBody);
282
+ expect(params.get('grant_type')).toBe('authorization_code');
283
+ expect(params.get('code')).toBe('the-code');
284
+ expect(params.get('redirect_uri')).toBe('https://app.example.com/cb');
285
+ });
286
+
287
+ /**
288
+ * A re-consent that returns no `refresh_token` must leave the field ABSENT rather than
289
+ * empty: a caller that stored `""` over a working refresh token would break the connection
290
+ * at the moment it looked like it was being repaired.
291
+ */
292
+ test('omits refreshToken entirely when Google did not re-issue one', async () => {
293
+ const result = await exchange(async () => Response.json({ access_token: 'at', expires_in: 60 }));
294
+ expect(result.status).toBe('ok');
295
+ expect(result).not.toHaveProperty('refreshToken');
296
+ });
297
+
298
+ test('a 400 is `refused`, names what Google said and what to do about it', async () => {
299
+ const result = await exchange(async () =>
300
+ Response.json({ error: 'invalid_grant', error_description: 'Bad Request' }, { status: 400 }),
301
+ );
302
+ if (result.status !== 'unavailable') throw new Error(`expected unavailable, got ${result.status}`);
303
+ expect(result.cause).toBe('refused');
304
+ expect(result.reason).toContain('Bad Request');
305
+ expect(result.fix).toContain('redirect URI');
306
+ });
307
+
308
+ test('a 400 with only `error` still names it', async () => {
309
+ const result = await exchange(async () => Response.json({ error: 'invalid_client' }, { status: 401 }));
310
+ if (result.status !== 'unavailable') throw new Error('expected unavailable');
311
+ expect(result.reason).toContain('invalid_client');
312
+ });
313
+
314
+ test('a 200 with no access_token is a failure, not an empty success', async () => {
315
+ const result = await exchange(async () => Response.json({ expires_in: 60 }));
316
+ expect(result.status).toBe('unavailable');
317
+ });
318
+
319
+ test('an unreachable Google is `transport`, reported, never thrown', async () => {
320
+ const result = await exchange(offline);
321
+ if (result.status !== 'unavailable') throw new Error('expected unavailable');
322
+ expect(result.cause).toBe('transport');
323
+ expect(result.reason).toContain('ENOTFOUND');
324
+ });
325
+
326
+ /**
327
+ * 🔴 A parse failure must never MASK a refusal. Google answers a revoked or already-used
328
+ * grant with a 400 whose body is sometimes not JSON (regression caught 2026-08-14).
329
+ */
330
+ test('a non-JSON body on a REFUSAL still reads as a refusal', async () => {
331
+ const result = await exchange(async () => new Response('<html>400</html>', { status: 400 }));
332
+ if (result.status !== 'unavailable') throw new Error('expected unavailable');
333
+ expect(result.cause).toBe('refused');
334
+ expect(result.reason).toContain('HTTP 400');
335
+ expect(result.fix).toContain('redirect URI');
336
+ });
337
+
338
+ test('a non-JSON body on a 200 is reported as exactly that', async () => {
339
+ const result = await exchange(async () => new Response('<html>ok?</html>', { status: 200 }));
340
+ if (result.status !== 'unavailable') throw new Error('expected unavailable');
341
+ expect(result.cause).toBe('transport');
342
+ expect(result.reason).toContain('not JSON');
343
+ });
344
+ });
345
+
346
+ // ─── refresh ─────────────────────────────────────────────────────────────────
347
+
348
+ describe('refreshAccessToken', () => {
349
+ const creds = { clientId: 'cid', clientSecret: 'sec', refreshToken: 'rt-secret' };
350
+
351
+ test('posts grant_type=refresh_token and reports the lifetime', async () => {
352
+ let params = new URLSearchParams();
353
+ const result = await refreshAccessToken({
354
+ ...creds,
355
+ fetchImpl: async (_url, init) => {
356
+ params = new URLSearchParams(String(init?.body ?? ''));
357
+ return tokenResponse({ expires_in: 1800 });
358
+ },
359
+ });
360
+ expect(result).toEqual({ status: 'ok', accessToken: 'at-1', expiresInSeconds: 1800, grantedScopes: ['s1', 's2'] });
361
+ expect(params.get('grant_type')).toBe('refresh_token');
362
+ expect(params.get('refresh_token')).toBe('rt-secret');
363
+ });
364
+
365
+ test('a missing expires_in is assumed to be an hour', async () => {
366
+ const result = await refreshAccessToken({ ...creds, fetchImpl: async () => Response.json({ access_token: 'a' }) });
367
+ expect(result.status === 'ok' && result.expiresInSeconds).toBe(3600);
368
+ });
369
+
370
+ test('a revoked token is `refused` with the default re-consent remedy, and no secret in it', async () => {
371
+ const result = await refreshAccessToken({ ...creds, fetchImpl: async () => refusal() });
372
+ if (result.status !== 'unavailable') throw new Error('expected unavailable');
373
+ expect(result.cause).toBe('refused');
374
+ expect(result.reason).toContain('expired or revoked');
375
+ expect(result.fix).toContain('re-run consent');
376
+ expect(JSON.stringify(result)).not.toContain('rt-secret');
377
+ expect(JSON.stringify(result)).not.toContain('"sec"');
378
+ });
379
+
380
+ test("the caller's refusalFix replaces the default", async () => {
381
+ const result = await refreshAccessToken({ ...creds, refusalFix: 'press Connect', fetchImpl: async () => refusal() });
382
+ expect(result.status === 'unavailable' && result.fix).toBe('press Connect');
383
+ });
384
+
385
+ test('offline is `transport`, never thrown', async () => {
386
+ const result = await refreshAccessToken({ ...creds, fetchImpl: offline });
387
+ expect(result.status === 'unavailable' && result.cause).toBe('transport');
388
+ });
389
+ });
390
+
391
+ // ─── revoke ──────────────────────────────────────────────────────────────────
392
+
393
+ describe('revokeGoogleToken', () => {
394
+ test('posts the token to the revoke endpoint and reports success', async () => {
395
+ let body = '';
396
+ let sentUrl = '';
397
+ const ok = await revokeGoogleToken('rt', {
398
+ fetchImpl: async (url, init) => {
399
+ sentUrl = url;
400
+ body = String(init?.body ?? '');
401
+ return new Response(null, { status: 200 });
402
+ },
403
+ });
404
+ expect(ok).toBe(true);
405
+ expect(sentUrl).toBe(GOOGLE_REVOKE_ENDPOINT);
406
+ expect(new URLSearchParams(body).get('token')).toBe('rt');
407
+ });
408
+
409
+ /** A disconnect must never be blocked by Google being unreachable. */
410
+ test('never throws when Google is unreachable', async () => {
411
+ await expect(revokeGoogleToken('rt', { fetchImpl: offline })).resolves.toBe(false);
412
+ });
413
+
414
+ test('a 400 (already forgotten) is `false`, not a throw', async () => {
415
+ await expect(
416
+ revokeGoogleToken('rt', { fetchImpl: async () => Response.json({ error: 'invalid_token' }, { status: 400 }) }),
417
+ ).resolves.toBe(false);
418
+ });
419
+
420
+ test('an empty token is a no-op', async () => {
421
+ let called = false;
422
+ const ok = await revokeGoogleToken('', {
423
+ fetchImpl: async () => {
424
+ called = true;
425
+ return new Response(null, { status: 200 });
426
+ },
427
+ });
428
+ expect(ok).toBe(false);
429
+ expect(called).toBe(false);
430
+ });
431
+ });
432
+
433
+ // ─── minter ──────────────────────────────────────────────────────────────────
434
+
435
+ describe('createGoogleTokenMinter', () => {
436
+ const creds = { clientId: 'cid', clientSecret: 'sec', refreshToken: 'rt' };
437
+
438
+ test("no credential is `unconfigured` and carries the caller's own remedy", async () => {
439
+ const minter = createGoogleTokenMinter({
440
+ credentials: null,
441
+ label: 'the dashboard',
442
+ setupFix: 'run bun run google:consent',
443
+ fetchImpl: async () => {
444
+ throw new Error('must not be called');
445
+ },
446
+ });
447
+ const result = await minter.token();
448
+ if (result.status !== 'unconfigured') throw new Error(`expected unconfigured, got ${result.status}`);
449
+ expect(result.reason).toContain('the dashboard');
450
+ expect(result.fix).toBe('run bun run google:consent');
451
+ });
452
+
453
+ test('reads env + secrets file under the given keys when no credential is supplied', async () => {
454
+ const file = secretsFileWith('GMAIL_CLIENT_ID=cid\nGMAIL_CLIENT_SECRET=sec\nGMAIL_REFRESH_TOKEN=from-alias\n');
455
+ let sent = '';
456
+ const minter = createGoogleTokenMinter({
457
+ env: {},
458
+ secretsFile: file,
459
+ keys: ALIASED_KEYS,
460
+ fetchImpl: async (_url, init) => {
461
+ sent = new URLSearchParams(String(init?.body ?? '')).get('refresh_token') ?? '';
462
+ return tokenResponse();
463
+ },
464
+ });
465
+ expect((await minter.token()).status).toBe('ok');
466
+ expect(sent).toBe('from-alias');
467
+ });
468
+
469
+ test('an unreadable secrets file is `unconfigured`, and makes no call', async () => {
470
+ let calls = 0;
471
+ const minter = createGoogleTokenMinter({
472
+ env: {},
473
+ secretsFile: NO_FILE,
474
+ fetchImpl: async () => {
475
+ calls += 1;
476
+ return tokenResponse();
477
+ },
478
+ });
479
+ expect((await minter.token()).status).toBe('unconfigured');
480
+ expect(calls).toBe(0);
481
+ });
482
+
483
+ test('caches the access token until shortly before it expires, then re-mints', async () => {
484
+ let calls = 0;
485
+ const clock = { now: 1_000_000 };
486
+ const minter = createGoogleTokenMinter({
487
+ credentials: creds,
488
+ now: () => clock.now,
489
+ fetchImpl: async () => {
490
+ calls += 1;
491
+ return tokenResponse({ access_token: `at-${calls}` });
492
+ },
493
+ });
494
+ expect(await minter.token()).toMatchObject({ accessToken: 'at-1' });
495
+ clock.now += 3000_000; // still inside 3600s - 60s margin
496
+ await minter.token();
497
+ expect(calls).toBe(1);
498
+ clock.now += 600_000; // past the margin
499
+ expect(await minter.token()).toMatchObject({ accessToken: 'at-2' });
500
+ expect(calls).toBe(2);
501
+ });
502
+
503
+ test('a token shorter-lived than the margin is never cached', async () => {
504
+ let calls = 0;
505
+ const minter = createGoogleTokenMinter({
506
+ credentials: creds,
507
+ now: () => 5,
508
+ fetchImpl: async () => {
509
+ calls += 1;
510
+ return tokenResponse({ expires_in: 30 });
511
+ },
512
+ });
513
+ await minter.token();
514
+ await minter.token();
515
+ expect(calls).toBe(2);
516
+ });
517
+
518
+ test('invalidate() forces the next call to re-mint', async () => {
519
+ let calls = 0;
520
+ const minter = createGoogleTokenMinter({
521
+ credentials: creds,
522
+ fetchImpl: async () => {
523
+ calls += 1;
524
+ return tokenResponse();
525
+ },
526
+ });
527
+ await minter.token();
528
+ minter.invalidate();
529
+ await minter.token();
530
+ expect(calls).toBe(2);
531
+ });
532
+
533
+ test('a refused refresh token is `unavailable`, `refused`, carries setupFix, and never echoes the credential', async () => {
534
+ const minter = createGoogleTokenMinter({
535
+ credentials: { clientId: 'cid', clientSecret: 'sec-value', refreshToken: 'rt-value' },
536
+ setupFix: 're-consent',
537
+ fetchImpl: async () => new Response('nope', { status: 400 }),
538
+ });
539
+ const result = await minter.token();
540
+ if (result.status !== 'unavailable') throw new Error('expected unavailable');
541
+ expect(result.cause).toBe('refused');
542
+ expect(result.fix).toBe('re-consent');
543
+ expect(JSON.stringify(result)).not.toContain('rt-value');
544
+ expect(JSON.stringify(result)).not.toContain('sec-value');
545
+ });
546
+
547
+ test('a failure is not cached — the next call tries again', async () => {
548
+ let calls = 0;
549
+ const minter = createGoogleTokenMinter({
550
+ credentials: creds,
551
+ fetchImpl: async () => {
552
+ calls += 1;
553
+ return calls === 1 ? refusal() : tokenResponse();
554
+ },
555
+ });
556
+ expect((await minter.token()).status).toBe('unavailable');
557
+ expect((await minter.token()).status).toBe('ok');
558
+ });
559
+
560
+ test('offline is `unavailable` with cause `transport`', async () => {
561
+ const result = await createGoogleTokenMinter({ credentials: creds, fetchImpl: offline }).token();
562
+ expect(result.status === 'unavailable' && result.cause).toBe('transport');
563
+ });
564
+
565
+ test('a token response with no access_token is a failure, not an empty success', async () => {
566
+ const minter = createGoogleTokenMinter({
567
+ credentials: creds,
568
+ fetchImpl: async () => Response.json({ expires_in: 3600 }),
569
+ });
570
+ expect((await minter.token()).status).toBe('unavailable');
571
+ });
572
+
573
+ test('grantedScopes is empty when the token could not be minted at all', async () => {
574
+ const minter = createGoogleTokenMinter({ credentials: null, fetchImpl: async () => tokenResponse() });
575
+ expect(await minter.grantedScopes()).toEqual([]);
576
+ });
577
+
578
+ test('methods survive being destructured', async () => {
579
+ const { token, grantedScopes } = createGoogleTokenMinter({
580
+ credentials: creds,
581
+ fetchImpl: async () => tokenResponse(),
582
+ });
583
+ expect((await token()).status).toBe('ok');
584
+ expect(await grantedScopes()).toEqual(['s1', 's2']);
585
+ });
586
+ });
587
+
588
+ // ─── probe ───────────────────────────────────────────────────────────────────
589
+
590
+ describe('probeCredentialExchange — a present value is not a working one', () => {
591
+ const aliased = (body: string, fetchImpl: FetchLike) =>
592
+ probeCredentialExchange({
593
+ env: {},
594
+ secretsFile: secretsFileWith(body),
595
+ keys: ALIASED_KEYS,
596
+ label: 'the digest',
597
+ setupFix: 'run get-refresh-token.sh',
598
+ fetchImpl,
599
+ });
600
+
601
+ const refusesAllBut = (livingToken: string) => {
602
+ const seen: string[] = [];
603
+ return {
604
+ seen,
605
+ fetchImpl: async (_url: string, init?: RequestInit) => {
606
+ const sent = new URLSearchParams(String(init?.body ?? '')).get('refresh_token') ?? '';
607
+ seen.push(sent);
608
+ return sent === livingToken
609
+ ? tokenResponse({ scope: 'gmail.readonly calendar.events' })
610
+ : Response.json({ error: 'invalid_grant', error_description: 'Bad Request' }, { status: 400 });
611
+ },
612
+ };
613
+ };
614
+
615
+ /**
616
+ * 🔴 THE PLANTED DEAD TOKEN — the exact state measured 2026-09-15. The canonical name is
617
+ * dead, the alias beside it is alive, and the diagnosis has to name BOTH or it sends the
618
+ * owner to a browser to fix something a one-line edit fixes.
619
+ */
620
+ test('a refused canonical token names it AND the untried alias', async () => {
621
+ const { seen, fetchImpl } = refusesAllBut('live');
622
+ const probe = await aliased(
623
+ 'GOOGLE_OAUTH_CLIENT_ID=cid\nGOOGLE_OAUTH_CLIENT_SECRET=sec\n' +
624
+ 'GOOGLE_OAUTH_REFRESH_TOKEN=dead\nGMAIL_REFRESH_TOKEN=live\n',
625
+ fetchImpl,
626
+ );
627
+ expect(probe.status).toBe('refused');
628
+ expect(probe.refreshTokenName).toBe('GOOGLE_OAUTH_REFRESH_TOKEN');
629
+ expect(probe.shadowedRefreshTokenNames).toEqual(['GMAIL_REFRESH_TOKEN']);
630
+ expect(probe.summary).toContain('GOOGLE_OAUTH_REFRESH_TOKEN');
631
+ expect(probe.summary).toContain('GMAIL_REFRESH_TOKEN');
632
+ expect(probe.summary).toContain('never tried');
633
+ // The remedy must NOT be "re-consent" first — that is the wrong forty minutes.
634
+ expect(probe.fix).toContain('GMAIL_REFRESH_TOKEN');
635
+ expect(probe.fix.indexOf('GMAIL_REFRESH_TOKEN')).toBeLessThan(probe.fix.indexOf('get-refresh-token.sh'));
636
+ // It exchanged the one it would actually USE, and did not go hunting for a working
637
+ // alias behind the owner's back.
638
+ expect(seen).toEqual(['dead']);
639
+ // And no credential value ever reaches the sentence.
640
+ expect(JSON.stringify(probe)).not.toContain('dead');
641
+ });
642
+
643
+ test('a refused token with no alias is `refused` with the plain setup remedy', async () => {
644
+ const probe = await aliased(
645
+ 'GOOGLE_OAUTH_CLIENT_ID=cid\nGOOGLE_OAUTH_CLIENT_SECRET=sec\nGOOGLE_OAUTH_REFRESH_TOKEN=dead\n',
646
+ refusesAllBut('live').fetchImpl,
647
+ );
648
+ expect(probe.status).toBe('refused');
649
+ expect(probe.fix).toBe('run get-refresh-token.sh');
650
+ expect(probe.summary).not.toContain('never tried');
651
+ });
652
+
653
+ test('a credential Google takes is `ok` and still says which name proved it', async () => {
654
+ const { fetchImpl } = refusesAllBut('live');
655
+ const probe = await aliased('GMAIL_CLIENT_ID=cid\nGMAIL_CLIENT_SECRET=sec\nGMAIL_REFRESH_TOKEN=live\n', fetchImpl);
656
+ expect(probe.status).toBe('ok');
657
+ expect(probe.refreshTokenName).toBe('GMAIL_REFRESH_TOKEN');
658
+ expect(probe.shadowedRefreshTokenNames).toEqual([]);
659
+ expect(probe.summary).toContain('GMAIL_REFRESH_TOKEN');
660
+ expect(probe.grantedScopes).toEqual(['gmail.readonly', 'calendar.events']);
661
+ expect(probe.fix).toBe('');
662
+ });
663
+
664
+ test('an `ok` that shadows an alias says the alias is never reached', async () => {
665
+ const probe = await aliased(
666
+ 'GOOGLE_OAUTH_CLIENT_ID=cid\nGOOGLE_OAUTH_CLIENT_SECRET=sec\n' +
667
+ 'GOOGLE_OAUTH_REFRESH_TOKEN=live\nGMAIL_REFRESH_TOKEN=other\n',
668
+ refusesAllBut('live').fetchImpl,
669
+ );
670
+ expect(probe.status).toBe('ok');
671
+ expect(probe.summary).toContain('never reached');
672
+ });
673
+
674
+ /** 🔴 An offline laptop has proved NOTHING about the credential and must not be reported
675
+ * as a dead grant — that is the false bell that gets a real one muted. */
676
+ test('a machine that could not reach Google is `unreachable`, not `refused`', async () => {
677
+ const probe = await aliased(
678
+ 'GOOGLE_OAUTH_CLIENT_ID=cid\nGOOGLE_OAUTH_CLIENT_SECRET=sec\nGOOGLE_OAUTH_REFRESH_TOKEN=rt\n',
679
+ offline,
680
+ );
681
+ expect(probe.status).toBe('unreachable');
682
+ expect(probe.summary).toContain('NOT tested');
683
+ });
684
+
685
+ test('no credential at all is `unconfigured` with the setup remedy, and no call', async () => {
686
+ let calls = 0;
687
+ const probe = await aliased('GOOGLE_OAUTH_CLIENT_ID=cid\n', async () => {
688
+ calls += 1;
689
+ return tokenResponse();
690
+ });
691
+ expect(probe.status).toBe('unconfigured');
692
+ expect(probe.summary).toContain('GOOGLE_OAUTH_REFRESH_TOKEN');
693
+ expect(probe.fix).toBe('run get-refresh-token.sh');
694
+ expect(calls).toBe(0);
695
+ });
696
+
697
+ test('supplied credentials report no env names — it never read any', async () => {
698
+ const probe = await probeCredentialExchange({
699
+ credentials: { clientId: 'cid', clientSecret: 'sec', refreshToken: 'rt' },
700
+ env: { GOOGLE_OAUTH_REFRESH_TOKEN: 'ignored' },
701
+ fetchImpl: async () => refusal(),
702
+ });
703
+ expect(probe.status).toBe('refused');
704
+ expect(probe.refreshTokenName).toBe('');
705
+ expect(probe.summary).not.toContain('GOOGLE_OAUTH_REFRESH_TOKEN');
706
+
707
+ const none = await probeCredentialExchange({ credentials: null, fetchImpl: async () => tokenResponse() });
708
+ expect(none.status).toBe('unconfigured');
709
+ expect(none.summary).not.toContain('nothing supplies');
710
+ });
711
+ });
712
+
713
+ // ─── 403 → remedy ────────────────────────────────────────────────────────────
714
+
715
+ describe('describeScopeFailure', () => {
716
+ test('a missing scope names the scope, what IS granted, and the consent command', () => {
717
+ const { reason, fix } = describeScopeFailure({
718
+ api: 'Cloud Monitoring',
719
+ needed: 'https://www.googleapis.com/auth/monitoring.read',
720
+ granted: ['https://www.googleapis.com/auth/gmail.readonly'],
721
+ httpStatus: 403,
722
+ consentCommand: 'bun run google:consent',
723
+ });
724
+ expect(reason).toContain('does NOT include');
725
+ expect(reason).toContain('monitoring.read');
726
+ expect(reason).toContain('gmail.readonly');
727
+ expect(fix).toContain('bun run google:consent');
728
+ });
729
+
730
+ test('a 403 on a GRANTED scope points at the API being disabled, not at consent', () => {
731
+ const { reason, fix } = describeScopeFailure({
732
+ api: 'Cloud Monitoring',
733
+ needed: 'scope-a',
734
+ granted: ['scope-a'],
735
+ httpStatus: 403,
736
+ consentCommand: 'bun run google:consent',
737
+ });
738
+ expect(reason).toContain('may be disabled');
739
+ expect(fix).toContain('Library');
740
+ expect(fix).not.toContain('google:consent');
741
+ });
742
+ });