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.
Showing the changes to e2e (1 of 9 files). Show all
Unified · Split
| @@ -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 showed | 3 | // 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's | 6 | // 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 is | 7 | // 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 | 8 | // disabled"). The old status-only classifier mapped every 401/403 to |
| 9 | // perfectly healthy credential renders as Expired and the UI tells the user | 9 | // "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 real | 12 | // The scenario replays that exact journey against the Google emulator: a real |
| 13 | // OAuth grant probes healthy; then the upstream starts answering the probe | 13 | // OAuth grant probes healthy; then the upstream starts answering the probe |
| 14 | // operation with Google's real SERVICE_DISABLED body (fault injection - the | 14 | // operation with Google's real SERVICE_DISABLED body (fault injection - the |
| 15 | // token stays valid, exactly like prod); the connection now reads Expired on | 15 | // 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 disabled | 17 | // disabled" badge with Google's own remediation text (including the console |
| 18 | // in the project" from "credential expired", this scenario is the one that | 18 | // link that enables the API), and no reconnect prompt. |
| 19 | // must change. | ||
| 20 | import { randomBytes } from "node:crypto"; | 19 | import { randomBytes } from "node:crypto"; |
| 21 | 20 | ||
| 22 | import { expect } from "@effect/vitest"; | 21 | import { expect } from "@effect/vitest"; |
| @@ -205,7 +204,7 @@ const connectGoogleAccount = (input: { · 182 unmodified lines | |||
| 205 | }); | 204 | }); |
| 206 | 205 | ||
| 207 | scenario( | 206 | 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", |
| 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 GCP | 261 | // 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 page | 324 | 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 | }); |