Skip to content

Commit b90461f

Browse files
committed
feat: derive connection account info from the health-check probe
Layer account identity on top of the liveness probe. A health check can now name a response field whose value identifies the connected account, so the same probe that answers "is this alive?" also answers "whose account is this?". Core: HealthCheckSpec gains an optional identityField (a dot-path into the response body); HealthCheckResult carries the extracted identity plus a bounded responseSample of the actual returned fields; HealthCheckCandidate carries the operation's projected responseFields. New pure helpers: extractIdentity (resolve a dot-path, numeric segments index arrays), projectResponseFields (enumerate a response schema's scalar leaves breadth-first, merging every allOf/oneOf/anyOf branch so discriminated-union fields aren't dropped), and extractResponseFields (walk a real body for the live preview). OpenAPI backing extracts the identity on a healthy probe and projects each candidate's response fields. React: the editor gains a typed identity-field picker fed by those fields and a live preview (probe a pasted test key, see the real response plus what the identity resolves to); the account row labels itself with the probed identity; and the Add Connection "check the key works" flow gains the identity field (in the inline picker) and auto-fills the connection name from the probed identity. Covered by e2e: validating a key derives the identity; a saved connection's probe surfaces the account then drops it once expired; the identity picker surfaces a shallow scalar and a second-union-branch-only field across a discriminated union; the editor live preview and the Add Connection name-derivation drive the identity flow in the browser.
1 parent dc99124 commit b90461f

13 files changed

Lines changed: 1105 additions & 91 deletions

File tree

e2e/scenarios/health-checks-ui.test.ts

Lines changed: 215 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,14 +27,17 @@ import type { HttpApiClient } from "effect/unstable/httpapi";
2727
import type { Page } from "playwright";
2828
import { composePluginApi } from "@executor-js/api/server";
2929
import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
30-
import { IntegrationSlug } from "@executor-js/sdk/shared";
30+
import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared";
3131

3232
import { scenario } from "../src/scenario";
3333
import { Api, Browser, Target } from "../src/services";
3434

3535
const api = composePluginApi([openApiHttpPlugin()] as const);
3636
type Client = HttpApiClient.ForApi<typeof api>;
3737

38+
const TEMPLATE = AuthTemplateSlug.make("apiKey");
39+
const IDENTITY = "alice@example.com";
40+
3841
const newSlug = (prefix: string) =>
3942
IntegrationSlug.make(`${prefix}-${randomBytes(4).toString("hex")}`);
4043

@@ -85,9 +88,7 @@ const serveIdentityApi = (validToken: string) =>
8588
const authorized = request.headers["authorization"] === `Bearer ${validToken}`;
8689
if (request.method === "GET" && (request.url ?? "").startsWith("/me")) {
8790
response.writeHead(authorized ? 200 : 401, { "content-type": "application/json" });
88-
response.end(
89-
JSON.stringify(authorized ? { email: "alice@example.com" } : { error: "x" }),
90-
);
91+
response.end(JSON.stringify(authorized ? { email: IDENTITY } : { error: "x" }));
9192
return;
9293
}
9394
response.writeHead(404, { "content-type": "application/json" });
@@ -128,6 +129,57 @@ const registerIdentityIntegration = (client: Client, slug: IntegrationSlug, base
128129
},
129130
});
130131

132+
/** Like `serveIdentityApi`, but with a `revoke()` that flips the key off so a
133+
* saved connection's previously-good key stops working mid-session (the editor
134+
* scenario's healthy -> expired transition). */
135+
const serveMutableIdentityApi = (validToken: string) =>
136+
Effect.acquireRelease(
137+
Effect.callback<{
138+
readonly url: string;
139+
readonly revoke: () => void;
140+
readonly close: () => void;
141+
}>((resume) => {
142+
let live = true;
143+
const server = createServer((request, response) => {
144+
const authorized = live && request.headers["authorization"] === `Bearer ${validToken}`;
145+
if (request.method === "GET" && (request.url ?? "").startsWith("/me")) {
146+
response.writeHead(authorized ? 200 : 401, { "content-type": "application/json" });
147+
response.end(JSON.stringify(authorized ? { email: IDENTITY } : { error: "x" }));
148+
return;
149+
}
150+
response.writeHead(404, { "content-type": "application/json" });
151+
response.end(JSON.stringify({ error: "not_found" }));
152+
});
153+
server.listen(0, "127.0.0.1", () => {
154+
const address = server.address();
155+
const port = typeof address === "object" && address ? address.port : 0;
156+
resume(
157+
Effect.succeed({
158+
url: `http://127.0.0.1:${port}`,
159+
revoke: () => {
160+
live = false;
161+
},
162+
close: () => {
163+
server.close();
164+
server.closeAllConnections();
165+
},
166+
}),
167+
);
168+
});
169+
}),
170+
(server) => Effect.sync(server.close),
171+
);
172+
173+
/** The stored operation name for the GET probe (openapi prefixes it by tag),
174+
* discovered the same way the editor does: from the ranked candidate list. */
175+
const getMeOperation = (client: Client, slug: IntegrationSlug) =>
176+
Effect.gen(function* () {
177+
const candidates = yield* client.integrations.healthCheckCandidates({ params: { slug } });
178+
const getMe = candidates.find((candidate) => candidate.method === "get");
179+
if (!getMe) return yield* Effect.die("identity spec exposed no GET candidate");
180+
return getMe.operation;
181+
});
182+
131183
/** Select the combobox option whose visible text contains `optionText` by
132184
* keyboard (so it works inside a modal without a portaled-popup click). */
133185
const selectComboboxOption = async (page: Page, inputId: string, optionText: string) => {
@@ -430,3 +482,162 @@ scenario(
430482
}),
431483
),
432484
);
485+
486+
// ===========================================================================
487+
// 4. Edit sheet WITH identity: pick the operation + identity field, live-preview
488+
// the response, then drive "Check now" on a saved connection healthy ->
489+
// expired once the upstream revokes the key. (identity layer)
490+
// ===========================================================================
491+
492+
scenario(
493+
"Health checks (UI) · edit sheet with identity: preview the response, then healthy then expired",
494+
{},
495+
Effect.scoped(
496+
Effect.gen(function* () {
497+
const target = yield* Target;
498+
const browser = yield* Browser;
499+
const { client: makeClient } = yield* Api;
500+
const identity = yield* target.newIdentity();
501+
const client = yield* makeClient(api, identity);
502+
const goodToken = `gk_${randomBytes(8).toString("hex")}`;
503+
const server = yield* serveMutableIdentityApi(goodToken);
504+
const slug = newSlug("hc-ui-id");
505+
const name = ConnectionName.make("main");
506+
507+
yield* Effect.ensuring(
508+
Effect.gen(function* () {
509+
// Off camera: the integration and a saved connection holding the live
510+
// key. The health check is configured ON camera in the editor below.
511+
yield* registerIdentityIntegration(client, slug, server.url);
512+
const operation = yield* getMeOperation(client, slug);
513+
yield* client.connections.create({
514+
payload: {
515+
owner: "org",
516+
name,
517+
integration: slug,
518+
template: TEMPLATE,
519+
value: goodToken,
520+
},
521+
});
522+
523+
yield* browser.session(identity, async ({ page, step }) => {
524+
const connections = page.locator("section").filter({
525+
has: page.getByRole("heading", { level: 3, name: "Connections" }),
526+
});
527+
const menuTrigger = connections.locator('button[aria-haspopup="menu"]');
528+
529+
await step("Open the integration's connections", async () => {
530+
await page.goto(`/integrations/${slug}`, { waitUntil: "networkidle" });
531+
await connections.getByText("main", { exact: true }).waitFor();
532+
await page.getByRole("heading", { level: 3, name: "Health check" }).waitFor();
533+
});
534+
535+
await step("Pick the GET identity call and its email identity field", async () => {
536+
await page.getByRole("button", { name: "Set up" }).click();
537+
await selectComboboxOption(page, "health-check-operation", operation);
538+
await selectComboboxOption(page, "health-check-identity", "email");
539+
});
540+
541+
await step("Live preview a pasted key: status, response, and identity", async () => {
542+
const sheet = page.getByRole("dialog");
543+
await page.locator("#health-check-preview-key").fill(goodToken);
544+
await sheet.getByRole("button", { name: "Preview", exact: true }).click();
545+
await sheet.getByText("Response", { exact: true }).waitFor({ timeout: 30_000 });
546+
await sheet.getByText("Resolves to:").waitFor();
547+
await sheet.getByText(IDENTITY).first().waitFor();
548+
});
549+
550+
await step("Save the health check", async () => {
551+
await page.getByRole("button", { name: "Save", exact: true }).click();
552+
await page.locator("#health-check-operation").waitFor({ state: "hidden" });
553+
});
554+
555+
await step("Check the live connection: healthy, and whose account it is", async () => {
556+
await menuTrigger.click();
557+
await page.getByRole("menuitem", { name: "Check now" }).click();
558+
await connections.getByText(IDENTITY).waitFor({ timeout: 30_000 });
559+
await connections.getByLabel("Status: Healthy").waitFor();
560+
});
561+
562+
await step("The upstream revokes the key: the connection reads expired", async () => {
563+
server.revoke();
564+
await menuTrigger.click();
565+
await page.getByRole("menuitem", { name: "Check now" }).click();
566+
await connections.getByText("Expired", { exact: true }).waitFor({ timeout: 30_000 });
567+
await connections.getByLabel("Status: Expired").waitFor();
568+
});
569+
});
570+
571+
const stored = yield* client.integrations.healthCheckGet({ params: { slug } });
572+
expect(stored).toEqual({ operation, identityField: "email" });
573+
}),
574+
Effect.gen(function* () {
575+
yield* client.connections
576+
.remove({ params: { owner: "org", integration: slug, name } })
577+
.pipe(Effect.ignore);
578+
yield* client.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore);
579+
}),
580+
);
581+
}),
582+
),
583+
);
584+
585+
// ===========================================================================
586+
// 5. Add Connection, check configured WITH identity: checking the key derives
587+
// the connection name from the probed identity. (identity layer)
588+
// ===========================================================================
589+
590+
scenario(
591+
"Health checks (UI) · Add Connection derives the connection name from the probed identity",
592+
{},
593+
Effect.scoped(
594+
Effect.gen(function* () {
595+
const target = yield* Target;
596+
const browser = yield* Browser;
597+
const { client: makeClient } = yield* Api;
598+
const identity = yield* target.newIdentity();
599+
const client = yield* makeClient(api, identity);
600+
const goodToken = `gk_${randomBytes(8).toString("hex")}`;
601+
const server = yield* serveIdentityApi(goodToken);
602+
const slug = newSlug("hc-ui-name");
603+
604+
yield* Effect.ensuring(
605+
Effect.gen(function* () {
606+
// Configure a check WITH an identity field up front, so the Add
607+
// Connection modal probes against it (no inline picker needed).
608+
yield* registerIdentityIntegration(client, slug, server.url);
609+
const operation = yield* getMeOperation(client, slug);
610+
yield* client.integrations.healthCheckSet({
611+
params: { slug },
612+
payload: { spec: { operation, identityField: "email" } },
613+
});
614+
615+
yield* browser.session(identity, async ({ page, step }) => {
616+
const dialog = page.getByRole("dialog");
617+
618+
await step("Open the Add Connection modal", async () => {
619+
await page.goto(`/integrations/${slug}`, { waitUntil: "networkidle" });
620+
await page.getByRole("button", { name: "Add connection", exact: true }).click();
621+
await page.getByRole("heading", { name: /Add connection/ }).waitFor();
622+
});
623+
624+
await step("A valid key checks healthy and names the connection", async () => {
625+
await dialog.getByPlaceholder("paste the value / token").fill(goodToken);
626+
await dialog.getByRole("button", { name: "Check the key works" }).click();
627+
await dialog.getByText("Healthy").waitFor({ timeout: 30_000 });
628+
// The probed identity auto-fills the display name.
629+
await page.waitForFunction(
630+
(expected) =>
631+
(document.querySelector("#connection-name") as HTMLInputElement | null)?.value ===
632+
expected,
633+
IDENTITY,
634+
{ timeout: 10_000 },
635+
);
636+
});
637+
});
638+
}),
639+
client.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore),
640+
);
641+
}),
642+
),
643+
);

0 commit comments

Comments
 (0)