History

Cover metadata editing and multi-user spec convergence with e2e scenarios

Two behaviors PR #997 introduced had no watchable coverage: - metadata-editing (cross-target): an integration's name and description, and a connection's description, are editable after the fact and read back — the write half that openapi-update-spec's survives-a-refresh assertion assumes. - spec-update-convergence (cloud, real multi-user org): when one member refreshes a shared integration's spec, a co-worker's OWN connection converges to the new tool catalog on the co-worker's next read, even though the editor can never write the co-worker's rows. Proves the lazy syncStaleConnectionTools path end-to-end.

Rhys Sullivan committed 5 months ago

Commit c888e901 parent d032f652 files · +365 −0Browse files

Unified · Split

2 files
  1. e2e/cloud/spec-update-convergence.test.ts +245 −0
  2. e2e/scenarios/metadata-editing.test.ts +120 −0
e2e/cloud/spec-update-convergence.test.ts+245−0
@@ -0,0 +1,245 @@
1+// Cloud-only (needs real multi-user organizations): when one member refreshes
2+// a shared integration's spec, a DIFFERENT member's OWN connection converges to
3+// the new tool catalog on that member's next read — not just the editor's.
4+//
5+// This is the lazy-convergence design. Tools are stored per connection. The
6+// editor stamps the integration's `tools_revised_at`, but the owner policy lets
7+// them rebuild catalogs only in their own partition — they cannot write a
8+// co-worker's personal connection rows. So each member's connection carries a
9+// `tools_synced_at` and rebuilds itself the next time THAT member lists tools.
10+// Without it, a colleague keeps calling tools that the API no longer serves.
11+import { randomBytes } from "node:crypto";
12+import { createServer } from "node:http";
13+
14+import { expect } from "@effect/vitest";
15+import { Effect } from "effect";
16+import type { HttpApiClient } from "effect/unstable/httpapi";
17+import { composePluginApi } from "@executor-js/api/server";
18+import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
19+import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared";
20+
21+import { scenario } from "../src/scenario";
22+import { Api, Target } from "../src/services";
23+import type { Identity, Target as TargetShape } from "../src/target";
24+
25+const api = composePluginApi([openApiHttpPlugin()] as const);
26+type Client = HttpApiClient.ForApi<typeof api>;
27+
28+// Same evolving spec the single-user openapi-update-spec scenario uses, so the
29+// expected tool names below are already an independently-verified contract.
30+const specV1 = JSON.stringify({
31+ openapi: "3.0.3",
32+ info: { title: "Evolving API", version: "1.0.0" },
33+ paths: {
34+ "/ping": {
35+ get: {
36+ operationId: "ping",
37+ summary: "Return a pong",
38+ responses: { "200": { description: "pong" } },
39+ },
40+ },
41+ "/legacy": {
42+ get: {
43+ operationId: "legacyOp",
44+ summary: "Soon to be removed",
45+ responses: { "200": { description: "ok" } },
46+ },
47+ },
48+ },
49+});
50+
51+const specV2 = JSON.stringify({
52+ openapi: "3.0.3",
53+ info: { title: "Evolving API", version: "2.0.0" },
54+ paths: {
55+ "/ping": {
56+ get: {
57+ operationId: "ping",
58+ summary: "Return a pong",
59+ responses: { "200": { description: "pong" } },
60+ },
61+ },
62+ "/widgets": {
63+ get: {
64+ operationId: "listWidgets",
65+ summary: "List widgets",
66+ responses: { "200": { description: "widgets" } },
67+ },
68+ },
69+ },
70+});
71+
72+const V1_TOOLS = ["legacy.legacyOp", "ping.getOperation"];
73+const V2_TOOLS = ["ping.getOperation", "widgets.listWidgets"];
74+
75+/** A real 127.0.0.1 server whose served spec can be swapped mid-scenario. */
76+const serveMutableSpec = (initial: string) =>
77+ Effect.acquireRelease(
78+ Effect.callback<{
79+ readonly url: string;
80+ readonly setBody: (body: string) => void;
81+ readonly close: () => void;
82+ }>((resume) => {
83+ let body = initial;
84+ const server = createServer((_request, response) => {
85+ response.writeHead(200, { "content-type": "application/json" });
86+ response.end(body);
87+ });
88+ server.listen(0, "127.0.0.1", () => {
89+ const address = server.address();
90+ const port = typeof address === "object" && address ? address.port : 0;
91+ resume(
92+ Effect.succeed({
93+ url: `http://127.0.0.1:${port}/spec.json`,
94+ setBody: (next: string) => {
95+ body = next;
96+ },
97+ close: () => {
98+ server.close();
99+ server.closeAllConnections();
100+ },
101+ }),
102+ );
103+ });
104+ }),
105+ (server) => Effect.sync(server.close),
106+ );
107+
108+// ── Session plumbing over the real auth endpoints (mirrors the web app) ──────
109+const cookieOf = (identity: Identity): string => identity.headers?.["cookie"] ?? "";
110+
111+const postJson = (target: TargetShape, path: string, identity: Identity, body: unknown) =>
112+ Effect.promise(async () => {
113+ const response = await fetch(new URL(path, target.baseUrl), {
114+ method: "POST",
115+ headers: {
116+ "content-type": "application/json",
117+ origin: new URL(target.baseUrl).origin,
118+ cookie: cookieOf(identity),
119+ },
120+ body: JSON.stringify(body),
121+ });
122+ if (!response.ok) {
123+ throw new Error(`${path} failed (${response.status}): ${await response.text()}`);
124+ }
125+ return response;
126+ });
127+
128+const withRefreshedSession = (identity: Identity, response: Response): Identity => {
129+ const refreshed = (response.headers.getSetCookie?.() ?? [])
130+ .find((header) => header.startsWith("wos-session="))
131+ ?.split(";")[0];
132+ if (!refreshed) throw new Error("response did not refresh the session cookie");
133+ return { ...identity, headers: { cookie: refreshed } };
134+};
135+
136+/** Invite `member` into `admin`'s org and accept — the real invite flow. */
137+const joinOrg = (target: TargetShape, admin: Identity, member: Identity) =>
138+ Effect.gen(function* () {
139+ const inviteResponse = yield* postJson(target, "/api/account/members/invite", admin, {
140+ email: member.credentials?.email,
141+ });
142+ const invitation = (yield* Effect.promise(() => inviteResponse.json())) as { id: string };
143+ const acceptResponse = yield* postJson(target, "/api/auth/accept-invitation", member, {
144+ invitationId: invitation.id,
145+ });
146+ return withRefreshedSession(member, acceptResponse);
147+ });
148+
149+const apiKeyTemplate = [
150+ {
151+ slug: "apiKey",
152+ type: "apiKey" as const,
153+ headers: { authorization: ["Bearer ", { type: "variable" as const, name: "token" }] },
154+ },
155+];
156+
157+const personalConnection = (client: Client, integration: IntegrationSlug, name: ConnectionName) =>
158+ client.connections.create({
159+ payload: {
160+ owner: "user",
161+ name,
162+ integration,
163+ template: AuthTemplateSlug.make("apiKey"),
164+ value: `tok-${randomBytes(8).toString("hex")}`,
165+ },
166+ });
167+
168+const ownToolNames = (client: Client, integration: IntegrationSlug) =>
169+ Effect.map(client.tools.list({ query: { integration, owner: "user" } }), (tools) =>
170+ tools.map((tool) => tool.name).sort(),
171+ );
172+
173+scenario(
174+ "Convergence · a spec refresh reaches a co-worker's own connection on their next read",
175+ {},
176+ Effect.scoped(
177+ Effect.gen(function* () {
178+ const target = yield* Target;
179+ const { client } = yield* Api;
180+
181+ const admin = yield* target.newIdentity();
182+ const invitee = yield* target.newIdentity({ org: false });
183+ const colleague = yield* joinOrg(target, admin, invitee);
184+
185+ const adminClient = yield* client(api, admin);
186+ const colleagueClient = yield* client(api, colleague);
187+
188+ const slug = IntegrationSlug.make(`converge-${randomBytes(4).toString("hex")}`);
189+ const adminConn = ConnectionName.make(`admin${randomBytes(3).toString("hex")}`);
190+ const colleagueConn = ConnectionName.make(`peer${randomBytes(3).toString("hex")}`);
191+ const specServer = yield* serveMutableSpec(specV1);
192+
193+ yield* Effect.ensuring(
194+ Effect.gen(function* () {
195+ // The admin registers a SHARED (org) integration from the live spec.
196+ yield* adminClient.openapi.addSpec({
197+ payload: {
198+ spec: { kind: "url", url: specServer.url },
199+ slug,
200+ baseUrl: "http://127.0.0.1:59999", // tools are never invoked here
201+ authenticationTemplate: apiKeyTemplate,
202+ },
203+ });
204+
205+ // Each member binds their OWN personal connection to it.
206+ yield* personalConnection(adminClient, slug, adminConn);
207+ yield* personalConnection(colleagueClient, slug, colleagueConn);
208+
209+ // Both see the v1 catalog on their own connection to start.
210+ expect(yield* ownToolNames(adminClient, slug), "admin starts on v1").toEqual(V1_TOOLS);
211+ expect(yield* ownToolNames(colleagueClient, slug), "the co-worker starts on v1").toEqual(
212+ V1_TOOLS,
213+ );
214+
215+ // The upstream API ships v2; the admin refreshes the shared spec.
216+ specServer.setBody(specV2);
217+ const updated = yield* adminClient.openapi.updateSpec({ params: { slug }, payload: {} });
218+ expect(updated.addedTools, "the diff names the new tool").toEqual([
219+ "widgets.listWidgets",
220+ ]);
221+
222+ // The editor's own connection follows immediately.
223+ expect(yield* ownToolNames(adminClient, slug), "admin converged to v2").toEqual(V2_TOOLS);
224+
225+ // The co-worker did nothing but read — yet their personal connection
226+ // has converged to v2, even though the admin could never write the
227+ // co-worker's rows. This is the lazy sync on the colleague's read.
228+ expect(
229+ yield* ownToolNames(colleagueClient, slug),
230+ "the co-worker converged to v2 on their next read",
231+ ).toEqual(V2_TOOLS);
232+ }),
233+ Effect.gen(function* () {
234+ yield* colleagueClient.connections
235+ .remove({ params: { owner: "user", integration: slug, name: colleagueConn } })
236+ .pipe(Effect.ignore);
237+ yield* adminClient.connections
238+ .remove({ params: { owner: "user", integration: slug, name: adminConn } })
239+ .pipe(Effect.ignore);
240+ yield* adminClient.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore);
241+ }),
242+ );
243+ }),
244+ ),
245+);
e2e/scenarios/metadata-editing.test.ts+120−0
@@ -0,0 +1,120 @@
1+// Cross-target: the curate-your-catalog promise. An integration's display
2+// name and description, and a connection's description, are user-editable
3+// metadata — set them after the fact and they persist and read back. This is
4+// the write half of the descriptions feature; openapi-update-spec covers the
5+// half where a curated description SURVIVES a spec refresh.
6+import { randomBytes, randomUUID } from "node:crypto";
7+
8+import { expect } from "@effect/vitest";
9+import { Effect } from "effect";
10+import { composePluginApi } from "@executor-js/api/server";
11+import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
12+import {
13+ AuthTemplateSlug,
14+ ConnectionName,
15+ IntegrationSlug,
16+ ProviderItemId,
17+} from "@executor-js/sdk/shared";
18+
19+import { scenario } from "../src/scenario";
20+import { Api, Target } from "../src/services";
21+
22+const api = composePluginApi([openApiHttpPlugin()] as const);
23+
24+/** Minimal apiKey-authenticated spec — a connection can bind to it; the single
25+ * operation is never invoked here. */
26+const pingSpec = JSON.stringify({
27+ openapi: "3.0.3",
28+ info: { title: "Ping API", version: "1.0.0" },
29+ paths: {
30+ "/ping": {
31+ get: { operationId: "ping", summary: "Ping", responses: { "200": { description: "pong" } } },
32+ },
33+ },
34+});
35+
36+scenario(
37+ "Metadata · an integration's name/description and a connection's description are editable",
38+ {},
39+ Effect.gen(function* () {
40+ const target = yield* Target;
41+ const { client } = yield* Api;
42+ const apiClient = yield* client(api, yield* target.newIdentity());
43+
44+ const slug = IntegrationSlug.make(`meta-edit-${randomBytes(4).toString("hex")}`);
45+ const connectionName = ConnectionName.make(`conn${randomBytes(4).toString("hex")}`);
46+
47+ yield* Effect.ensuring(
48+ Effect.gen(function* () {
49+ yield* apiClient.openapi.addSpec({
50+ payload: {
51+ spec: { kind: "blob", value: pingSpec },
52+ slug,
53+ baseUrl: "http://127.0.0.1:59999", // never contacted
54+ description: "Auto-derived at add time.",
55+ authenticationTemplate: [
56+ {
57+ slug: "apiKey",
58+ type: "apiKey",
59+ headers: { authorization: ["Bearer ", { type: "variable", name: "token" }] },
60+ },
61+ ],
62+ },
63+ });
64+
65+ // The add-time description is what the user first sees.
66+ const initial = yield* apiClient.integrations.get({ params: { slug } });
67+ expect(initial.description, "the add-time description is stored").toBe(
68+ "Auto-derived at add time.",
69+ );
70+
71+ // Rename and re-describe the integration — the curate step.
72+ const renamed = yield* apiClient.integrations.update({
73+ params: { slug },
74+ payload: { name: "Acme Ping", description: "Hand-curated for the team." },
75+ });
76+ expect(renamed.name, "the update response carries the new name").toBe("Acme Ping");
77+ expect(renamed.description, "the update response carries the new description").toBe(
78+ "Hand-curated for the team.",
79+ );
80+
81+ // It persists for the next reader, not just in the response.
82+ const reread = yield* apiClient.integrations.get({ params: { slug } });
83+ expect(reread.name, "the name persisted").toBe("Acme Ping");
84+ expect(reread.description, "the description persisted").toBe("Hand-curated for the team.");
85+
86+ // A connection carries its own editable description.
87+ const providers = yield* apiClient.providers.list();
88+ yield* apiClient.connections.create({
89+ payload: {
90+ owner: "org",
91+ name: connectionName,
92+ integration: slug,
93+ template: AuthTemplateSlug.make("apiKey"),
94+ from: { provider: providers[0]!, id: ProviderItemId.make(randomUUID()) },
95+ },
96+ });
97+ const describedConnection = yield* apiClient.connections.update({
98+ params: { owner: "org", integration: slug, name: connectionName },
99+ payload: { description: "Prod key, rotates quarterly." },
100+ });
101+ expect(describedConnection.description, "the connection description was set").toBe(
102+ "Prod key, rotates quarterly.",
103+ );
104+
105+ const connectionReread = yield* apiClient.connections.get({
106+ params: { owner: "org", integration: slug, name: connectionName },
107+ });
108+ expect(connectionReread.description, "the connection description persisted").toBe(
109+ "Prod key, rotates quarterly.",
110+ );
111+ }),
112+ Effect.gen(function* () {
113+ yield* apiClient.connections
114+ .remove({ params: { owner: "org", integration: slug, name: connectionName } })
115+ .pipe(Effect.ignore);
116+ yield* apiClient.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore);
117+ }),
118+ );
119+ }),
120+);