// A Google 403 for a DISABLED API must NOT read as an expired credential. // // Production case (2026-07-10): a user's "Personal Google" connection showed // the red Expired badge although its OAuth token was alive and refreshing. // The org's Google OAuth client lives in a GCP project without the Gmail / // Drive / Calendar APIs enabled, so the health probe gets Google's // SERVICE_DISABLED 403 ("Gmail API has not been used in project ... or it is // disabled"). The old status-only classifier mapped every 401/403 to // "expired", so a healthy credential rendered as Expired and the UI told the // user to reconnect - advice that cannot fix a disabled upstream API. // // The scenario replays that exact journey against the Google emulator: a real // OAuth grant probes healthy; then the upstream starts answering the probe // operation with Google's real SERVICE_DISABLED body (fault injection - the // token stays valid, exactly like prod). The fixed classifier reads the error // body and reports "misconfigured": on screen that is the amber "API // disabled" badge with Google's own remediation text (including the console // link that enables the API), and no reconnect prompt. import { randomBytes } from "node:crypto"; import { expect } from "@effect/vitest"; import { Effect } from "effect"; import type { HttpApiClient } from "effect/unstable/httpapi"; import { composePluginApi } from "@executor-js/api/server"; import { connectEmulator, type EmulatorClient } from "@executor-js/emulate"; import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; import { AuthTemplateSlug, ConnectionName, IntegrationSlug, OAuthClientSlug, } from "@executor-js/sdk/shared"; import { createEmulatorInstance } from "../src/emulator-instance"; import { scenario } from "../src/scenario"; import { Api, Browser, Target } from "../src/services"; import type { Identity, Target as TargetShape } from "../src/target"; import type { BrowserSurface } from "../src/surfaces/browser"; const api = composePluginApi([openApiHttpPlugin()] as const); type Client = HttpApiClient.ForApi; const GOOGLE_AUTH_TEMPLATE = AuthTemplateSlug.make("googleOAuth2"); const CONNECTION = ConnectionName.make("main"); // The Gmail preset's declared health check, and the probe the fault hijacks. const GMAIL_HEALTH_OPERATION = "gmail.users.labels.list"; // Google's real error body for an API that is not enabled in the OAuth // client's GCP project. Verbatim shape from production: HTTP 403 with reason // `accessNotConfigured` - an authorization-configuration error, NOT an // expired/revoked credential. const DISABLED_API_MESSAGE = "Gmail API has not been used in project 000000000000 before or it is disabled. " + "Enable it by visiting https://console.developers.google.com/apis/api/gmail.googleapis.com/overview?project=000000000000 then retry."; const disabledApiBody = { error: { code: 403, message: DISABLED_API_MESSAGE, errors: [ { message: DISABLED_API_MESSAGE, domain: "usageLimits", reason: "accessNotConfigured" }, ], status: "PERMISSION_DENIED", }, }; const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`; const decodeHtml = (value: string): string => value .replaceAll("&", "&") .replaceAll(""", '"') .replaceAll("'", "'") .replaceAll("<", "<") .replaceAll(">", ">"); const inputFields = (html: string): Record => { const fields: Record = {}; for (const input of html.matchAll(/]*>/gi)) { const tag = input[0]; const name = tag.match(/\bname=["']([^"']+)["']/i)?.[1]; const value = tag.match(/\bvalue=["']([^"']*)["']/i)?.[1] ?? ""; if (name) fields[decodeHtml(name)] = decodeHtml(value); } return fields; }; const formAction = (html: string, fallback: string): string => { const action = html.match(/]*\baction=["']([^"']+)["']/i)?.[1]; return action ? decodeHtml(action) : fallback; }; const completeGoogleConsent = (authorizationUrl: string) => Effect.promise(async () => { const page = await fetch(authorizationUrl); if (!page.ok) throw new Error(`Google emulator authorize failed: ${page.status}`); const html = await page.text(); const callback = await fetch(formAction(html, authorizationUrl), { method: "POST", body: new URLSearchParams(inputFields(html)), redirect: "manual", }); const location = callback.headers.get("location"); if (callback.status !== 302 || !location) { throw new Error(`Google emulator consent did not redirect: ${callback.status}`); } const code = new URL(location).searchParams.get("code"); if (!code) throw new Error("Google emulator callback did not include a code"); return code; }); const createGoogleEmulator = Effect.gen(function* () { const baseUrl = yield* createEmulatorInstance("google", "disabled-api"); const client = yield* Effect.promise(() => connectEmulator({ baseUrl })); return { client, baseUrl }; }); const addGooglePresetFromCatalog = ( browser: BrowserSurface, identity: Identity, presetName: string, slug: string, ) => browser.session(identity, async ({ page, step }) => { await step(`Open ${presetName} from the connect catalog`, async () => { await page.goto("/integrations", { waitUntil: "networkidle" }); await page .getByRole("button", { name: /Connect/ }) .first() .click(); const dialog = page.getByRole("dialog", { name: "Connect an integration" }); await dialog.waitFor(); await dialog.getByPlaceholder(/Search or paste a URL/).fill(presetName); await dialog.getByRole("link", { name: new RegExp(`^${presetName}\\b`) }).click(); }); await step(`Add the ${presetName} integration`, async () => { await page.waitForURL(/\/integrations\/add\/openapi/); await page.getByRole("heading", { name: "Add OpenAPI integration" }).waitFor(); const button = page.getByRole("button", { name: "Add integration" }); await button.waitFor({ timeout: 120_000 }); await button.click({ timeout: 120_000 }); await page.waitForURL(new RegExp(`/integrations/${slug}\\b`), { timeout: 120_000 }); }); }); const connectGoogleAccount = (input: { readonly client: Client; readonly emulator: EmulatorClient; readonly emulatorBaseUrl: string; readonly target: TargetShape; readonly integration: IntegrationSlug; readonly oauthClient: OAuthClientSlug; }) => Effect.gen(function* () { const redirectUri = new URL("/api/oauth/callback", input.target.baseUrl).toString(); const credential = yield* Effect.promise(() => input.emulator.credentials.mint({ type: "oauth-authorization-code", redirect_uris: [redirectUri], }), ); if (!credential.client_id || !credential.client_secret) { return yield* Effect.die(`Google emulator did not mint an OAuth client`); } yield* input.client.openapi.configure({ params: { slug: input.integration }, payload: { baseUrl: input.emulatorBaseUrl }, }); yield* input.client.oauth.createClient({ payload: { owner: "org", slug: input.oauthClient, grant: "authorization_code", authorizationUrl: `${input.emulatorBaseUrl}/o/oauth2/v2/auth`, tokenUrl: `${input.emulatorBaseUrl}/oauth2/token`, clientId: credential.client_id, clientSecret: credential.client_secret, originIntegration: input.integration, }, }); const started = yield* input.client.oauth.start({ payload: { client: input.oauthClient, clientOwner: "org", owner: "org", name: CONNECTION, integration: input.integration, template: GOOGLE_AUTH_TEMPLATE, redirectUri, }, }); expect(started.status, "OAuth starts with an emulator redirect").toBe("redirect"); if (started.status !== "redirect") return yield* Effect.die("OAuth unexpectedly connected"); const code = yield* completeGoogleConsent(started.authorizationUrl); const completed = yield* input.client.oauth.complete({ payload: { state: started.state, code }, }); expect(completed.integration, "OAuth completion creates the connection").toBe( input.integration, ); }); scenario( "Google ยท a disabled upstream API reads as API disabled with the enable link, not Expired", { timeout: 300_000 }, Effect.gen(function* () { const target = yield* Target; const browser = yield* Browser; const { client: makeClient } = yield* Api; const identity = yield* target.newIdentity(); const client = yield* makeClient(api, identity); const emulator = yield* createGoogleEmulator; const slug = IntegrationSlug.make("google_gmail"); const oauthClient = OAuthClientSlug.make(unique("google_gmail_oauth")); yield* Effect.ensuring( Effect.gen(function* () { yield* addGooglePresetFromCatalog(browser, identity, "Gmail", String(slug)); const stored = yield* client.integrations.healthCheckGet({ params: { slug } }); expect(stored?.operation, "the Gmail preset declares its labels probe").toBe( GMAIL_HEALTH_OPERATION, ); yield* connectGoogleAccount({ client, emulator: emulator.client, emulatorBaseUrl: emulator.baseUrl, target, integration: slug, oauthClient, }); // Baseline: the freshly granted token probes healthy against the // emulator - same as prod the day the account was connected. const healthy = yield* client.connections.checkHealth({ params: { owner: "org", integration: slug, name: CONNECTION }, query: { ifStaleMs: 0 }, }); expect(healthy.status, `the fresh grant probes healthy: ${JSON.stringify(healthy)}`).toBe( "healthy", ); // The upstream flips: the probe operation now answers with Google's // real SERVICE_DISABLED 403. ONLY that route faults - the OAuth token // endpoints stay live, so the credential itself remains valid and // refreshable, exactly the production condition. Generous `times` // covers the UI's automatic revalidations on top of explicit checks. yield* Effect.promise(() => emulator.client.faults.arm({ match: { operationId: GMAIL_HEALTH_OPERATION }, response: { status: 403, body: disabledApiBody }, times: 100, }), ); yield* Effect.promise(() => emulator.client.ledger.clear()); // The fix under test: a 403 that means "enable this API in your GCP // project" classifies as misconfigured, not as a dead credential. const verdict = yield* client.connections.checkHealth({ params: { owner: "org", integration: slug, name: CONNECTION }, query: { ifStaleMs: 0 }, }); expect( verdict.status, `the SERVICE_DISABLED 403 classifies as misconfigured: ${JSON.stringify(verdict)}`, ).toBe("misconfigured"); expect(verdict.httpStatus, "the probe observed the 403").toBe(403); expect( verdict.detail, "the stored detail names the real cause (a disabled API, not an expired token)", ).toContain("has not been used in project"); // The 403 came from the armed fault on the probe route - the token // was never rejected by the emulator's auth layer. const ledger = yield* Effect.promise(() => emulator.client.ledger.list(50)); const probeEntry = ledger.find((entry) => entry.operationId === GMAIL_HEALTH_OPERATION); expect(probeEntry?.faulted, "the probe was answered by the armed fault").toBe(true); yield* browser.session(identity, async ({ page, step }) => { const connections = page.locator("section").filter({ has: page.getByRole("heading", { level: 3, name: "Connections" }), }); await step( "The connection shows the amber API disabled badge - not Expired - with no clicks", async () => { await page.goto(`/integrations/${slug}`, { waitUntil: "networkidle" }); await connections .getByText("API disabled", { exact: true }) .waitFor({ timeout: 30_000 }); await connections.getByLabel("Status: API disabled").waitFor(); // The dead-credential story must be gone entirely. expect( await connections.getByText("Expired", { exact: true }).count(), "no Expired badge on a misconfigured connection", ).toBe(0); }, ); await step( "The row carries Google's own instruction, console link included", async () => { const detail = connections.getByText(/has not been used in project/); await detail.waitFor(); const consoleLink = connections.getByRole("link", { name: /console\.developers\.google\.com/, }); await consoleLink.waitFor(); expect( await consoleLink.getAttribute("href"), "the enable-API console link is clickable", ).toContain("console.developers.google.com"); // Visible in FULL, not just present in the DOM: a one-line // truncate keeps the link in the tree while clipping it from // view, which is exactly the regression this guards against. // A clipped element overflows horizontally; a wrapped one // grows taller instead. const clipped = await detail.evaluate( (el) => el.scrollWidth > el.clientWidth + 1 || el.scrollHeight > el.clientHeight + 1, ); expect(clipped, "the remediation text wraps instead of clipping").toBe(false); const linkVisible = await consoleLink.evaluate((el) => { const rect = el.getBoundingClientRect(); const detailRect = el.closest("p")?.getBoundingClientRect(); return detailRect ? rect.right <= detailRect.right + 1 : false; }); expect(linkVisible, "the console link sits inside the visible detail box").toBe(true); }, ); await step( "Check now explains the disabled API instead of prescribing reconnect", async () => { await connections.locator('button[aria-haspopup="menu"]').click(); await page.getByRole("menuitem", { name: "Check now" }).click(); await page .getByText(/has not been used in project/) .first() .waitFor({ timeout: 30_000 }); expect( await page.getByText("Connection expired, reconnect to restore access").count(), "the reconnect toast never fires for a configuration 403", ).toBe(0); }, ); }); }), Effect.gen(function* () { yield* client.connections .remove({ params: { owner: "org", integration: slug, name: CONNECTION } }) .pipe(Effect.ignore); yield* client.oauth .removeClient({ params: { slug: oauthClient }, payload: { owner: "org" } }) .pipe(Effect.ignore); yield* client.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore); }), ); }), );