gogcli-mcp 3.0.0 → 4.0.1

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 (50) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/dist/index.js +474 -947
  4. package/dist/lib.js +575 -983
  5. package/manifest.json +2 -2
  6. package/mint.yaml +41 -37
  7. package/package.json +3 -3
  8. package/server.json +2 -2
  9. package/src/attachments.ts +28 -34
  10. package/src/blob-upload.ts +165 -134
  11. package/src/blob-urls.ts +3 -5
  12. package/src/bootstrap-auth.ts +97 -0
  13. package/src/index.ts +3 -4
  14. package/src/lib.ts +14 -9
  15. package/src/runner.ts +27 -176
  16. package/src/tools/appscript.ts +1 -1
  17. package/src/tools/auth.ts +5 -5
  18. package/src/tools/drive.ts +1 -3
  19. package/src/tools/gmail.ts +9 -9
  20. package/src/tools/utils.ts +12 -58
  21. package/tests/attachments.test.ts +11 -14
  22. package/tests/blob-upload.test.ts +235 -160
  23. package/tests/bootstrap-auth.test.ts +245 -0
  24. package/tests/runner-file-args.test.ts +1 -13
  25. package/tests/runner.test.ts +53 -96
  26. package/tests/sdk-single-copy.test.ts +4 -12
  27. package/tests/tools/auth-401-shapes.test.ts +2 -3
  28. package/tests/tools/auth.test.ts +5 -4
  29. package/tests/tools/drive.test.ts +11 -3
  30. package/tests/tools/gmail.test.ts +2 -2
  31. package/tests/tools/utils.test.ts +1 -50
  32. package/tests/zod-single-copy.test.ts +6 -14
  33. package/tsconfig.json +1 -2
  34. package/vitest.config.ts +2 -12
  35. package/src/auth-log.ts +0 -205
  36. package/src/connector-auth.ts +0 -319
  37. package/src/connector-login.ts +0 -87
  38. package/src/connector-runtime.ts +0 -910
  39. package/src/google-probe.ts +0 -113
  40. package/src/google-token.ts +0 -391
  41. package/src/remote-runner.ts +0 -77
  42. package/src/worker.ts +0 -117
  43. package/tests/auth-log.test.ts +0 -530
  44. package/tests/connector-auth.test.ts +0 -559
  45. package/tests/connector-login.test.ts +0 -151
  46. package/tests/connector-runtime.test.ts +0 -1664
  47. package/tests/google-probe.test.ts +0 -116
  48. package/tests/google-token.test.ts +0 -425
  49. package/tests/remote-runner.test.ts +0 -202
  50. package/tests/worker.test.ts +0 -167
@@ -1,116 +0,0 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { readGoogleProbe } from '../src/google-probe.js';
3
-
4
- /**
5
- * REVIEW DEFECT: "the probe could not run" was filed as "Google refused".
6
- *
7
- * The runner collapses every non-healthy outcome into `ok:false`, and BOTH
8
- * connector call sites used to branch on `ok === true` alone. Three of the
9
- * runner's causes are facts about the probe, not about Google — it timed out, it
10
- * could not be run (`gog` missing from PATH, `credentials.json` missing from the
11
- * volume), or its output could not be parsed — and each of those became an
12
- * error-level `connect.google-unhealthy` / `refusal.google-unhealthy`, whose
13
- * documented meaning is "Google was asked and refused". An operator grepping
14
- * event names would close the incident on evidence nobody gathered.
15
- *
16
- * The runner now states `measured` explicitly. This module is the ONE place that
17
- * reads it, precisely because the defect was two call sites making the same
18
- * judgement separately.
19
- */
20
- describe('readGoogleProbe', () => {
21
- it('reads a healthy answer as ok', () => {
22
- expect(readGoogleProbe({ ok: true, measured: true })).toEqual({ kind: 'ok' });
23
- });
24
-
25
- it('reads a measured refusal as unhealthy, carrying the runner’s cause', () => {
26
- expect(readGoogleProbe({ ok: false, measured: true, error: 'invalid_grant: …' })).toEqual({
27
- kind: 'unhealthy',
28
- reason: 'invalid_grant: …',
29
- });
30
- });
31
-
32
- it('says so plainly when a measured refusal names no cause', () => {
33
- const verdict = readGoogleProbe({ ok: false, measured: true });
34
- expect(verdict.kind).toBe('unhealthy');
35
- expect(verdict.reason).toMatch(/no cause/i);
36
- });
37
-
38
- it('reads measured:false as UNMEASURED however loudly the cause reads', () => {
39
- // The regression under test. Every one of these used to be `unhealthy`.
40
- for (const error of [
41
- 'the Google probe timed out before gog answered',
42
- 'the Google probe could not be run',
43
- 'gog auth list --check returned unrecognized output',
44
- 'gog did not report token validity',
45
- 'gog reported an account it explicitly did not check',
46
- ]) {
47
- expect(readGoogleProbe({ ok: false, measured: false, error })).toEqual({
48
- kind: 'unmeasured',
49
- reason: error,
50
- });
51
- }
52
- });
53
-
54
- it('refuses to claim HEALTH from a runner that never said it measured', () => {
55
- // The row this table was missing, and the last place `ok` outranked
56
- // `measured`. A bare `ok:true` used to return {kind:'ok'}, which on the
57
- // refusal path is `refusal.google-ok` — the record documented as "the one
58
- // record that means we cannot explain this", logged at error level, and
59
- // held up as the only evidence that could justify automatic recovery on the
60
- // hosted path. Raising it from a measurement nobody took is this branch's
61
- // founding defect surviving in the module written to delete it. `ok:true`
62
- // is not self-licensing: it is a health claim, and a health claim needs a
63
- // measurement behind it — exactly what the runner's README asserts when it
64
- // says `ok:true` always travels with `measured:true`.
65
- const verdict = readGoogleProbe({ ok: true });
66
- expect(verdict.kind).toBe('unmeasured');
67
- expect(verdict.reason).toMatch(/did not report whether/i);
68
- });
69
-
70
- it('carries the runner’s cause when a bare ok:true also names one', () => {
71
- const verdict = readGoogleProbe({ ok: true, error: 'something went wrong' });
72
- expect(verdict.kind).toBe('unmeasured');
73
- expect(verdict.reason).toContain('something went wrong');
74
- });
75
-
76
- it('never claims health from an incoherent ok:true + measured:false', () => {
77
- // `measured` is read FIRST, so the field that can only under-claim wins.
78
- const verdict = readGoogleProbe({ ok: true, measured: false });
79
- expect(verdict.kind).toBe('unmeasured');
80
- });
81
-
82
- it('describes an unmeasured answer that names no cause', () => {
83
- const verdict = readGoogleProbe({ ok: false, measured: false });
84
- expect(verdict.kind).toBe('unmeasured');
85
- expect(verdict.reason).toMatch(/could not measure/i);
86
- });
87
-
88
- it('refuses to claim ill health from a runner that never said it measured', () => {
89
- // Silence is not a measurement. An `ok:false` with no `measured` field is
90
- // read as unmeasured — under-claiming, the only safe direction.
91
- const verdict = readGoogleProbe({ ok: false, error: 'something went wrong' });
92
- expect(verdict.kind).toBe('unmeasured');
93
- expect(verdict.reason).toContain('something went wrong');
94
- expect(verdict.reason).toMatch(/did not report whether/i);
95
- });
96
-
97
- it('reads a body with no fields at all, and a null body, as unmeasured', () => {
98
- expect(readGoogleProbe({}).kind).toBe('unmeasured');
99
- expect(readGoogleProbe(null).kind).toBe('unmeasured');
100
- expect(readGoogleProbe(undefined).kind).toBe('unmeasured');
101
- expect(readGoogleProbe('a proxy error page').kind).toBe('unmeasured');
102
- });
103
-
104
- it('ignores non-boolean field types rather than trusting them', () => {
105
- // JSON from a proxy, or a future runner, may put anything here.
106
- expect(readGoogleProbe({ ok: 'true', measured: 'true' }).kind).toBe('unmeasured');
107
- expect(readGoogleProbe({ ok: 1, measured: 1 }).kind).toBe('unmeasured');
108
- });
109
-
110
- it('never carries a non-string cause into a log line', () => {
111
- const verdict = readGoogleProbe({ ok: false, measured: true, error: { nested: 'object' } });
112
- expect(verdict.kind).toBe('unhealthy');
113
- expect(typeof verdict.reason).toBe('string');
114
- expect(verdict.reason).toMatch(/no cause/i);
115
- });
116
- });
@@ -1,425 +0,0 @@
1
- import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
2
- import { makeAccessTokenSource, clearAccessTokenCache } from '../src/google-token.js';
3
-
4
- /**
5
- * Minting a short-lived access token from a long-lived refresh token is what
6
- * lets a hosted gog's identity belong to the REGISTRATION instead of to the Fly
7
- * box's volume (#241). The access token is what crosses the wire; the refresh
8
- * token never leaves this process.
9
- *
10
- * Two properties carry the whole design, and both are about NOT quietly doing
11
- * the wrong thing:
12
- *
13
- * 1. One caller's token must never be served to another. The cache is keyed
14
- * by the credential, not held as a single "current token" — a Worker
15
- * isolate serves many callers, and a global would hand the first caller's
16
- * identity to everyone after them.
17
- * 2. A failure must fail the CALL. Returning nothing would run the command as
18
- * the box's identity, and the caller would read someone else's mailbox
19
- * while everything looked like success.
20
- */
21
-
22
- const CLIENT = { GOG_CLIENT_ID: 'cid.apps.googleusercontent.com', GOG_CLIENT_SECRET: 'cs' };
23
-
24
- function tokenResponse(accessToken: string, expiresIn = 3600) {
25
- return new Response(JSON.stringify({ access_token: accessToken, expires_in: expiresIn }), {
26
- status: 200,
27
- headers: { 'content-type': 'application/json' },
28
- });
29
- }
30
-
31
- beforeEach(() => clearAccessTokenCache());
32
- afterEach(() => {
33
- vi.unstubAllGlobals();
34
- vi.useRealTimers();
35
- });
36
-
37
- describe('makeAccessTokenSource', () => {
38
- it('is absent when nothing is configured, so the box keeps acting as itself', () => {
39
- expect(makeAccessTokenSource({})).toBeUndefined();
40
- });
41
-
42
- it('passes through a directly-supplied access token without contacting Google', async () => {
43
- // The #230 path. Still supported: a caller who already holds a token should
44
- // not need an OAuth client to use it.
45
- const fetchMock = vi.fn();
46
- vi.stubGlobal('fetch', fetchMock);
47
- const source = makeAccessTokenSource({ GOG_ACCESS_TOKEN: 'ya29.direct' })!;
48
- expect(await source()).toBe('ya29.direct');
49
- expect(fetchMock).not.toHaveBeenCalled();
50
- });
51
-
52
- it('mints an access token from the refresh token, then serves it from cache', async () => {
53
- const fetchMock = vi.fn(async () => tokenResponse('ya29.minted'));
54
- vi.stubGlobal('fetch', fetchMock);
55
- const source = makeAccessTokenSource({ ...CLIENT, GOG_REFRESH_TOKEN: 'rt-1' })!;
56
-
57
- expect(await source()).toBe('ya29.minted');
58
- expect(await source()).toBe('ya29.minted');
59
- // A token exchange per tool call would be both slow and a good way to get
60
- // rate-limited by Google.
61
- expect(fetchMock).toHaveBeenCalledTimes(1);
62
-
63
- const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
64
- expect(url).toBe('https://oauth2.googleapis.com/token');
65
- const sent = new URLSearchParams(init.body as string);
66
- expect(sent.get('grant_type')).toBe('refresh_token');
67
- expect(sent.get('refresh_token')).toBe('rt-1');
68
- expect(sent.get('client_id')).toBe(CLIENT.GOG_CLIENT_ID);
69
- });
70
-
71
- it('never serves one credential holder the other one’s token', async () => {
72
- // THE one that matters. A module-level "current access token" passes every
73
- // other test in this file and fails this one — and on the Worker path,
74
- // where a single isolate serves many callers, that is a cross-account leak
75
- // rather than a bug.
76
- const fetchMock = vi
77
- .fn()
78
- .mockResolvedValueOnce(tokenResponse('ya29.alice'))
79
- .mockResolvedValueOnce(tokenResponse('ya29.bob'));
80
- vi.stubGlobal('fetch', fetchMock);
81
-
82
- const alice = makeAccessTokenSource({ ...CLIENT, GOG_REFRESH_TOKEN: 'rt-alice' })!;
83
- const bob = makeAccessTokenSource({ ...CLIENT, GOG_REFRESH_TOKEN: 'rt-bob' })!;
84
-
85
- expect(await alice()).toBe('ya29.alice');
86
- expect(await bob()).toBe('ya29.bob');
87
- // And each keeps its own on a second read.
88
- expect(await alice()).toBe('ya29.alice');
89
- expect(await bob()).toBe('ya29.bob');
90
- expect(fetchMock).toHaveBeenCalledTimes(2);
91
- });
92
-
93
- it('re-mints once the token is close to expiring', async () => {
94
- vi.useFakeTimers();
95
- const fetchMock = vi
96
- .fn()
97
- .mockResolvedValueOnce(tokenResponse('ya29.first', 3600))
98
- .mockResolvedValueOnce(tokenResponse('ya29.second', 3600));
99
- vi.stubGlobal('fetch', fetchMock);
100
- const source = makeAccessTokenSource({ ...CLIENT, GOG_REFRESH_TOKEN: 'rt-1' })!;
101
-
102
- expect(await source()).toBe('ya29.first');
103
- // Just inside the safety margin: a token that expires mid-flight is a
104
- // failure the caller cannot do anything about, so it is replaced early.
105
- vi.advanceTimersByTime((3600 - 60) * 1000);
106
- expect(await source()).toBe('ya29.second');
107
- expect(fetchMock).toHaveBeenCalledTimes(2);
108
- });
109
-
110
- it('THROWS when the exchange fails, rather than returning nothing', async () => {
111
- // Returning undefined here would run the command as the box's identity.
112
- vi.stubGlobal(
113
- 'fetch',
114
- vi.fn(async () => new Response(JSON.stringify({ error: 'server_error' }), { status: 500 })),
115
- );
116
- const source = makeAccessTokenSource({ ...CLIENT, GOG_REFRESH_TOKEN: 'rt-1' })!;
117
- await expect(source()).rejects.toThrow(/could not be refreshed|token exchange/i);
118
- });
119
-
120
- it('explains an invalid_grant instead of surfacing a bare OAuth error', async () => {
121
- vi.stubGlobal(
122
- 'fetch',
123
- vi.fn(
124
- async () =>
125
- new Response(JSON.stringify({ error: 'invalid_grant', error_description: 'Token has been expired or revoked.' }), {
126
- status: 400,
127
- }),
128
- ),
129
- );
130
- const source = makeAccessTokenSource({ ...CLIENT, GOG_REFRESH_TOKEN: 'rt-dead' })!;
131
- await expect(source()).rejects.toThrow(/expired or been revoked|re-?enrol|re-?authoriz/i);
132
- // The literal token, not just the prose. tools/utils.ts picks the richer
133
- // INVALID_GRANT_HINT (7-day Testing-mode cause + the headless re-auth pair)
134
- // by matching `invalid_grant`; a message that only DESCRIBES the failure
135
- // earns the generic "authentication may have expired" advice instead, which
136
- // is what this mint used to get while the identical failure reported by gog
137
- // got the specific guidance.
138
- await expect(source()).rejects.toThrow(/invalid_grant/);
139
- });
140
-
141
- it('does not cache a failure, so a transient outage is not sticky', async () => {
142
- const fetchMock = vi
143
- .fn()
144
- .mockResolvedValueOnce(new Response('{}', { status: 503 }))
145
- .mockResolvedValueOnce(tokenResponse('ya29.recovered'));
146
- vi.stubGlobal('fetch', fetchMock);
147
- const source = makeAccessTokenSource({ ...CLIENT, GOG_REFRESH_TOKEN: 'rt-1' })!;
148
-
149
- await expect(source()).rejects.toThrow();
150
- expect(await source()).toBe('ya29.recovered');
151
- });
152
-
153
- it('fails loudly on a half-configured credential instead of acting as the box', async () => {
154
- // A refresh token with no OAuth client cannot mint anything. Treating it as
155
- // "unconfigured" would silently fall back to the box's identity, which is
156
- // the exact confusion this feature exists to remove — so the source exists
157
- // and throws when used, leaving tools/list working and the reason visible.
158
- for (const partial of [
159
- { GOG_REFRESH_TOKEN: 'rt-1' },
160
- { GOG_REFRESH_TOKEN: 'rt-1', GOG_CLIENT_ID: 'cid' },
161
- { GOG_REFRESH_TOKEN: 'rt-1', GOG_CLIENT_SECRET: 'cs' },
162
- ]) {
163
- const source = makeAccessTokenSource(partial);
164
- expect(source).toBeTypeOf('function');
165
- await expect(source!()).rejects.toThrow(/GOG_CLIENT_ID|GOG_CLIENT_SECRET/);
166
- }
167
- });
168
-
169
- it('says the exchange was unreachable when the network itself fails', async () => {
170
- // Distinct from a rejection BY Google: nothing was evaluated, so the
171
- // credential may be perfectly good and the right response is to retry, not
172
- // to tell the owner to re-enrol.
173
- vi.stubGlobal(
174
- 'fetch',
175
- vi.fn(async () => {
176
- throw new TypeError('fetch failed');
177
- }),
178
- );
179
- const source = makeAccessTokenSource({ ...CLIENT, GOG_REFRESH_TOKEN: 'rt-1' })!;
180
- await expect(source()).rejects.toThrow(/could not be reached.*fetch failed/i);
181
- });
182
-
183
- it('survives a thrown non-Error without masking it with a TypeError', async () => {
184
- // `err.message` on a thrown string is undefined, and the template would
185
- // then hide the real failure behind a crash inside the error path.
186
- vi.stubGlobal(
187
- 'fetch',
188
- vi.fn(async () => {
189
- throw 'socket closed';
190
- }),
191
- );
192
- const source = makeAccessTokenSource({ ...CLIENT, GOG_REFRESH_TOKEN: 'rt-1' })!;
193
- await expect(source()).rejects.toThrow(/could not be reached.*socket closed/i);
194
- });
195
-
196
- it('refuses a 200 that carries no access_token', async () => {
197
- // A success status with nothing usable in it would otherwise cache
198
- // `undefined` and send no token at all — a silent downgrade to the
199
- // backend's identity, wearing a 200.
200
- vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ expires_in: 3600 }), { status: 200 })));
201
- const source = makeAccessTokenSource({ ...CLIENT, GOG_REFRESH_TOKEN: 'rt-1' })!;
202
- await expect(source()).rejects.toThrow(/no access_token/i);
203
- });
204
-
205
- it('assumes an hour when Google omits expires_in', async () => {
206
- vi.useFakeTimers();
207
- const fetchMock = vi.fn(async () => new Response(JSON.stringify({ access_token: 'ya29.nolife' }), { status: 200 }));
208
- vi.stubGlobal('fetch', fetchMock);
209
- const source = makeAccessTokenSource({ ...CLIENT, GOG_REFRESH_TOKEN: 'rt-1' })!;
210
-
211
- expect(await source()).toBe('ya29.nolife');
212
- // Still cached half an hour later — i.e. it did not treat "no expiry" as
213
- // "already expired" and re-mint on every single call.
214
- vi.advanceTimersByTime(1800 * 1000);
215
- expect(await source()).toBe('ya29.nolife');
216
- expect(fetchMock).toHaveBeenCalledTimes(1);
217
- });
218
-
219
- it('reports the status when the body is not JSON at all', async () => {
220
- // An edge proxy answering a 502 with an HTML error page is the realistic
221
- // case. Parsing that would throw inside the error path and bury the status,
222
- // which is the only useful thing such a response carries.
223
- vi.stubGlobal(
224
- 'fetch',
225
- vi.fn(async () => new Response('<html>502 Bad Gateway</html>', { status: 502 })),
226
- );
227
- const source = makeAccessTokenSource({ ...CLIENT, GOG_REFRESH_TOKEN: 'rt-1' })!;
228
- await expect(source()).rejects.toThrow(/could not be refreshed \(HTTP 502/);
229
- });
230
-
231
- it('coalesces concurrent callers into ONE token exchange', async () => {
232
- // The reported bug was several per-service servers flapping into needs-auth
233
- // independently. This is the version of that failure that lives in OUR
234
- // code: `get` → `await exchange` → `set` has an await between the miss and
235
- // the fill, so N calls arriving together all miss and all exchange.
236
- //
237
- // One process per caller hides it; a Worker isolate serving many callers,
238
- // or simply several tool calls in flight at once, turns one refresh into N
239
- // simultaneous hits on Google's token endpoint — which is a good way to be
240
- // rate-limited into exactly the intermittent auth errors being debugged.
241
- let inFlight = 0;
242
- let maxConcurrent = 0;
243
- const fetchMock = vi.fn(async () => {
244
- inFlight += 1;
245
- maxConcurrent = Math.max(maxConcurrent, inFlight);
246
- await new Promise((r) => setTimeout(r, 5));
247
- inFlight -= 1;
248
- return tokenResponse('ya29.shared');
249
- });
250
- vi.stubGlobal('fetch', fetchMock);
251
-
252
- const source = makeAccessTokenSource({ ...CLIENT, GOG_REFRESH_TOKEN: 'rt-1' })!;
253
- const results = await Promise.all(Array.from({ length: 8 }, () => source()));
254
-
255
- expect(results).toEqual(Array(8).fill('ya29.shared'));
256
- expect(fetchMock).toHaveBeenCalledTimes(1);
257
- expect(maxConcurrent).toBe(1);
258
- });
259
-
260
- it('lets a later caller retry after a concurrent exchange failed', async () => {
261
- // Coalescing must not make one failure permanent for everyone: the shared
262
- // attempt is dropped when it settles, so the next call starts a fresh one.
263
- // The failure has to take a tick. An exchange that rejects instantly can
264
- // finish and clear itself before the second caller even looks, so that
265
- // caller correctly starts its own attempt — which would make this test pass
266
- // without any sharing having happened. A real exchange is a network round
267
- // trip, so the shared-failure case is the one worth pinning.
268
- const fetchMock = vi
269
- .fn()
270
- .mockImplementationOnce(async () => {
271
- await new Promise((r) => setTimeout(r, 5));
272
- throw new Error('boom');
273
- })
274
- .mockResolvedValueOnce(tokenResponse('ya29.after'));
275
- vi.stubGlobal('fetch', fetchMock);
276
- const source = makeAccessTokenSource({ ...CLIENT, GOG_REFRESH_TOKEN: 'rt-1' })!;
277
-
278
- const settled = await Promise.allSettled([source(), source()]);
279
- expect(settled.every((s) => s.status === 'rejected')).toBe(true);
280
- // Both shared ONE failed exchange rather than each making their own...
281
- expect(fetchMock).toHaveBeenCalledTimes(1);
282
- // ...and the failure was not cached, so the next caller recovers.
283
- expect(await source()).toBe('ya29.after');
284
- });
285
-
286
- it('does not let two different credentials share one in-flight exchange', async () => {
287
- // Answers are keyed off the REQUEST's refresh_token, never off call order.
288
- //
289
- // `mockResolvedValueOnce` twice would assign alice's token to whichever
290
- // exchange reaches fetch first, and that is not the order the sources were
291
- // called in: each one awaits `crypto.subtle.digest` (via the cache key)
292
- // before it reaches fetch, and two digest promises are not guaranteed to
293
- // settle in the order they were started. Measured against this very source,
294
- // bob's request went first in 4 of 400 races (~1%), which is exactly the
295
- // rate at which an order-keyed mock hands alice bob's token and reddens CI
296
- // for a reason that has nothing to do with the behaviour under test.
297
- //
298
- // Keying off the body also makes the assertion say what it means: the point
299
- // is that each credential got ITS OWN token, which is a claim about
300
- // pairing, not about sequence.
301
- const fetchMock = vi.fn(async (_url: string, init: { body: string }) => {
302
- const refreshToken = new URLSearchParams(init.body).get('refresh_token');
303
- return tokenResponse(refreshToken === 'rt-alice' ? 'ya29.alice' : 'ya29.bob');
304
- });
305
- vi.stubGlobal('fetch', fetchMock);
306
- const alice = makeAccessTokenSource({ ...CLIENT, GOG_REFRESH_TOKEN: 'rt-alice' })!;
307
- const bob = makeAccessTokenSource({ ...CLIENT, GOG_REFRESH_TOKEN: 'rt-bob' })!;
308
-
309
- const [a, b] = await Promise.all([alice(), bob()]);
310
- expect(a).toBe('ya29.alice');
311
- expect(b).toBe('ya29.bob');
312
- expect(fetchMock).toHaveBeenCalledTimes(2);
313
- });
314
-
315
- it('never puts the refresh token in the error it throws', async () => {
316
- vi.stubGlobal(
317
- 'fetch',
318
- vi.fn(async () => new Response(JSON.stringify({ error: 'invalid_grant' }), { status: 400 })),
319
- );
320
- const source = makeAccessTokenSource({ ...CLIENT, GOG_REFRESH_TOKEN: 'rt-super-secret-value' })!;
321
- await expect(source()).rejects.toThrow(
322
- expect.not.stringContaining('rt-super-secret-value') as unknown as string,
323
- );
324
- });
325
- });
326
-
327
- /**
328
- * A token Google has REJECTED must leave the cache.
329
- *
330
- * The cache's only read guard is time-based (`expiresAt - EXPIRY_MARGIN_MS >
331
- * Date.now()`), so before this existed a token Google answered 401 to was
332
- * re-served for the rest of its nominal hour — every call in that window failed
333
- * identically, retrying changed nothing, and only reconnecting (a fresh isolate
334
- * with an empty cache) helped.
335
- *
336
- * The eviction is deliberately NOT `clearAccessTokenCache()`. That is a test
337
- * seam which drops EVERY credential; using it here would mean one caller's dead
338
- * token forced a re-mint on every other caller sharing the isolate — a new bug
339
- * in the same shape as the ones this module was built to avoid.
340
- */
341
- describe('invalidate', () => {
342
- it('evicts the rejected token so the next call mints a fresh one', async () => {
343
- const fetchMock = vi
344
- .fn()
345
- .mockResolvedValueOnce(tokenResponse('ya29.first'))
346
- .mockResolvedValueOnce(tokenResponse('ya29.second'));
347
- vi.stubGlobal('fetch', fetchMock);
348
- const source = makeAccessTokenSource({ ...CLIENT, GOG_REFRESH_TOKEN: 'rt-1' })!;
349
-
350
- expect(await source()).toBe('ya29.first');
351
- expect(await source()).toBe('ya29.first');
352
- expect(fetchMock).toHaveBeenCalledTimes(1);
353
-
354
- // True: something was actually dropped, so a retry has a chance of using a
355
- // DIFFERENT token. That answer is what the caller's retry decision hangs on.
356
- expect(await source.invalidate('ya29.first')).toBe(true);
357
-
358
- expect(await source()).toBe('ya29.second');
359
- expect(fetchMock).toHaveBeenCalledTimes(2);
360
- });
361
-
362
- it('evicts only the credential whose token was rejected', async () => {
363
- // The multi-tenant property. A Worker isolate serves many callers; one
364
- // caller meeting a dead token must not cost everyone else a re-mint.
365
- const fetchMock = vi
366
- .fn()
367
- .mockResolvedValueOnce(tokenResponse('ya29.alice'))
368
- .mockResolvedValueOnce(tokenResponse('ya29.bob'));
369
- vi.stubGlobal('fetch', fetchMock);
370
- const alice = makeAccessTokenSource({ ...CLIENT, GOG_REFRESH_TOKEN: 'rt-alice' })!;
371
- const bob = makeAccessTokenSource({ ...CLIENT, GOG_REFRESH_TOKEN: 'rt-bob' })!;
372
-
373
- expect(await alice()).toBe('ya29.alice');
374
- expect(await bob()).toBe('ya29.bob');
375
- expect(await alice.invalidate('ya29.alice')).toBe(true);
376
-
377
- // Bob's entry is untouched — still served from cache, no third exchange.
378
- expect(await bob()).toBe('ya29.bob');
379
- expect(fetchMock).toHaveBeenCalledTimes(2);
380
- });
381
-
382
- it('ignores a rejection naming a token that is no longer the cached one', async () => {
383
- // The ABA case. Caller A sends token T; while A is in flight, B refreshes
384
- // the entry to T2; A comes back with "T was rejected". Dropping T2 there
385
- // would make the next call mint a third token for nothing, and two callers
386
- // could keep evicting each other's work indefinitely.
387
- const fetchMock = vi.fn(async () => tokenResponse('ya29.current'));
388
- vi.stubGlobal('fetch', fetchMock);
389
- const source = makeAccessTokenSource({ ...CLIENT, GOG_REFRESH_TOKEN: 'rt-1' })!;
390
-
391
- expect(await source()).toBe('ya29.current');
392
- expect(await source.invalidate('ya29.superseded')).toBe(false);
393
- expect(await source()).toBe('ya29.current');
394
- expect(fetchMock).toHaveBeenCalledTimes(1);
395
- });
396
-
397
- it('reports nothing evicted when the credential has no cached token at all', async () => {
398
- vi.stubGlobal('fetch', vi.fn());
399
- const source = makeAccessTokenSource({ ...CLIENT, GOG_REFRESH_TOKEN: 'rt-1' })!;
400
- expect(await source.invalidate('ya29.never-minted')).toBe(false);
401
- });
402
-
403
- it('offers no eviction at all for a directly-supplied token, which cannot be re-minted', async () => {
404
- // The #230 path stores an ACCESS token, so there is nothing to mint from:
405
- // reading again returns the identical string.
406
- //
407
- // The ABSENCE of the method is the signal, not a `false` return. A source
408
- // that answers false is indistinguishable from a mintable one whose token a
409
- // concurrent caller already replaced, and the connector logs those two as
410
- // different causes — so a source that attached an always-false `invalidate`
411
- // made the auth log blame concurrency for "nothing here can mint a
412
- // replacement", which is the one case where re-authorizing IS the repair.
413
- const source = makeAccessTokenSource({ GOG_ACCESS_TOKEN: 'ya29.direct' })!;
414
- expect(source.invalidate).toBeUndefined();
415
- expect(await source()).toBe('ya29.direct');
416
- });
417
-
418
- it('offers no eviction for a source that cannot mint at all', async () => {
419
- // GOG_REFRESH_TOKEN without an OAuth client: the source exists only to fail
420
- // loudly on first use, so there is never a cached token behind it.
421
- const source = makeAccessTokenSource({ GOG_REFRESH_TOKEN: 'rt-1', GOG_CLIENT_ID: 'cid' })!;
422
- expect(source.invalidate).toBeUndefined();
423
- await expect(source()).rejects.toThrow(/GOG_CLIENT_SECRET/);
424
- });
425
- });