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.
2019import { randomBytes } from "node:crypto" ;
2120
2221import { expect } from "@effect/vitest" ;
@@ -205,7 +204,7 @@ const connectGoogleAccount = (input: {
205204 } ) ;
206205
207206scenario (
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(
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(
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 ( / h a s n o t b e e n u s e d i n p r o j e c t / ) . waitFor ( ) ;
308+ const consoleLink = connections . getByRole ( "link" , {
309+ name : / c o n s o l e \. d e v e l o p e r s \. g o o g l e \. c o m / ,
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 ( / h a s n o t b e e n u s e d i n p r o j e c t / )
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 } ) ;
0 commit comments