History

health: classify Google disabled-API 403 as misconfigured, not expired

A SERVICE_DISABLED / accessNotConfigured 403 means the API is disabled in the OAuth client's project; the credential is fine. Probes now classify it as a new 'misconfigured' status: amber 'API disabled' badge, the provider's own remediation text (console link clickable) on the row, and no reconnect prompt.

Rhys Sullivan committed 4 months ago

Commit ee67b0e1 parent a546d2c9 files · +262 −37Browse files

Showing the changes to e2e (1 of 9 files). Show all

Unified · Split

e2e/scenarios/google-disabled-api.test.tsrenamed from e2e/scenarios/google-disabled-api-expired.test.ts+45−21
@@ -1,22 +1,21 @@
1−// Repro: a Google 403 for a DISABLED API reads as an expired credential.
1+// A Google 403 for a DISABLED API must NOT read as an expired credential.
22 //
33 // Production case (2026-07-10): a user's "Personal Google" connection showed
44 // the red Expired badge although its OAuth token was alive and refreshing.
55 // The org's Google OAuth client lives in a GCP project without the Gmail /
66 // Drive / Calendar APIs enabled, so the health probe gets Google's
77 // SERVICE_DISABLED 403 ("Gmail API has not been used in project ... or it is
8−// disabled"). `classifyHttpStatus` maps every 401/403 to "expired", so a
9−// perfectly healthy credential renders as Expired and the UI tells the user
10−// to reconnect - which cannot fix a disabled upstream API.
8+// disabled"). The old status-only classifier mapped every 401/403 to
9+// "expired", so a healthy credential rendered as Expired and the UI told the
10+// user to reconnect - advice that cannot fix a disabled upstream API.
1111 //
1212 // The scenario replays that exact journey against the Google emulator: a real
1313 // OAuth grant probes healthy; then the upstream starts answering the probe
1414 // operation with Google's real SERVICE_DISABLED body (fault injection - the
15−// token stays valid, exactly like prod); the connection now reads Expired on
16−// screen with the reconnect toast. The assertions pin today's (wrong)
17−// classification on purpose: when the classifier learns to tell "API disabled
18−// in the project" from "credential expired", this scenario is the one that
19−// must change.
15+// token stays valid, exactly like prod). The fixed classifier reads the error
16+// body and reports "misconfigured": on screen that is the amber "API
17+// disabled" badge with Google's own remediation text (including the console
18+// link that enables the API), and no reconnect prompt.
2019 import { randomBytes } from "node:crypto";
2120
2221 import { expect } from "@effect/vitest";
@@ -205,7 +204,7 @@ const connectGoogleAccount = (input: { · 182 unmodified lines
205204 });
206205
207206 scenario(
208− "Google · repro: a disabled upstream API reads as an expired connection (SERVICE_DISABLED 403)",
207+ "Google · a disabled upstream API reads as API disabled with the enable link, not Expired",
209208 { timeout: 300_000 },
210209 Effect.gen(function* () {
211210 const target = yield* Target;
@@ -259,16 +258,16 @@ scenario( · 47 unmodified lines
259258 );
260259 yield* Effect.promise(() => emulator.client.ledger.clear());
261260
262− // THE BUG, pinned: a 403 that means "enable this API in your GCP
263− // project" classifies as an expired credential.
261+ // The fix under test: a 403 that means "enable this API in your GCP
262+ // project" classifies as misconfigured, not as a dead credential.
264263 const verdict = yield* client.connections.checkHealth({
265264 params: { owner: "org", integration: slug, name: CONNECTION },
266265 query: { ifStaleMs: 0 },
267266 });
268267 expect(
269268 verdict.status,
270− "BUG: the SERVICE_DISABLED 403 classifies as an expired credential",
271− ).toBe("expired");
269+ `the SERVICE_DISABLED 403 classifies as misconfigured: ${JSON.stringify(verdict)}`,
270+ ).toBe("misconfigured");
272271 expect(verdict.httpStatus, "the probe observed the 403").toBe(403);
273272 expect(
274273 verdict.detail,
@@ -287,24 +286,49 @@ scenario( · 12 unmodified lines
287286 });
288287
289288 await step(
290− "The healthy-yesterday connection now shows Expired, with no clicks",
289+ "The connection shows the amber API disabled badge - not Expired - with no clicks",
291290 async () => {
292291 await page.goto(`/integrations/${slug}`, { waitUntil: "networkidle" });
293− await connections.getByText("Expired", { exact: true }).waitFor({ timeout: 30_000 });
294− await connections.getByLabel("Status: Expired").waitFor();
292+ await connections
293+ .getByText("API disabled", { exact: true })
294+ .waitFor({ timeout: 30_000 });
295+ await connections.getByLabel("Status: API disabled").waitFor();
296+ // The dead-credential story must be gone entirely.
297+ expect(
298+ await connections.getByText("Expired", { exact: true }).count(),
299+ "no Expired badge on a misconfigured connection",
300+ ).toBe(0);
301+ },
302+ );
303+
304+ await step(
305+ "The row carries Google's own instruction, console link included",
306+ async () => {
307+ await connections.getByText(/has not been used in project/).waitFor();
308+ const consoleLink = connections.getByRole("link", {
309+ name: /console\.developers\.google\.com/,
310+ });
311+ await consoleLink.waitFor();
312+ expect(
313+ await consoleLink.getAttribute("href"),
314+ "the enable-API console link is clickable",
315+ ).toContain("console.developers.google.com");
295316 },
296317 );
297318
298319 await step(
299− "Check now tells the user to reconnect - advice that cannot fix a disabled API",
320+ "Check now explains the disabled API instead of prescribing reconnect",
300321 async () => {
301322 await connections.locator('button[aria-haspopup="menu"]').click();
302323 await page.getByRole("menuitem", { name: "Check now" }).click();
303− // The misleading UX in one line: the toast prescribes reconnecting
304− // while the upstream error says "enable the API in your project".
305324 await page
306− .getByText("Connection expired, reconnect to restore access")
325+ .getByText(/has not been used in project/)
326+ .first()
307327 .waitFor({ timeout: 30_000 });
328+ expect(
329+ await page.getByText("Connection expired, reconnect to restore access").count(),
330+ "the reconnect toast never fires for a configuration 403",
331+ ).toBe(0);
308332 },
309333 );
310334 });

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t