Skip to content

Commit 1952ca8

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 d1aa3ea commit 1952ca8

13 files changed

Lines changed: 1088 additions & 87 deletions

File tree

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

Lines changed: 202 additions & 1 deletion
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

@@ -128,6 +131,47 @@ const registerIdentityIntegration = (client: Client, slug: IntegrationSlug, base
128131
},
129132
});
130133

134+
/** Like `serveIdentityApi`, but with a `revoke()` that flips the key off so a
135+
* saved connection's previously-good key stops working mid-session (the editor
136+
* scenario's healthy -> expired transition). */
137+
const serveMutableIdentityApi = (validToken: string) =>
138+
Effect.acquireRelease(
139+
Effect.callback<{
140+
readonly url: string;
141+
readonly revoke: () => void;
142+
readonly close: () => void;
143+
}>((resume) => {
144+
let live = true;
145+
const server = createServer((request, response) => {
146+
const authorized = live && request.headers["authorization"] === `Bearer ${validToken}`;
147+
if (request.method === "GET" && (request.url ?? "").startsWith("/me")) {
148+
response.writeHead(authorized ? 200 : 401, { "content-type": "application/json" });
149+
response.end(JSON.stringify(authorized ? { email: IDENTITY } : { error: "x" }));
150+
return;
151+
}
152+
response.writeHead(404, { "content-type": "application/json" });
153+
response.end(JSON.stringify({ error: "not_found" }));
154+
});
155+
server.listen(0, "127.0.0.1", () => {
156+
const address = server.address();
157+
const port = typeof address === "object" && address ? address.port : 0;
158+
resume(
159+
Effect.succeed({
160+
url: `http://127.0.0.1:${port}`,
161+
revoke: () => {
162+
live = false;
163+
},
164+
close: () => {
165+
server.close();
166+
server.closeAllConnections();
167+
},
168+
}),
169+
);
170+
});
171+
}),
172+
(server) => Effect.sync(server.close),
173+
);
174+
131175
/** The stored operation name for the GET probe (openapi prefixes it by tag),
132176
* discovered the same way the editor does: from the ranked candidate list. */
133177
const getMeOperation = (client: Client, slug: IntegrationSlug) =>
@@ -571,3 +615,160 @@ scenario(
571615
}),
572616
),
573617
);
618+
619+
// ===========================================================================
620+
// Edit sheet WITH identity (identity layer): pick the operation + identity field
621+
// by mouse, live-preview the response, then drive "Check now" on a saved
622+
// connection healthy -> expired once the upstream revokes the key.
623+
// ===========================================================================
624+
625+
scenario(
626+
"Health checks (UI) · edit sheet with identity: preview the response, then healthy then expired",
627+
{},
628+
Effect.scoped(
629+
Effect.gen(function* () {
630+
const target = yield* Target;
631+
const browser = yield* Browser;
632+
const { client: makeClient } = yield* Api;
633+
const identity = yield* target.newIdentity();
634+
const client = yield* makeClient(api, identity);
635+
const goodToken = `gk_${randomBytes(8).toString("hex")}`;
636+
const server = yield* serveMutableIdentityApi(goodToken);
637+
const slug = newSlug("hc-ui-id");
638+
const name = ConnectionName.make("main");
639+
640+
yield* Effect.ensuring(
641+
Effect.gen(function* () {
642+
yield* registerIdentityIntegration(client, slug, server.url);
643+
const operation = yield* getMeOperation(client, slug);
644+
yield* client.connections.create({
645+
payload: {
646+
owner: "org",
647+
name,
648+
integration: slug,
649+
template: TEMPLATE,
650+
value: goodToken,
651+
},
652+
});
653+
654+
yield* browser.session(identity, async ({ page, step }) => {
655+
const connections = page.locator("section").filter({
656+
has: page.getByRole("heading", { level: 3, name: "Connections" }),
657+
});
658+
const menuTrigger = connections.locator('button[aria-haspopup="menu"]');
659+
660+
await step("Open the integration's connections", async () => {
661+
await page.goto(`/integrations/${slug}`, { waitUntil: "networkidle" });
662+
await connections.getByText("main", { exact: true }).waitFor();
663+
await page.getByRole("heading", { level: 3, name: "Health check" }).waitFor();
664+
});
665+
666+
await step(
667+
"Pick the GET identity call and its email identity field by mouse",
668+
async () => {
669+
await page.getByRole("button", { name: "Set up" }).click();
670+
await clickComboboxOption(page, "health-check-operation", "getMe");
671+
await clickComboboxOption(page, "health-check-identity", "email");
672+
},
673+
);
674+
675+
await step("Live preview a pasted key: status, response, and identity", async () => {
676+
const sheet = page.getByRole("dialog");
677+
await page.locator("#health-check-preview-key").fill(goodToken);
678+
await sheet.getByRole("button", { name: "Preview", exact: true }).click();
679+
await sheet.getByText("Response", { exact: true }).waitFor({ timeout: 30_000 });
680+
await sheet.getByText("Resolves to:").waitFor();
681+
await sheet.getByText(IDENTITY).first().waitFor();
682+
});
683+
684+
await step("Save the health check", async () => {
685+
await page.getByRole("button", { name: "Save", exact: true }).click();
686+
await page.locator("#health-check-operation").waitFor({ state: "hidden" });
687+
});
688+
689+
await step("Check the live connection: healthy, and whose account it is", async () => {
690+
await menuTrigger.click();
691+
await page.getByRole("menuitem", { name: "Check now" }).click();
692+
await connections.getByText(IDENTITY).waitFor({ timeout: 30_000 });
693+
await connections.getByLabel("Status: Healthy").waitFor();
694+
});
695+
696+
await step("The upstream revokes the key: the connection reads expired", async () => {
697+
server.revoke();
698+
await menuTrigger.click();
699+
await page.getByRole("menuitem", { name: "Check now" }).click();
700+
await connections.getByText("Expired", { exact: true }).waitFor({ timeout: 30_000 });
701+
await connections.getByLabel("Status: Expired").waitFor();
702+
});
703+
});
704+
705+
const stored = yield* client.integrations.healthCheckGet({ params: { slug } });
706+
expect(stored).toEqual({ operation, identityField: "email" });
707+
}),
708+
Effect.gen(function* () {
709+
yield* client.connections
710+
.remove({ params: { owner: "org", integration: slug, name } })
711+
.pipe(Effect.ignore);
712+
yield* client.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore);
713+
}),
714+
);
715+
}),
716+
),
717+
);
718+
719+
// ===========================================================================
720+
// Add Connection, check configured WITH identity (identity layer): checking the
721+
// key derives the connection name from the probed identity.
722+
// ===========================================================================
723+
724+
scenario(
725+
"Health checks (UI) · Add Connection derives the connection name from the probed identity",
726+
{},
727+
Effect.scoped(
728+
Effect.gen(function* () {
729+
const target = yield* Target;
730+
const browser = yield* Browser;
731+
const { client: makeClient } = yield* Api;
732+
const identity = yield* target.newIdentity();
733+
const client = yield* makeClient(api, identity);
734+
const goodToken = `gk_${randomBytes(8).toString("hex")}`;
735+
const server = yield* serveIdentityApi(goodToken);
736+
const slug = newSlug("hc-ui-name");
737+
738+
yield* Effect.ensuring(
739+
Effect.gen(function* () {
740+
yield* registerIdentityIntegration(client, slug, server.url);
741+
const operation = yield* getMeOperation(client, slug);
742+
yield* client.integrations.healthCheckSet({
743+
params: { slug },
744+
payload: { spec: { operation, identityField: "email" } },
745+
});
746+
747+
yield* browser.session(identity, async ({ page, step }) => {
748+
const dialog = page.getByRole("dialog");
749+
750+
await step("Open the Add Connection modal", async () => {
751+
await page.goto(`/integrations/${slug}`, { waitUntil: "networkidle" });
752+
await page.getByRole("button", { name: "Add connection", exact: true }).click();
753+
await page.getByRole("heading", { name: /Add connection/ }).waitFor();
754+
});
755+
756+
await step("A valid key checks healthy and names the connection", async () => {
757+
await dialog.getByPlaceholder("paste the value / token").fill(goodToken);
758+
await dialog.getByRole("button", { name: "Check the key works" }).click();
759+
await dialog.getByText("Healthy").waitFor({ timeout: 30_000 });
760+
await page.waitForFunction(
761+
(expected) =>
762+
(document.querySelector("#connection-name") as HTMLInputElement | null)?.value ===
763+
expected,
764+
IDENTITY,
765+
{ timeout: 10_000 },
766+
);
767+
});
768+
});
769+
}),
770+
client.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore),
771+
);
772+
}),
773+
),
774+
);

0 commit comments

Comments
 (0)