Skip to content

Commit a546d2c

Browse files
committed
e2e: repro Google SERVICE_DISABLED 403 misclassified as expired connection
1 parent 04ce4b4 commit a546d2c

1 file changed

Lines changed: 323 additions & 0 deletions

File tree

Lines changed: 323 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,323 @@
1+
// Repro: a Google 403 for a DISABLED API reads as an expired credential.
2+
//
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.
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
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.
11+
//
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
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.
20+
import { randomBytes } from "node:crypto";
21+
22+
import { expect } from "@effect/vitest";
23+
import { Effect } from "effect";
24+
import type { HttpApiClient } from "effect/unstable/httpapi";
25+
import { composePluginApi } from "@executor-js/api/server";
26+
import { connectEmulator, type EmulatorClient } from "@executor-js/emulate";
27+
import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
28+
import {
29+
AuthTemplateSlug,
30+
ConnectionName,
31+
IntegrationSlug,
32+
OAuthClientSlug,
33+
} from "@executor-js/sdk/shared";
34+
35+
import { createEmulatorInstance } from "../src/emulator-instance";
36+
import { scenario } from "../src/scenario";
37+
import { Api, Browser, Target } from "../src/services";
38+
import type { Identity, Target as TargetShape } from "../src/target";
39+
import type { BrowserSurface } from "../src/surfaces/browser";
40+
41+
const api = composePluginApi([openApiHttpPlugin()] as const);
42+
type Client = HttpApiClient.ForApi<typeof api>;
43+
const GOOGLE_AUTH_TEMPLATE = AuthTemplateSlug.make("googleOAuth2");
44+
const CONNECTION = ConnectionName.make("main");
45+
46+
// The Gmail preset's declared health check, and the probe the fault hijacks.
47+
const GMAIL_HEALTH_OPERATION = "gmail.users.labels.list";
48+
49+
// Google's real error body for an API that is not enabled in the OAuth
50+
// client's GCP project. Verbatim shape from production: HTTP 403 with reason
51+
// `accessNotConfigured` - an authorization-configuration error, NOT an
52+
// expired/revoked credential.
53+
const DISABLED_API_MESSAGE =
54+
"Gmail API has not been used in project 000000000000 before or it is disabled. " +
55+
"Enable it by visiting https://console.developers.google.com/apis/api/gmail.googleapis.com/overview?project=000000000000 then retry.";
56+
const disabledApiBody = {
57+
error: {
58+
code: 403,
59+
message: DISABLED_API_MESSAGE,
60+
errors: [
61+
{ message: DISABLED_API_MESSAGE, domain: "usageLimits", reason: "accessNotConfigured" },
62+
],
63+
status: "PERMISSION_DENIED",
64+
},
65+
};
66+
67+
const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`;
68+
69+
const decodeHtml = (value: string): string =>
70+
value
71+
.replaceAll("&amp;", "&")
72+
.replaceAll("&quot;", '"')
73+
.replaceAll("&#x27;", "'")
74+
.replaceAll("&lt;", "<")
75+
.replaceAll("&gt;", ">");
76+
77+
const inputFields = (html: string): Record<string, string> => {
78+
const fields: Record<string, string> = {};
79+
for (const input of html.matchAll(/<input\b[^>]*>/gi)) {
80+
const tag = input[0];
81+
const name = tag.match(/\bname=["']([^"']+)["']/i)?.[1];
82+
const value = tag.match(/\bvalue=["']([^"']*)["']/i)?.[1] ?? "";
83+
if (name) fields[decodeHtml(name)] = decodeHtml(value);
84+
}
85+
return fields;
86+
};
87+
88+
const formAction = (html: string, fallback: string): string => {
89+
const action = html.match(/<form\b[^>]*\baction=["']([^"']+)["']/i)?.[1];
90+
return action ? decodeHtml(action) : fallback;
91+
};
92+
93+
const completeGoogleConsent = (authorizationUrl: string) =>
94+
Effect.promise(async () => {
95+
const page = await fetch(authorizationUrl);
96+
if (!page.ok) throw new Error(`Google emulator authorize failed: ${page.status}`);
97+
const html = await page.text();
98+
const callback = await fetch(formAction(html, authorizationUrl), {
99+
method: "POST",
100+
body: new URLSearchParams(inputFields(html)),
101+
redirect: "manual",
102+
});
103+
const location = callback.headers.get("location");
104+
if (callback.status !== 302 || !location) {
105+
throw new Error(`Google emulator consent did not redirect: ${callback.status}`);
106+
}
107+
const code = new URL(location).searchParams.get("code");
108+
if (!code) throw new Error("Google emulator callback did not include a code");
109+
return code;
110+
});
111+
112+
const createGoogleEmulator = Effect.gen(function* () {
113+
const baseUrl = yield* createEmulatorInstance("google", "disabled-api");
114+
const client = yield* Effect.promise(() => connectEmulator({ baseUrl }));
115+
return { client, baseUrl };
116+
});
117+
118+
const addGooglePresetFromCatalog = (
119+
browser: BrowserSurface,
120+
identity: Identity,
121+
presetName: string,
122+
slug: string,
123+
) =>
124+
browser.session(identity, async ({ page, step }) => {
125+
await step(`Open ${presetName} from the connect catalog`, async () => {
126+
await page.goto("/integrations", { waitUntil: "networkidle" });
127+
await page
128+
.getByRole("button", { name: /Connect/ })
129+
.first()
130+
.click();
131+
const dialog = page.getByRole("dialog", { name: "Connect an integration" });
132+
await dialog.waitFor();
133+
await dialog.getByPlaceholder(/Search or paste a URL/).fill(presetName);
134+
await dialog.getByRole("link", { name: new RegExp(`^${presetName}\\b`) }).click();
135+
});
136+
137+
await step(`Add the ${presetName} integration`, async () => {
138+
await page.waitForURL(/\/integrations\/add\/openapi/);
139+
await page.getByRole("heading", { name: "Add OpenAPI integration" }).waitFor();
140+
const button = page.getByRole("button", { name: "Add integration" });
141+
await button.waitFor({ timeout: 120_000 });
142+
await button.click({ timeout: 120_000 });
143+
await page.waitForURL(new RegExp(`/integrations/${slug}\\b`), { timeout: 120_000 });
144+
});
145+
});
146+
147+
const connectGoogleAccount = (input: {
148+
readonly client: Client;
149+
readonly emulator: EmulatorClient;
150+
readonly emulatorBaseUrl: string;
151+
readonly target: TargetShape;
152+
readonly integration: IntegrationSlug;
153+
readonly oauthClient: OAuthClientSlug;
154+
}) =>
155+
Effect.gen(function* () {
156+
const redirectUri = new URL("/api/oauth/callback", input.target.baseUrl).toString();
157+
const credential = yield* Effect.promise(() =>
158+
input.emulator.credentials.mint({
159+
type: "oauth-authorization-code",
160+
redirect_uris: [redirectUri],
161+
}),
162+
);
163+
if (!credential.client_id || !credential.client_secret) {
164+
return yield* Effect.die(`Google emulator did not mint an OAuth client`);
165+
}
166+
167+
yield* input.client.openapi.configure({
168+
params: { slug: input.integration },
169+
payload: { baseUrl: input.emulatorBaseUrl },
170+
});
171+
yield* input.client.oauth.createClient({
172+
payload: {
173+
owner: "org",
174+
slug: input.oauthClient,
175+
grant: "authorization_code",
176+
authorizationUrl: `${input.emulatorBaseUrl}/o/oauth2/v2/auth`,
177+
tokenUrl: `${input.emulatorBaseUrl}/oauth2/token`,
178+
clientId: credential.client_id,
179+
clientSecret: credential.client_secret,
180+
originIntegration: input.integration,
181+
},
182+
});
183+
184+
const started = yield* input.client.oauth.start({
185+
payload: {
186+
client: input.oauthClient,
187+
clientOwner: "org",
188+
owner: "org",
189+
name: CONNECTION,
190+
integration: input.integration,
191+
template: GOOGLE_AUTH_TEMPLATE,
192+
redirectUri,
193+
},
194+
});
195+
expect(started.status, "OAuth starts with an emulator redirect").toBe("redirect");
196+
if (started.status !== "redirect") return yield* Effect.die("OAuth unexpectedly connected");
197+
198+
const code = yield* completeGoogleConsent(started.authorizationUrl);
199+
const completed = yield* input.client.oauth.complete({
200+
payload: { state: started.state, code },
201+
});
202+
expect(completed.integration, "OAuth completion creates the connection").toBe(
203+
input.integration,
204+
);
205+
});
206+
207+
scenario(
208+
"Google · repro: a disabled upstream API reads as an expired connection (SERVICE_DISABLED 403)",
209+
{ timeout: 300_000 },
210+
Effect.gen(function* () {
211+
const target = yield* Target;
212+
const browser = yield* Browser;
213+
const { client: makeClient } = yield* Api;
214+
const identity = yield* target.newIdentity();
215+
const client = yield* makeClient(api, identity);
216+
const emulator = yield* createGoogleEmulator;
217+
const slug = IntegrationSlug.make("google_gmail");
218+
const oauthClient = OAuthClientSlug.make(unique("google_gmail_oauth"));
219+
220+
yield* Effect.ensuring(
221+
Effect.gen(function* () {
222+
yield* addGooglePresetFromCatalog(browser, identity, "Gmail", String(slug));
223+
224+
const stored = yield* client.integrations.healthCheckGet({ params: { slug } });
225+
expect(stored?.operation, "the Gmail preset declares its labels probe").toBe(
226+
GMAIL_HEALTH_OPERATION,
227+
);
228+
229+
yield* connectGoogleAccount({
230+
client,
231+
emulator: emulator.client,
232+
emulatorBaseUrl: emulator.baseUrl,
233+
target,
234+
integration: slug,
235+
oauthClient,
236+
});
237+
238+
// Baseline: the freshly granted token probes healthy against the
239+
// emulator - same as prod the day the account was connected.
240+
const healthy = yield* client.connections.checkHealth({
241+
params: { owner: "org", integration: slug, name: CONNECTION },
242+
query: { ifStaleMs: 0 },
243+
});
244+
expect(healthy.status, `the fresh grant probes healthy: ${JSON.stringify(healthy)}`).toBe(
245+
"healthy",
246+
);
247+
248+
// The upstream flips: the probe operation now answers with Google's
249+
// real SERVICE_DISABLED 403. ONLY that route faults - the OAuth token
250+
// endpoints stay live, so the credential itself remains valid and
251+
// refreshable, exactly the production condition. Generous `times`
252+
// covers the UI's automatic revalidations on top of explicit checks.
253+
yield* Effect.promise(() =>
254+
emulator.client.faults.arm({
255+
match: { operationId: GMAIL_HEALTH_OPERATION },
256+
response: { status: 403, body: disabledApiBody },
257+
times: 100,
258+
}),
259+
);
260+
yield* Effect.promise(() => emulator.client.ledger.clear());
261+
262+
// THE BUG, pinned: a 403 that means "enable this API in your GCP
263+
// project" classifies as an expired credential.
264+
const verdict = yield* client.connections.checkHealth({
265+
params: { owner: "org", integration: slug, name: CONNECTION },
266+
query: { ifStaleMs: 0 },
267+
});
268+
expect(
269+
verdict.status,
270+
"BUG: the SERVICE_DISABLED 403 classifies as an expired credential",
271+
).toBe("expired");
272+
expect(verdict.httpStatus, "the probe observed the 403").toBe(403);
273+
expect(
274+
verdict.detail,
275+
"the stored detail names the real cause (a disabled API, not an expired token)",
276+
).toContain("has not been used in project");
277+
278+
// The 403 came from the armed fault on the probe route - the token
279+
// was never rejected by the emulator's auth layer.
280+
const ledger = yield* Effect.promise(() => emulator.client.ledger.list(50));
281+
const probeEntry = ledger.find((entry) => entry.operationId === GMAIL_HEALTH_OPERATION);
282+
expect(probeEntry?.faulted, "the probe was answered by the armed fault").toBe(true);
283+
284+
yield* browser.session(identity, async ({ page, step }) => {
285+
const connections = page.locator("section").filter({
286+
has: page.getByRole("heading", { level: 3, name: "Connections" }),
287+
});
288+
289+
await step(
290+
"The healthy-yesterday connection now shows Expired, with no clicks",
291+
async () => {
292+
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();
295+
},
296+
);
297+
298+
await step(
299+
"Check now tells the user to reconnect - advice that cannot fix a disabled API",
300+
async () => {
301+
await connections.locator('button[aria-haspopup="menu"]').click();
302+
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
306+
.getByText("Connection expired, reconnect to restore access")
307+
.waitFor({ timeout: 30_000 });
308+
},
309+
);
310+
});
311+
}),
312+
Effect.gen(function* () {
313+
yield* client.connections
314+
.remove({ params: { owner: "org", integration: slug, name: CONNECTION } })
315+
.pipe(Effect.ignore);
316+
yield* client.oauth
317+
.removeClient({ params: { slug: oauthClient }, payload: { owner: "org" } })
318+
.pipe(Effect.ignore);
319+
yield* client.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore);
320+
}),
321+
);
322+
}),
323+
);

0 commit comments

Comments
 (0)