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.
Unified · Split
| @@ -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 | +); | |
| @@ -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 | +); | |