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.
@@ -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
- const fetchMock = vi
281
- .fn()
282
- .mockResolvedValueOnce(tokenResponse('ya29.alice'))
283
- .mockResolvedValueOnce(tokenResponse('ya29.bob'));
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
+ });
@@ -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;
@@ -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
+ });
@@ -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
- vi.mock('../../src/runner.js');
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
+ });
@@ -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
- // No `client_id` query param: the login page renders without needing a
84
- // registered OAuth client, which is all we verify here.
85
- // `redirect_uri` IS required, though — do not remove it. workers-oauth-provider
86
- // 0.8.x calls validateRedirectUriScheme() unconditionally from parseAuthRequest,
87
- // and it rejects any value with no scheme — including the empty string an ABSENT
88
- // `redirect_uri` becomes ("Invalid redirect URI"). client_id stays omitted, so no
89
- // client lookup happens and the assertion below is unchanged.
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
- `&redirect_uri=${encodeURIComponent('https://example.com/callback')}`,
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');