gogcli-mcp 2.21.0 → 2.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/dist/index.js +436 -58
- package/dist/lib.js +437 -59
- package/manifest.json +1 -1
- package/package.json +1 -1
- package/server.json +2 -2
- package/src/auth-log.ts +205 -0
- package/src/connector-auth.ts +265 -8
- package/src/connector-runtime.ts +764 -79
- package/src/google-probe.ts +113 -0
- package/src/google-token.ts +181 -15
- package/src/runner.ts +67 -2
- package/src/timestamps.ts +7 -0
- package/src/tools/auth.ts +8 -2
- package/src/tools/utils.ts +70 -5
- package/src/worker.ts +33 -3
- package/tests/auth-log.test.ts +530 -0
- package/tests/connector-auth.test.ts +539 -8
- package/tests/connector-runtime.test.ts +1121 -2
- package/tests/google-probe.test.ts +116 -0
- package/tests/google-token.test.ts +125 -4
- package/tests/runner.test.ts +21 -1
- package/tests/timestamps.test.ts +52 -0
- package/tests/tools/auth-401-context.test.ts +42 -0
- package/tests/tools/auth-401-shapes.test.ts +50 -0
- package/tests/tools/auth.test.ts +28 -0
- package/tests/tools/utils.test.ts +55 -2
- package/tests/worker.test.ts +33 -8
|
@@ -0,0 +1,116 @@
|
|
|
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
|
+
});
|
|
@@ -129,6 +129,13 @@ describe('makeAccessTokenSource', () => {
|
|
|
129
129
|
);
|
|
130
130
|
const source = makeAccessTokenSource({ ...CLIENT, GOG_REFRESH_TOKEN: 'rt-dead' })!;
|
|
131
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/);
|
|
132
139
|
});
|
|
133
140
|
|
|
134
141
|
it('does not cache a failure, so a transient outage is not sticky', async () => {
|
|
@@ -277,10 +284,24 @@ describe('makeAccessTokenSource', () => {
|
|
|
277
284
|
});
|
|
278
285
|
|
|
279
286
|
it('does not let two different credentials share one in-flight exchange', async () => {
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
+
});
|
|
284
305
|
vi.stubGlobal('fetch', fetchMock);
|
|
285
306
|
const alice = makeAccessTokenSource({ ...CLIENT, GOG_REFRESH_TOKEN: 'rt-alice' })!;
|
|
286
307
|
const bob = makeAccessTokenSource({ ...CLIENT, GOG_REFRESH_TOKEN: 'rt-bob' })!;
|
|
@@ -302,3 +323,103 @@ describe('makeAccessTokenSource', () => {
|
|
|
302
323
|
);
|
|
303
324
|
});
|
|
304
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
|
+
});
|
package/tests/runner.test.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, it, expect, vi } from 'vitest';
|
|
2
2
|
import { EventEmitter } from 'node:events';
|
|
3
3
|
import { spawn as mockedSpawn } from 'node:child_process';
|
|
4
|
-
import { run, runBinary, runExecutor } from '../src/runner.js';
|
|
4
|
+
import { run, runBinary, runExecutor, RunnerTransportError, isRunnerTransportError } from '../src/runner.js';
|
|
5
5
|
import type { Spawner, GogExecutor } from '../src/runner.js';
|
|
6
6
|
|
|
7
7
|
// The real spawn is dynamically imported inside runner's default executor.
|
|
@@ -763,6 +763,26 @@ describe('run executor seam', () => {
|
|
|
763
763
|
}
|
|
764
764
|
});
|
|
765
765
|
|
|
766
|
+
// run() re-wraps every thrown error to redact secrets from its message. That
|
|
767
|
+
// rewrap must not cost a RunnerTransportError its TYPE: the type is the only
|
|
768
|
+
// thing that tells diagnose() the failure was the connector's transport and
|
|
769
|
+
// not the caller's Google credential, and a bare Error puts it straight back
|
|
770
|
+
// to guessing from prose.
|
|
771
|
+
it('preserves a RunnerTransportError through the redacting rewrap', async () => {
|
|
772
|
+
const executor = vi.fn(async () => {
|
|
773
|
+
throw new RunnerTransportError('runner key mismatch; saw 1//0eLEAKED-REFRESH end', 'transport-auth', 401);
|
|
774
|
+
}) as unknown as GogExecutor;
|
|
775
|
+
const err = await runExecutor
|
|
776
|
+
.run({ executor }, () => run(['gmail', 'get', 'm1']))
|
|
777
|
+
.catch((e: unknown) => e);
|
|
778
|
+
expect(isRunnerTransportError(err)).toBe(true);
|
|
779
|
+
expect((err as RunnerTransportError).kind).toBe('transport-auth');
|
|
780
|
+
expect((err as RunnerTransportError).status).toBe(401);
|
|
781
|
+
// and it is still redacted
|
|
782
|
+
expect((err as Error).message).not.toContain('1//0eLEAKED-REFRESH');
|
|
783
|
+
expect((err as Error).message).toContain('[REDACTED]');
|
|
784
|
+
});
|
|
785
|
+
|
|
766
786
|
it('options.spawner takes precedence over an injected ALS executor', async () => {
|
|
767
787
|
const spawner = makeSpawner(0, '{"via":"spawner"}');
|
|
768
788
|
const executor = vi.fn(async () => '{"via":"executor"}') as unknown as GogExecutor;
|
package/tests/timestamps.test.ts
CHANGED
|
@@ -316,3 +316,55 @@ describe('normalizeTimestamps', () => {
|
|
|
316
316
|
expect(out.date).toMatch(/[+-]\d{2}:\d{2}$/);
|
|
317
317
|
});
|
|
318
318
|
});
|
|
319
|
+
|
|
320
|
+
// gog 0.35.0 (#946) adds `internalDateIso` to Gmail message AND thread
|
|
321
|
+
// listings: Gmail's own internalDate rendered RFC3339 with a real offset. It is
|
|
322
|
+
// separately sourced from the neighbouring `date` (a naive reconstruction of
|
|
323
|
+
// the sender's Date header), so the two can legitimately disagree — but only
|
|
324
|
+
// `internalDateIso` carries its own zone, which makes it the field a machine
|
|
325
|
+
// consumer should read.
|
|
326
|
+
describe('internalDateIso (gog >= 0.35.0 Gmail listings)', () => {
|
|
327
|
+
it('gains a display sibling and survives a DISPLAY_TZ unlike its own offset', () => {
|
|
328
|
+
const payload = JSON.stringify({
|
|
329
|
+
messages: [{ id: 'm1', date: '2026-07-28 03:36', internalDateIso: '2026-07-28T03:36:12-04:00' }],
|
|
330
|
+
});
|
|
331
|
+
const pt = JSON.parse(normalizeTimestamps(payload, 'America/Los_Angeles', 'UTC'));
|
|
332
|
+
const row = pt.messages[0];
|
|
333
|
+
// Re-rendered in the display zone, same instant, offset still explicit.
|
|
334
|
+
expect(row.internalDateIso).toBe('2026-07-28T00:36:12-07:00');
|
|
335
|
+
expect(Date.parse(row.internalDateIso)).toBe(Date.parse('2026-07-28T03:36:12-04:00'));
|
|
336
|
+
expect(row.internalDateIsoDisplay).toContain('Tue, Jul 28');
|
|
337
|
+
expect(isNaiveTimestamp(row.internalDateIso)).toBe(false);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it('keeps sub-second precision when gog emits milliseconds', () => {
|
|
341
|
+
const out = JSON.parse(normalizeTimestamps(
|
|
342
|
+
JSON.stringify({ internalDateIso: '2026-07-28T03:36:12.250-04:00' }), ET,
|
|
343
|
+
));
|
|
344
|
+
expect(out.internalDateIso).toBe('2026-07-28T03:36:12.250-04:00');
|
|
345
|
+
expect(out.internalDateIsoDisplay).toContain('Tue, Jul 28');
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
// The trap: `date` is gog re-formatting the sender header into GOG_TIMEZONE
|
|
349
|
+
// with no offset, so the wrapper has to re-read it in that zone. Only
|
|
350
|
+
// `internalDateIso` is self-describing, and the two are allowed to disagree.
|
|
351
|
+
it('is trusted verbatim while the naive sibling is read in GOG_TIMEZONE', () => {
|
|
352
|
+
const out = JSON.parse(normalizeTimestamps(JSON.stringify({
|
|
353
|
+
date: '2026-07-28 03:36',
|
|
354
|
+
internalDateIso: '2026-07-27T23:36:12-04:00',
|
|
355
|
+
}), ET, 'UTC'));
|
|
356
|
+
expect(out.internalDateIso).toBe('2026-07-27T23:36:12-04:00');
|
|
357
|
+
expect(out.date).toBe('2026-07-27T23:36:00-04:00');
|
|
358
|
+
expect(out.dateDisplay).toContain('Jul 27');
|
|
359
|
+
expect(out.internalDateIsoDisplay).toContain('Jul 27');
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
// A thread listing carries the same field (gmail_thread_search_helpers.go).
|
|
363
|
+
it('normalizes the field on thread listings too', () => {
|
|
364
|
+
const out = JSON.parse(normalizeTimestamps(
|
|
365
|
+
JSON.stringify({ threads: [{ id: 't1', internalDateIso: '2026-07-28T03:36:12Z' }] }), ET,
|
|
366
|
+
));
|
|
367
|
+
expect(out.threads[0].internalDateIso).toBe('2026-07-27T23:36:12-04:00');
|
|
368
|
+
expect(out.threads[0].internalDateIsoDisplay).toContain('Mon, Jul 27');
|
|
369
|
+
});
|
|
370
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { diagnose } from '../../src/tools/utils.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `401` is a status code, but it is also just a number, and gog's output is
|
|
6
|
+
* full of numbers that are row indices, ranges and counts. The pattern used to
|
|
7
|
+
* be a bare `\b401\b`, so "row 401 is outside the sheet grid" — a pure Sheets
|
|
8
|
+
* range error — told the caller to re-authorize a healthy Google account.
|
|
9
|
+
*
|
|
10
|
+
* That is the same defect this PR exists to remove, reached from gog's stderr
|
|
11
|
+
* instead of the runner's status line: a caller is sent to do a manual,
|
|
12
|
+
* account-wide re-auth that cannot possibly fix their problem.
|
|
13
|
+
*
|
|
14
|
+
* So a 401 now has to look like a STATUS, not like an integer.
|
|
15
|
+
*/
|
|
16
|
+
const reauth = async (msg: string) => {
|
|
17
|
+
const r = await diagnose(new Error(msg));
|
|
18
|
+
return /Use gog_auth_add to re-authorize the account/i.test(r.content.map((c: any) => c.text).join('\n'));
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
describe('401 must look like a status, not any integer', () => {
|
|
22
|
+
it.each([
|
|
23
|
+
['row 401 is outside the sheet grid'],
|
|
24
|
+
['wrote 401 rows'],
|
|
25
|
+
['A401:B401 exceeds grid limits'],
|
|
26
|
+
['deleted 401 messages'],
|
|
27
|
+
['sheet has 401 columns'],
|
|
28
|
+
])('does NOT claim an auth failure for: %s', async (msg) => {
|
|
29
|
+
expect(await reauth(msg)).toBe(false);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it.each([
|
|
33
|
+
['googleapi: Error 401: Invalid Credentials, authError'],
|
|
34
|
+
['HTTP 401 Unauthorized'],
|
|
35
|
+
['request failed with status 401'],
|
|
36
|
+
['unexpected status code 401'],
|
|
37
|
+
['401 Unauthorized'],
|
|
38
|
+
['server responded 401: token rejected'],
|
|
39
|
+
])('still DOES claim an auth failure for: %s', async (msg) => {
|
|
40
|
+
expect(await reauth(msg)).toBe(true);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { diagnose } from '../../src/tools/utils.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* #246: making 401 "look like a status" (#245 follow-up) required a separator
|
|
6
|
+
* between the status word and the number. `\s*[:=]?\s*` cannot cross an opening
|
|
7
|
+
* paren or a JSON quote, so the CANONICAL shape gog emits for a Google auth
|
|
8
|
+
* failure stopped matching entirely:
|
|
9
|
+
*
|
|
10
|
+
* Google API error (401 authError): Invalid Credentials
|
|
11
|
+
*
|
|
12
|
+
* That shape is hard-coded in connector-runtime.ts as /Google API error \(401\b/
|
|
13
|
+
* and used as the fixture across auth-log and connector-runtime tests. Losing it
|
|
14
|
+
* is strictly worse than the `row 401` false positive that motivated the change:
|
|
15
|
+
* a false positive sends someone on a pointless re-auth, but this leaves a REAL
|
|
16
|
+
* dead credential with no hint at all.
|
|
17
|
+
*/
|
|
18
|
+
const reauth = async (msg: string) => {
|
|
19
|
+
const r = await diagnose(new Error(msg));
|
|
20
|
+
return /Use gog_auth_add to re-authorize the account/i.test(r.content.map((c: any) => c.text).join('\n'));
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
describe('401 shapes that ARE auth failures', () => {
|
|
24
|
+
it.each([
|
|
25
|
+
['Google API error (401 authError): Invalid Credentials'], // the canonical gog shape
|
|
26
|
+
['Google API error (401): Invalid Credentials'],
|
|
27
|
+
['{"code": 401, "message": "Invalid Credentials"}'], // JSON body
|
|
28
|
+
['{"status":401}'],
|
|
29
|
+
['googleapi: Error 401: Invalid Credentials, authError'],
|
|
30
|
+
['HTTP 401 Unauthorized'],
|
|
31
|
+
['request failed with status 401'],
|
|
32
|
+
['unexpected status code 401'],
|
|
33
|
+
['response=401'],
|
|
34
|
+
])('claims an auth failure for: %s', async (msg) => {
|
|
35
|
+
expect(await reauth(msg)).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe('401 shapes that are NOT auth failures', () => {
|
|
40
|
+
it.each([
|
|
41
|
+
['row 401 is outside the sheet grid'],
|
|
42
|
+
['wrote 401 rows'],
|
|
43
|
+
['A401:B401 exceeds grid limits'],
|
|
44
|
+
['deleted 401 messages'],
|
|
45
|
+
['sheet has 401 columns'],
|
|
46
|
+
['error: could not write row 401'], // status word present, but far from the number
|
|
47
|
+
])('stays silent for: %s', async (msg) => {
|
|
48
|
+
expect(await reauth(msg)).toBe(false);
|
|
49
|
+
});
|
|
50
|
+
});
|
package/tests/tools/auth.test.ts
CHANGED
|
@@ -69,6 +69,20 @@ describe('gog_auth_status', () => {
|
|
|
69
69
|
const result = await harness.callTool('gog_auth_status', {});
|
|
70
70
|
expect(result.content[0].text).toBe('Error: Status failed');
|
|
71
71
|
});
|
|
72
|
+
|
|
73
|
+
it('does not present itself as a health check', async () => {
|
|
74
|
+
// `gog auth status` prints the keyring backend and where the credential
|
|
75
|
+
// files live. It contacts nothing. Named "status" next to a connector whose
|
|
76
|
+
// UI says "connected", it reads as the answer to "is my auth OK?" — which is
|
|
77
|
+
// the question only gog_auth_health can answer.
|
|
78
|
+
const harness = await setupHandlers();
|
|
79
|
+
const { tools } = await harness.client.listTools();
|
|
80
|
+
const desc = tools.find((t) => t.name === 'gog_auth_status')!.description!;
|
|
81
|
+
|
|
82
|
+
expect(desc).toMatch(/does not contact Google/i);
|
|
83
|
+
expect(desc).toContain('gog_auth_health');
|
|
84
|
+
await harness.close();
|
|
85
|
+
});
|
|
72
86
|
});
|
|
73
87
|
|
|
74
88
|
describe('gog_auth_services', () => {
|
|
@@ -127,6 +141,20 @@ describe('gog_auth_add', () => {
|
|
|
127
141
|
});
|
|
128
142
|
|
|
129
143
|
describe('gog_auth_health', () => {
|
|
144
|
+
it('names itself as the only live measurement of the Google layer', async () => {
|
|
145
|
+
// DEFECT 1's wording half: the hosted connector's "connected" / "refreshed"
|
|
146
|
+
// is an OAuth refresh inside OAUTH_KV that contacts neither Fly nor Google.
|
|
147
|
+
// Nothing in this repo can change that word, so the tool that DOES measure
|
|
148
|
+
// has to say that it is the one that does.
|
|
149
|
+
const harness = await setupHandlers();
|
|
150
|
+
const { tools } = await harness.client.listTools();
|
|
151
|
+
const desc = tools.find((t) => t.name === 'gog_auth_health')!.description!;
|
|
152
|
+
|
|
153
|
+
expect(desc).toMatch(/connected|refreshed/i);
|
|
154
|
+
expect(desc).toMatch(/only|nothing else/i);
|
|
155
|
+
await harness.close();
|
|
156
|
+
});
|
|
157
|
+
|
|
130
158
|
const CHECK_JSON = JSON.stringify({
|
|
131
159
|
accounts: [
|
|
132
160
|
{ email: 'chris.c.hall@gmail.com', created_at: '2026-07-17T15:08:39Z', valid: true },
|
|
@@ -1,8 +1,17 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
2
|
import * as runner from '../../src/runner.js';
|
|
3
3
|
import { runOrDiagnose, pushPaginationFlags, formatAccountList, formatAuthHealth } from '../../src/tools/utils.js';
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
import { RunnerTransportError } from '../../src/connector-runtime.js';
|
|
5
|
+
|
|
6
|
+
// PARTIAL mock: only `run` is stubbed. A full automock would also replace
|
|
7
|
+
// `RunnerTransportError` and `isRunnerTransportError`, and diagnose()'s whole
|
|
8
|
+
// point is that it classifies the REAL class structurally — an automocked
|
|
9
|
+
// brand check answers undefined for everything and the tests below would pass
|
|
10
|
+
// while proving nothing.
|
|
11
|
+
vi.mock('../../src/runner.js', async (importOriginal) => ({
|
|
12
|
+
...(await importOriginal<typeof runner>()),
|
|
13
|
+
run: vi.fn(),
|
|
14
|
+
}));
|
|
6
15
|
|
|
7
16
|
beforeEach(() => vi.clearAllMocks());
|
|
8
17
|
|
|
@@ -455,3 +464,47 @@ describe('formatAuthHealth', () => {
|
|
|
455
464
|
expect(formatAuthHealth('{"foo":1}', NOW)).toBe('{"foo":1}');
|
|
456
465
|
});
|
|
457
466
|
});
|
|
467
|
+
|
|
468
|
+
// The connector's own transport failing is NOT the Google credential failing.
|
|
469
|
+
// diagnose() must recognise that structurally, from the error's type, because
|
|
470
|
+
// the runner's prose ("unauthorized") is indistinguishable from Google's.
|
|
471
|
+
describe('diagnose: runner-authored transport failures', () => {
|
|
472
|
+
it('blames the runner key, not the Google account, for a transport-auth failure', async () => {
|
|
473
|
+
vi.mocked(runner.run)
|
|
474
|
+
.mockRejectedValueOnce(
|
|
475
|
+
new RunnerTransportError('gog-runner rejected the bearer token; GOG_RUNNER_KEY vs RUNNER_KEY', 'transport-auth', 401),
|
|
476
|
+
)
|
|
477
|
+
.mockResolvedValueOnce('user@gmail.com');
|
|
478
|
+
const result = await runOrDiagnose(['sheets', 'get', 'A1'], {});
|
|
479
|
+
const text = result.content[0].text as string;
|
|
480
|
+
expect(text).not.toContain('gog_auth_add');
|
|
481
|
+
expect(text).not.toMatch(/re-authorize the account/i);
|
|
482
|
+
expect(text).toContain('GOG_RUNNER_KEY');
|
|
483
|
+
expect(text).toContain('RUNNER_KEY');
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
it('lets the TYPE beat the prose: runner words that read like Google words get no re-auth hint', async () => {
|
|
487
|
+
// The regression this whole change exists to prevent. A runner-authored
|
|
488
|
+
// failure whose text happens to contain every Google auth signal must still
|
|
489
|
+
// not produce Google auth advice — the request never reached Google.
|
|
490
|
+
vi.mocked(runner.run)
|
|
491
|
+
.mockRejectedValueOnce(
|
|
492
|
+
new RunnerTransportError('unauthorized: 401 invalid_grant, token has been expired or revoked', 'transport-request', 400),
|
|
493
|
+
)
|
|
494
|
+
.mockResolvedValueOnce('user@gmail.com');
|
|
495
|
+
const result = await runOrDiagnose(['sheets', 'get', 'A1'], {});
|
|
496
|
+
const text = result.content[0].text as string;
|
|
497
|
+
expect(text).not.toContain('gog_auth_add');
|
|
498
|
+
expect(text).not.toContain('often transient');
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
it('advises a retry for a retryable transport failure', async () => {
|
|
502
|
+
vi.mocked(runner.run)
|
|
503
|
+
.mockRejectedValueOnce(new RunnerTransportError('gog-runner is restarting; retry this call.', 'transport-retryable', 503))
|
|
504
|
+
.mockResolvedValueOnce('user@gmail.com');
|
|
505
|
+
const result = await runOrDiagnose(['sheets', 'get', 'A1'], {});
|
|
506
|
+
const text = result.content[0].text as string;
|
|
507
|
+
expect(text).toContain('often transient');
|
|
508
|
+
expect(text).not.toContain('gog_auth_add');
|
|
509
|
+
});
|
|
510
|
+
});
|
package/tests/worker.test.ts
CHANGED
|
@@ -80,16 +80,41 @@ describe('gogcli Cloudflare connector — OAuth surface', () => {
|
|
|
80
80
|
}
|
|
81
81
|
|
|
82
82
|
it('GET /authorize renders the gogcli login page with the connector-key field', async () => {
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
// 0.8.x
|
|
87
|
-
//
|
|
88
|
-
// `redirect_uri`
|
|
89
|
-
//
|
|
83
|
+
// `/authorize` is only reachable for a REGISTERED client, so the request has
|
|
84
|
+
// to be preceded by a real dynamic client registration.
|
|
85
|
+
//
|
|
86
|
+
// It did not always: workers-oauth-provider 0.8.x parsed the request without
|
|
87
|
+
// a `client_id` (it only called validateRedirectUriScheme unconditionally,
|
|
88
|
+
// which is why `redirect_uri` was already here). 0.10.x moved the
|
|
89
|
+
// `client_id is required` check and the client lookup ahead of everything
|
|
90
|
+
// else in parseAuthRequest, so the old URL now throws before the login page
|
|
91
|
+
// is ever rendered — an AuthorizationError from inside the library, not from
|
|
92
|
+
// any code in this repo. Registering first is the fix, and it exercises the
|
|
93
|
+
// path a real client actually takes.
|
|
94
|
+
const redirectUri = 'https://example.com/callback';
|
|
95
|
+
const registration = await SELF.fetch('https://example.com/register', {
|
|
96
|
+
method: 'POST',
|
|
97
|
+
headers: { 'content-type': 'application/json' },
|
|
98
|
+
body: JSON.stringify({
|
|
99
|
+
client_name: 'worker test client',
|
|
100
|
+
redirect_uris: [redirectUri],
|
|
101
|
+
token_endpoint_auth_method: 'none',
|
|
102
|
+
}),
|
|
103
|
+
});
|
|
104
|
+
expect(registration.status).toBe(201);
|
|
105
|
+
const { client_id: clientId } = (await registration.json()) as { client_id: string };
|
|
106
|
+
expect(clientId).toBeTruthy();
|
|
107
|
+
|
|
108
|
+
// PKCE is mandatory for a public client (`token_endpoint_auth_method: none`)
|
|
109
|
+
// on the authorization-code flow, and the provider enforces it during the
|
|
110
|
+
// same parse. The challenge is never redeemed here — this test stops at the
|
|
111
|
+
// rendered page — so any well-formed S256 value will do.
|
|
112
|
+
const codeChallenge = 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM';
|
|
90
113
|
const res = await SELF.fetch(
|
|
91
114
|
'https://example.com/authorize?response_type=code&state=abc' +
|
|
92
|
-
`&
|
|
115
|
+
`&client_id=${encodeURIComponent(clientId)}` +
|
|
116
|
+
`&redirect_uri=${encodeURIComponent(redirectUri)}` +
|
|
117
|
+
`&code_challenge=${codeChallenge}&code_challenge_method=S256`,
|
|
93
118
|
);
|
|
94
119
|
expect(res.status).toBe(200);
|
|
95
120
|
expect(res.headers.get('content-type')).toContain('text/html');
|