Skip to content

Commit eb877ee

Browse files
RhysSullivanzrm625
authored andcommitted
Add e2e covering scoped health check refreshes
Two connections with distinct identities on one integration; checking one row must not refetch or repaint the sibling owner scope. Asserts both the visible row identity and the absence of a cross-owner connections refetch.
1 parent 926b9db commit eb877ee

1 file changed

Lines changed: 397 additions & 0 deletions

File tree

Lines changed: 397 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,397 @@
1+
// Cross-target (browser): manual connection health checks must update only the
2+
// row that was checked. A broad refresh used to refetch sibling account rows,
3+
// letting unrelated persisted identity changes appear as collateral.
4+
import { randomBytes } from "node:crypto";
5+
import { createServer } from "node:http";
6+
7+
import { expect } from "@effect/vitest";
8+
import { Effect } from "effect";
9+
import type { HttpApiClient } from "effect/unstable/httpapi";
10+
import type { Locator, Page } from "playwright";
11+
import { composePluginApi } from "@executor-js/api/server";
12+
import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
13+
import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared";
14+
15+
import { scenario } from "../src/scenario";
16+
import { Api, Browser, Target } from "../src/services";
17+
18+
const api = composePluginApi([openApiHttpPlugin()] as const);
19+
type Client = HttpApiClient.ForApi<typeof api>;
20+
21+
const TEMPLATE = AuthTemplateSlug.make("apiKey");
22+
const ALICE = "alice@example.com";
23+
const ALICE_REFRESHED = "alice.refreshed@example.com";
24+
const BOB = "bob@example.com";
25+
const BOB_REFRESHED = "BOB-SHOULD-NEVER-APPEAR@example.com";
26+
const WATCHER_KEY = "__executorHealthScopedRefreshWatcher";
27+
28+
type RowTitleSample = {
29+
readonly atMs: number;
30+
readonly title: string;
31+
};
32+
33+
type WatcherState = {
34+
readonly done: boolean;
35+
readonly samples: readonly RowTitleSample[];
36+
readonly violation: RowTitleSample | null;
37+
};
38+
39+
const newSlug = (prefix: string) =>
40+
IntegrationSlug.make(`${prefix}-${randomBytes(4).toString("hex")}`);
41+
42+
const identitySpec = (baseUrl: string): string =>
43+
JSON.stringify({
44+
openapi: "3.0.3",
45+
info: { title: "Scoped Health API", version: "1.0.0" },
46+
servers: [{ url: baseUrl }],
47+
paths: {
48+
"/me": {
49+
get: {
50+
operationId: "getMe",
51+
summary: "The current account",
52+
responses: {
53+
"200": {
54+
description: "The authenticated account",
55+
content: {
56+
"application/json": {
57+
schema: {
58+
type: "object",
59+
properties: { email: { type: "string" }, login: { type: "string" } },
60+
},
61+
},
62+
},
63+
},
64+
},
65+
},
66+
},
67+
},
68+
});
69+
70+
const serveIdentityApi = (
71+
accounts: readonly {
72+
readonly token: string;
73+
readonly emails: readonly [string, ...string[]];
74+
readonly login: string;
75+
readonly delayMs?: number;
76+
}[],
77+
) =>
78+
Effect.acquireRelease(
79+
Effect.callback<{ readonly url: string; readonly close: () => void }>((resume) => {
80+
const byToken = new Map(accounts.map((account) => [account.token, account]));
81+
const callsByToken = new Map<string, number>();
82+
const server = createServer((request, response) => {
83+
const authorization = Array.isArray(request.headers.authorization)
84+
? request.headers.authorization[0]
85+
: request.headers.authorization;
86+
const token = authorization?.startsWith("Bearer ")
87+
? authorization.slice("Bearer ".length)
88+
: "";
89+
const account = byToken.get(token);
90+
91+
if (request.method === "GET" && (request.url ?? "").startsWith("/me")) {
92+
const respond = () => {
93+
if (!account) {
94+
response.writeHead(401, { "content-type": "application/json" });
95+
response.end(JSON.stringify({ error: "invalid_token" }));
96+
return;
97+
}
98+
const calls = callsByToken.get(token) ?? 0;
99+
const email = account.emails[Math.min(calls, account.emails.length - 1)];
100+
callsByToken.set(token, calls + 1);
101+
response.writeHead(200, { "content-type": "application/json" });
102+
response.end(JSON.stringify({ email, login: account.login }));
103+
};
104+
const delayMs = account?.delayMs ?? 0;
105+
if (delayMs > 0) {
106+
setTimeout(respond, delayMs);
107+
return;
108+
}
109+
respond();
110+
return;
111+
}
112+
113+
response.writeHead(404, { "content-type": "application/json" });
114+
response.end(JSON.stringify({ error: "not_found" }));
115+
});
116+
server.listen(0, "127.0.0.1", () => {
117+
const address = server.address();
118+
const port = typeof address === "object" && address ? address.port : 0;
119+
resume(
120+
Effect.succeed({
121+
url: `http://127.0.0.1:${port}`,
122+
close: () => {
123+
server.close();
124+
server.closeAllConnections();
125+
},
126+
}),
127+
);
128+
});
129+
}),
130+
(server) => Effect.sync(server.close),
131+
);
132+
133+
const registerIdentityIntegration = (client: Client, slug: IntegrationSlug, baseUrl: string) =>
134+
client.openapi.addSpec({
135+
payload: {
136+
spec: { kind: "blob", value: identitySpec(baseUrl) },
137+
slug,
138+
baseUrl,
139+
authenticationTemplate: [
140+
{
141+
slug: "apiKey",
142+
type: "apiKey",
143+
headers: { authorization: ["Bearer ", { type: "variable", name: "token" }] },
144+
},
145+
],
146+
},
147+
});
148+
149+
const getMeOperation = (client: Client, slug: IntegrationSlug) =>
150+
Effect.gen(function* () {
151+
const candidates = yield* client.integrations.healthCheckCandidates({ params: { slug } });
152+
const getMe = candidates.find((candidate) => candidate.method === "get");
153+
if (!getMe) return yield* Effect.die("identity spec exposed no GET candidate");
154+
return getMe.operation;
155+
});
156+
157+
const accountRow = (page: Page, marker: string): Locator =>
158+
page.locator('[data-slot="card-stack-entry"]').filter({ hasText: marker }).first();
159+
160+
const rowTitle = (row: Locator): Locator => row.locator('[data-slot="card-stack-entry-title"]');
161+
162+
const readTitle = async (row: Locator): Promise<string> =>
163+
(await rowTitle(row).innerText()).replace(/\s+/g, " ").trim();
164+
165+
const installRowTitleWatcher = (
166+
page: Page,
167+
input: {
168+
readonly marker: string;
169+
readonly forbiddenTitle: string;
170+
readonly durationMs: number;
171+
},
172+
) =>
173+
page.evaluate(({ marker, forbiddenTitle, durationMs }) => {
174+
type MutableWatcherState = {
175+
done: boolean;
176+
samples: RowTitleSample[];
177+
violation: RowTitleSample | null;
178+
};
179+
const key = "__executorHealthScopedRefreshWatcher";
180+
const normalize = (value: string | null | undefined) =>
181+
(value ?? "").replace(/\s+/g, " ").trim();
182+
const state: MutableWatcherState = { done: false, samples: [], violation: null };
183+
const globalWindow = window as Window & Record<string, MutableWatcherState | undefined>;
184+
globalWindow[key] = state;
185+
const startedAt = performance.now();
186+
const readCurrentTitle = () => {
187+
const row = Array.from(
188+
document.querySelectorAll<HTMLElement>('[data-slot="card-stack-entry"]'),
189+
).find((element) => normalize(element.textContent).includes(marker));
190+
return normalize(row?.querySelector('[data-slot="card-stack-entry-title"]')?.textContent);
191+
};
192+
const record = () => {
193+
const sample = { atMs: Math.round(performance.now() - startedAt), title: readCurrentTitle() };
194+
state.samples.push(sample);
195+
if (sample.title.includes(forbiddenTitle) && state.violation === null) {
196+
state.violation = sample;
197+
}
198+
};
199+
const observer = new MutationObserver(record);
200+
observer.observe(document.body, { childList: true, characterData: true, subtree: true });
201+
const interval = window.setInterval(record, 20);
202+
record();
203+
window.setTimeout(() => {
204+
record();
205+
window.clearInterval(interval);
206+
observer.disconnect();
207+
state.done = true;
208+
}, durationMs);
209+
}, input);
210+
211+
const readWatcherState = (page: Page) =>
212+
page.evaluate((key) => {
213+
const globalWindow = window as Window & Record<string, WatcherState | undefined>;
214+
return globalWindow[key] ?? null;
215+
}, WATCHER_KEY);
216+
217+
scenario(
218+
"Health checks (UI) · Check now keeps sibling account identities scoped",
219+
{},
220+
Effect.scoped(
221+
Effect.gen(function* () {
222+
const target = yield* Target;
223+
const browser = yield* Browser;
224+
const { client: makeClient } = yield* Api;
225+
const identity = yield* target.newIdentity();
226+
const client = yield* makeClient(api, identity);
227+
const aliceToken = `ak_${randomBytes(8).toString("hex")}`;
228+
const bobToken = `bk_${randomBytes(8).toString("hex")}`;
229+
const server = yield* serveIdentityApi([
230+
{ token: aliceToken, emails: [ALICE, ALICE_REFRESHED], login: "alice", delayMs: 150 },
231+
{ token: bobToken, emails: [BOB, BOB_REFRESHED], login: "bob" },
232+
]);
233+
const slug = newSlug("hc-scoped-refresh");
234+
const aliceName = ConnectionName.make("alice");
235+
const bobName = ConnectionName.make("bob");
236+
const aliceMarker = `scoped-refresh row alice ${randomBytes(4).toString("hex")}`;
237+
const bobMarker = `scoped-refresh row bob ${randomBytes(4).toString("hex")}`;
238+
239+
yield* Effect.ensuring(
240+
Effect.gen(function* () {
241+
yield* registerIdentityIntegration(client, slug, server.url);
242+
const operation = yield* getMeOperation(client, slug);
243+
yield* client.integrations.healthCheckSet({
244+
params: { slug },
245+
payload: { spec: { operation, identityField: "email" } },
246+
});
247+
248+
yield* client.connections.create({
249+
payload: {
250+
owner: "org",
251+
name: aliceName,
252+
integration: slug,
253+
template: TEMPLATE,
254+
value: aliceToken,
255+
description: aliceMarker,
256+
},
257+
});
258+
yield* client.connections.create({
259+
payload: {
260+
owner: "user",
261+
name: bobName,
262+
integration: slug,
263+
template: TEMPLATE,
264+
value: bobToken,
265+
identityLabel: BOB,
266+
description: bobMarker,
267+
},
268+
});
269+
270+
const aliceHealth = yield* client.connections.checkHealth({
271+
params: { owner: "org", integration: slug, name: aliceName },
272+
query: {},
273+
});
274+
expect(aliceHealth.identity, "Alice's saved verdict carries Alice").toBe(ALICE);
275+
const bobHealth = yield* client.connections.checkHealth({
276+
params: { owner: "user", integration: slug, name: bobName },
277+
query: {},
278+
});
279+
expect(bobHealth.identity, "Bob's saved verdict carries Bob").toBe(BOB);
280+
281+
yield* browser.session(identity, async ({ page, step }) => {
282+
await step("Open the integration accounts list with both identities", async () => {
283+
await page.goto(`/integrations/${slug}`, { waitUntil: "networkidle" });
284+
await page.getByRole("tab", { name: "Accounts" }).waitFor();
285+
await page.getByText("Workspace", { exact: true }).first().waitFor();
286+
await page.getByText("Personal", { exact: true }).first().waitFor();
287+
288+
const aliceRow = accountRow(page, aliceMarker);
289+
const bobRow = accountRow(page, bobMarker);
290+
await rowTitle(aliceRow).getByText(ALICE, { exact: true }).waitFor();
291+
await rowTitle(bobRow).getByText(BOB, { exact: true }).waitFor();
292+
expect(await readTitle(aliceRow), "Alice row starts with Alice's identity").toContain(
293+
ALICE,
294+
);
295+
expect(await readTitle(bobRow), "Bob row starts with Bob's identity").toContain(BOB);
296+
});
297+
298+
await step("Update Bob's saved health outside the visible row", async () => {
299+
const bobRow = accountRow(page, bobMarker);
300+
const refreshedBob = await Effect.runPromise(
301+
client.connections.checkHealth({
302+
params: { owner: "user", integration: slug, name: bobName },
303+
query: {},
304+
}),
305+
);
306+
expect(refreshedBob.identity, "Bob's persisted verdict changed offscreen").toBe(
307+
BOB_REFRESHED,
308+
);
309+
expect(await readTitle(bobRow), "Bob row still shows the cached identity").toContain(
310+
BOB,
311+
);
312+
expect(
313+
await readTitle(bobRow),
314+
"Bob row has not refetched the offscreen identity yet",
315+
).not.toContain(BOB_REFRESHED);
316+
});
317+
318+
await step("Check Alice and watch Bob's row for identity bleed", async () => {
319+
const aliceRow = accountRow(page, aliceMarker);
320+
const bobRow = accountRow(page, bobMarker);
321+
const connectionReads: string[] = [];
322+
page.on("request", (request) => {
323+
const url = new URL(request.url());
324+
if (url.pathname === "/api/connections") {
325+
connectionReads.push(`${url.pathname}${url.search}`);
326+
}
327+
});
328+
await installRowTitleWatcher(page, {
329+
marker: bobMarker,
330+
forbiddenTitle: BOB_REFRESHED,
331+
durationMs: 3_000,
332+
});
333+
334+
await aliceRow.hover();
335+
await aliceRow.locator("button").first().click();
336+
await page.getByRole("menuitem", { name: "Check now", exact: true }).click();
337+
await Promise.all([
338+
page.waitForFunction(
339+
(key) => {
340+
const globalWindow = window as Window &
341+
Record<string, WatcherState | undefined>;
342+
return globalWindow[key]?.done === true;
343+
},
344+
WATCHER_KEY,
345+
{ timeout: 5_000 },
346+
),
347+
page
348+
.getByText(`Healthy: ${ALICE_REFRESHED}`, { exact: true })
349+
.waitFor({ timeout: 30_000 }),
350+
]);
351+
352+
const watched = await readWatcherState(page);
353+
expect(watched, "row title watcher installed").not.toBeNull();
354+
if (watched === null) return;
355+
const sampleSummary = watched.samples
356+
.map((sample) => `${sample.atMs}ms=${sample.title}`)
357+
.join(" | ");
358+
expect(
359+
watched.violation,
360+
`Checking Alice refetched Bob's row and showed its offscreen identity. Samples: ${sampleSummary}`,
361+
).toBeNull();
362+
expect(await readTitle(bobRow), "Bob row ends with Bob's identity").toContain(BOB);
363+
expect(
364+
await readTitle(aliceRow),
365+
"Alice row picked up the refreshed identity",
366+
).toContain(ALICE_REFRESHED);
367+
expect(
368+
await readTitle(bobRow),
369+
"Bob row never ends as the forbidden refreshed Bob identity",
370+
).not.toContain(BOB_REFRESHED);
371+
const bobOwnerReads = connectionReads.filter((path) =>
372+
new URL(`http://executor.test${path}`).searchParams.has("owner")
373+
? new URL(`http://executor.test${path}`).searchParams.get("owner") === "user"
374+
: false,
375+
);
376+
expect(
377+
bobOwnerReads,
378+
`Checking Alice should not refresh Bob's owner-scoped list. Reads: ${connectionReads.join(
379+
" | ",
380+
)}`,
381+
).toEqual([]);
382+
});
383+
});
384+
}),
385+
Effect.gen(function* () {
386+
yield* client.connections
387+
.remove({ params: { owner: "org", integration: slug, name: aliceName } })
388+
.pipe(Effect.ignore);
389+
yield* client.connections
390+
.remove({ params: { owner: "user", integration: slug, name: bobName } })
391+
.pipe(Effect.ignore);
392+
yield* client.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore);
393+
}),
394+
);
395+
}),
396+
),
397+
);

0 commit comments

Comments
 (0)