From 11406bd9e55c09c8319adf46ff3c3d2380d8fc6a Mon Sep 17 00:00:00 2001 From: fajarhide Date: Fri, 28 Aug 2026 22:29:54 +0700 Subject: [PATCH] test: exercise every google scope against a real account The consent screen makes you justify each restricted scope in writing and then demonstrate it on video. Both are easier to get right when the claims have been run rather than reasoned about, and a scope that quietly stopped working is the kind of thing you want to find before a reviewer does. Fifteen calls in four groups, one per scope. Everything it creates it removes: a scratch event a year out, a scratch folder, a message to the account's own address. It touches nothing that was already there apart from two reads. Not part of vitest. It needs a live gateway, a workspace credential and a connected Google account, so it stays a command. --- package.json | 3 +- scripts/check-google-scopes.ts | 132 +++++++++++++++++++++++++++++++++ 2 files changed, 134 insertions(+), 1 deletion(-) create mode 100644 scripts/check-google-scopes.ts diff --git a/package.json b/package.json index 892cf5b..543089f 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "typecheck": "tsc -p tsconfig.json --noEmit", "test": "vitest run", "test:watch": "vitest", - "quickstart": "node --env-file-if-exists=.env --experimental-strip-types scripts/quickstart.ts" + "quickstart": "node --env-file-if-exists=.env --experimental-strip-types scripts/quickstart.ts", + "check:google": "node --env-file-if-exists=.env --experimental-strip-types scripts/check-google-scopes.ts" }, "dependencies": { "@electric-sql/pglite": "^0.5.6", diff --git a/scripts/check-google-scopes.ts b/scripts/check-google-scopes.ts new file mode 100644 index 0000000..5dcb7c6 --- /dev/null +++ b/scripts/check-google-scopes.ts @@ -0,0 +1,132 @@ +/** + * Exercises every Google scope the gateway asks for, against a real account, + * and puts each one back the way it found it. + * + * It exists because the Google consent screen makes you justify each scope in + * writing and then demonstrate it on video, and both are worth checking before + * a reviewer does. A passing run is the evidence: the tools ran, the writes + * took effect, and nothing narrower would have served. + * + * Not a unit test and not run by vitest. It needs a live gateway, a workspace + * credential, and a connected Google account, so it is a command you run: + * + * GATEWAY_URL=https://api.selat.dev SELAT_TOKEN=... npm run check:google + * + * ponytail: creates its own scratch folder, event and message rather than + * touching anything already in the account. Slower than renaming a file that + * is already there, and the only version safe to hand someone else. + */ + +const gateway = (process.env.GATEWAY_URL ?? '').replace(/\/$/, '') +const token = process.env.SELAT_TOKEN ?? '' +if (!gateway || !token) { + console.error('set GATEWAY_URL and SELAT_TOKEN') + process.exit(2) +} + +const MARK = 'selat-scope-check' +let failed = 0 + +async function call(tool: string, args: Record = {}): Promise { + const res = await fetch(`${gateway}/v1/tools/${tool}/call`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify(args), + }) + const body = (await res.json()) as { content?: unknown; error?: { code: string; message: string } } + if (!res.ok || body.error) { + throw new Error(`${tool}: ${body.error?.code ?? res.status} ${body.error?.message ?? ''}`) + } + return body.content +} + +/** One line per call, so a failure names the tool rather than the scope. */ +async function step(tool: string, args: Record, show: (c: any) => string) { + try { + const content = await call(tool, args) + console.log(` ok ${tool.padEnd(24)} ${show(content)}`) + return content + } catch (error) { + failed += 1 + console.log(` FAIL ${tool.padEnd(24)} ${(error as Error).message}`) + return null + } +} + +const count = (c: any) => `${(c?.items ?? c?.messages ?? c?.files ?? []).length} rows` +const first = (c: any) => (c?.items ?? c?.messages ?? c?.files ?? [])[0] + +console.log(`\ncalendar.calendarlist.readonly`) +await step('gcal__list_calendars', {}, count) + +console.log(`\ncalendar.events`) +await step('gcal__list_events', { calendar_id: 'primary' }, count) +// A year out and an hour long, so a run that dies before cleanup leaves +// something obviously synthetic rather than something in next week's view. +const event = await step( + 'gcal__create_event', + { + calendar_id: 'primary', + summary: MARK, + start_time: '2027-01-04T10:00:00+07:00', + end_time: '2027-01-04T11:00:00+07:00', + }, + (c) => `created ${c.id}`, +) +if (event) { + await step( + 'gcal__delete_event', + { calendar_id: 'primary', event_id: event.id }, + () => `deleted ${event.id}`, + ) +} + +console.log(`\ndrive`) +const files = await step('gdrive__list_files', {}, count) +const someFile = first(files) +if (someFile) { + // The id, never the name. This runs against a real account and its output + // gets pasted into issues and shown on screen while recording the consent + // video, and a document title is the one field in this whole run that can + // carry something private. + await step('gdrive__get_file', { file_id: someFile.id }, (c) => `read ${c.id}`) +} +const folder = await step('gdrive__create_folder', { name: MARK }, (c) => `created ${c.id}`) +if (folder) { + await step( + 'gdrive__rename_file', + { file_id: folder.id, name: `${MARK}-renamed` }, + (c) => `renamed to ${c.name}`, + ) + // delete rather than trash: it existed for two seconds and leaving it in the + // bin makes the caller clean up after a check that claimed to clean up. + await step('gdrive__delete_file', { file_id: folder.id }, () => `deleted ${folder.id}`) +} + +console.log(`\ngmail.modify`) +const profile = await step('gmail__get_profile', {}, (c) => c.emailAddress) +await step('gmail__list_messages', { query: 'in:inbox' }, count) +const messages = await call('gmail__list_messages', { query: 'in:inbox' }).catch(() => null) +const someMessage = first(messages) +if (someMessage) { + await step('gmail__get_message', { id: someMessage.id }, (c) => `read ${c.id}`) +} +if (profile) { + const raw = Buffer.from( + `To: ${profile.emailAddress}\r\nSubject: ${MARK}\r\n\r\nSent and trashed by ${MARK}.\r\n`, + ).toString('base64') + const sent = await step('gmail__send_message', { raw }, (c) => `sent ${c.id}`) + if (sent) { + // STARRED is visible and reversible. Removing UNREAD would also prove the + // scope, but it edits state a person might care about. + await step( + 'gmail__modify_message', + { id: sent.id, add_label_ids: ['STARRED'] }, + (c) => `labels ${c.labelIds.join(',')}`, + ) + await step('gmail__trash_message', { id: sent.id }, (c) => `labels ${c.labelIds.join(',')}`) + } +} + +console.log(failed === 0 ? '\nall scopes exercised, nothing left behind\n' : `\n${failed} failed\n`) +process.exit(failed === 0 ? 0 : 1)