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.
11import { randomBytes } from "node:crypto";
12import { createServer } from "node:http";
13
14import { expect } from "@effect/vitest";
15import { Effect } from "effect";
16import type { HttpApiClient } from "effect/unstable/httpapi";
17import { composePluginApi } from "@executor-js/api/server";
18import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
19import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared";
20
21import { scenario } from "../src/scenario";
22import { Api, Target } from "../src/services";
23import type { Identity, Target as TargetShape } from "../src/target";
24
25const api = composePluginApi([openApiHttpPlugin()] as const);
26type 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.
30const 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
51const 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
72const V1_TOOLS = ["legacy.legacyOp", "ping.getOperation"];
73const V2_TOOLS = ["ping.getOperation", "widgets.listWidgets"];
74
75/** A real 127.0.0.1 server whose served spec can be swapped mid-scenario. */
76const 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) ──────
109const cookieOf = (identity: Identity): string => identity.headers?.["cookie"] ?? "";
110
111const 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
128const 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. */
137const 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
149const apiKeyTemplate = [
150 {
151 slug: "apiKey",
152 type: "apiKey" as const,
153 headers: { authorization: ["Bearer ", { type: "variable" as const, name: "token" }] },
154 },
155];
156
157const 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
168const 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
173scenario(
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.
6import { randomBytes, randomUUID } from "node:crypto";
7
8import { expect } from "@effect/vitest";
9import { Effect } from "effect";
10import { composePluginApi } from "@executor-js/api/server";
11import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
12import {
13 AuthTemplateSlug,
14 ConnectionName,
15 IntegrationSlug,
16 ProviderItemId,
17} from "@executor-js/sdk/shared";
18
19import { scenario } from "../src/scenario";
20import { Api, Target } from "../src/services";
21
22const 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. */
26const 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
36scenario(
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);