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.
2//2//
3// Production case (2026-07-10): a user's "Personal Google" connection showed3// Production case (2026-07-10): a user's "Personal Google" connection showed
4// the red Expired badge although its OAuth token was alive and refreshing.4// the red Expired badge although its OAuth token was alive and refreshing.
5// The org's Google OAuth client lives in a GCP project without the Gmail /5// The org's Google OAuth client lives in a GCP project without the Gmail /
6// Drive / Calendar APIs enabled, so the health probe gets Google's6// Drive / Calendar APIs enabled, so the health probe gets Google's
7// SERVICE_DISABLED 403 ("Gmail API has not been used in project ... or it is7// SERVICE_DISABLED 403 ("Gmail API has not been used in project ... or it is
8// disabled"). `classifyHttpStatus` maps every 401/403 to "expired", so a8// disabled"). The old status-only classifier mapped every 401/403 to
9// perfectly healthy credential renders as Expired and the UI tells the user9// "expired", so a healthy credential rendered as Expired and the UI told the
10// to reconnect - which cannot fix a disabled upstream API.10// user to reconnect - advice that cannot fix a disabled upstream API.
11//11//
12// The scenario replays that exact journey against the Google emulator: a real12// The scenario replays that exact journey against the Google emulator: a real
13// OAuth grant probes healthy; then the upstream starts answering the probe13// OAuth grant probes healthy; then the upstream starts answering the probe
14// operation with Google's real SERVICE_DISABLED body (fault injection - the14// operation with Google's real SERVICE_DISABLED body (fault injection - the
15// token stays valid, exactly like prod); the connection now reads Expired on15// token stays valid, exactly like prod). The fixed classifier reads the error
16// screen with the reconnect toast. The assertions pin today's (wrong)16// body and reports "misconfigured": on screen that is the amber "API
17// classification on purpose: when the classifier learns to tell "API disabled17// disabled" badge with Google's own remediation text (including the console
18// in the project" from "credential expired", this scenario is the one that18// link that enables the API), and no reconnect prompt.
19// must change.
20import { randomBytes } from "node:crypto";19import { randomBytes } from "node:crypto";
21 20
22import { expect } from "@effect/vitest";21import { expect } from "@effect/vitest";
@@ -205,7 +204,7 @@ const connectGoogleAccount = (input: { · 182 unmodified lines
205 });204 });
206 205
207scenario(206scenario(
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",
209 { timeout: 300_000 },208 { timeout: 300_000 },
210 Effect.gen(function* () {209 Effect.gen(function* () {
211 const target = yield* Target;210 const target = yield* Target;
@@ -259,16 +258,16 @@ scenario( · 47 unmodified lines
259 );258 );
260 yield* Effect.promise(() => emulator.client.ledger.clear());259 yield* Effect.promise(() => emulator.client.ledger.clear());
261 260
262 // THE BUG, pinned: a 403 that means "enable this API in your GCP261 // The fix under test: a 403 that means "enable this API in your GCP
263 // project" classifies as an expired credential.262 // project" classifies as misconfigured, not as a dead credential.
264 const verdict = yield* client.connections.checkHealth({263 const verdict = yield* client.connections.checkHealth({
265 params: { owner: "org", integration: slug, name: CONNECTION },264 params: { owner: "org", integration: slug, name: CONNECTION },
266 query: { ifStaleMs: 0 },265 query: { ifStaleMs: 0 },
267 });266 });
268 expect(267 expect(
269 verdict.status,268 verdict.status,
270 "BUG: the SERVICE_DISABLED 403 classifies as an expired credential",269 `the SERVICE_DISABLED 403 classifies as misconfigured: ${JSON.stringify(verdict)}`,
271 ).toBe("expired");270 ).toBe("misconfigured");
272 expect(verdict.httpStatus, "the probe observed the 403").toBe(403);271 expect(verdict.httpStatus, "the probe observed the 403").toBe(403);
273 expect(272 expect(
274 verdict.detail,273 verdict.detail,
@@ -287,24 +286,49 @@ scenario( · 12 unmodified lines
287 });286 });
288 287
289 await step(288 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",
291 async () => {290 async () => {
292 await page.goto(`/integrations/${slug}`, { waitUntil: "networkidle" });291 await page.goto(`/integrations/${slug}`, { waitUntil: "networkidle" });
293 await connections.getByText("Expired", { exact: true }).waitFor({ timeout: 30_000 });292 await connections
294 await connections.getByLabel("Status: Expired").waitFor();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");
295 },316 },
296 );317 );
297 318
298 await step(319 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",
300 async () => {321 async () => {
301 await connections.locator('button[aria-haspopup="menu"]').click();322 await connections.locator('button[aria-haspopup="menu"]').click();
302 await page.getByRole("menuitem", { name: "Check now" }).click();323 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".
305 await page324 await page
306 .getByText("Connection expired, reconnect to restore access")325 .getByText(/has not been used in project/)
326 .first()
307 .waitFor({ timeout: 30_000 });327 .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);
308 },332 },
309 );333 );
310 });334 });

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