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 | 3 | // Production case (2026-07-10): a user's "Personal Google" connection showed |
| 4 | 4 | // the red Expired badge although its OAuth token was alive and refreshing. |
| 5 | 5 | // The org's Google OAuth client lives in a GCP project without the Gmail / |
| 6 | 6 | // Drive / Calendar APIs enabled, so the health probe gets Google's |
| 7 | 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 | |
| 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. | |
| 11 | 11 | // |
| 12 | 12 | // The scenario replays that exact journey against the Google emulator: a real |
| 13 | 13 | // OAuth grant probes healthy; then the upstream starts answering the probe |
| 14 | 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 | |
| 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. | |
| 20 | 19 | import { randomBytes } from "node:crypto"; |
| 21 | 20 | |
| 22 | 21 | import { expect } from "@effect/vitest"; |
| @@ -205,7 +204,7 @@ const connectGoogleAccount = (input: { · 182 unmodified lines | ||
| 205 | 204 | }); |
| 206 | 205 | |
| 207 | 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 | 208 | { timeout: 300_000 }, |
| 210 | 209 | Effect.gen(function* () { |
| 211 | 210 | const target = yield* Target; |
| @@ -259,16 +258,16 @@ scenario( · 47 unmodified lines | ||
| 259 | 258 | ); |
| 260 | 259 | yield* Effect.promise(() => emulator.client.ledger.clear()); |
| 261 | 260 | |
| 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. | |
| 264 | 263 | const verdict = yield* client.connections.checkHealth({ |
| 265 | 264 | params: { owner: "org", integration: slug, name: CONNECTION }, |
| 266 | 265 | query: { ifStaleMs: 0 }, |
| 267 | 266 | }); |
| 268 | 267 | expect( |
| 269 | 268 | 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"); | |
| 272 | 271 | expect(verdict.httpStatus, "the probe observed the 403").toBe(403); |
| 273 | 272 | expect( |
| 274 | 273 | verdict.detail, |
| @@ -287,24 +286,49 @@ scenario( · 12 unmodified lines | ||
| 287 | 286 | }); |
| 288 | 287 | |
| 289 | 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 | 290 | async () => { |
| 292 | 291 | 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"); | |
| 295 | 316 | }, |
| 296 | 317 | ); |
| 297 | 318 | |
| 298 | 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 | 321 | async () => { |
| 301 | 322 | await connections.locator('button[aria-haspopup="menu"]').click(); |
| 302 | 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 | 324 | await page |
| 306 | − .getByText("Connection expired, reconnect to restore access") | |
| 325 | + .getByText(/has not been used in project/) | |
| 326 | + .first() | |
| 307 | 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 | }); |