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 | ); | ||