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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/dist/index.js +474 -947
- package/dist/lib.js +575 -983
- package/manifest.json +2 -2
- package/mint.yaml +41 -37
- package/package.json +3 -3
- package/server.json +2 -2
- package/src/attachments.ts +28 -34
- package/src/blob-upload.ts +165 -134
- package/src/blob-urls.ts +3 -5
- package/src/bootstrap-auth.ts +97 -0
- package/src/index.ts +3 -4
- package/src/lib.ts +14 -9
- package/src/runner.ts +27 -176
- package/src/tools/appscript.ts +1 -1
- package/src/tools/auth.ts +5 -5
- package/src/tools/drive.ts +1 -3
- package/src/tools/gmail.ts +9 -9
- package/src/tools/utils.ts +12 -58
- package/tests/attachments.test.ts +11 -14
- package/tests/blob-upload.test.ts +235 -160
- package/tests/bootstrap-auth.test.ts +245 -0
- package/tests/runner-file-args.test.ts +1 -13
- package/tests/runner.test.ts +53 -96
- package/tests/sdk-single-copy.test.ts +4 -12
- package/tests/tools/auth-401-shapes.test.ts +2 -3
- package/tests/tools/auth.test.ts +5 -4
- package/tests/tools/drive.test.ts +11 -3
- package/tests/tools/gmail.test.ts +2 -2
- package/tests/tools/utils.test.ts +1 -50
- package/tests/zod-single-copy.test.ts +6 -14
- package/tsconfig.json +1 -2
- package/vitest.config.ts +2 -12
- package/src/auth-log.ts +0 -205
- package/src/connector-auth.ts +0 -319
- package/src/connector-login.ts +0 -87
- package/src/connector-runtime.ts +0 -910
- package/src/google-probe.ts +0 -113
- package/src/google-token.ts +0 -391
- package/src/remote-runner.ts +0 -77
- package/src/worker.ts +0 -117
- package/tests/auth-log.test.ts +0 -530
- package/tests/connector-auth.test.ts +0 -559
- package/tests/connector-login.test.ts +0 -151
- package/tests/connector-runtime.test.ts +0 -1664
- package/tests/google-probe.test.ts +0 -116
- package/tests/google-token.test.ts +0 -425
- package/tests/remote-runner.test.ts +0 -202
- 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
|
-
});
|