// Cross-target: connecting a SECOND account to the same integration must ADD a // distinct connection, never silently overwrite the first. This is the exact // data-loss bug that shipped — a human label like "Work Gmail" reaches // `oauth.start` as the client-derived "workGmail", the mint stored the // `connectionIdentifier` form, and the free-name guard compared the // un-re-normalized name case-sensitively, missed the existing row, and the // second connect re-minted (overwrote) the first account instead of resolving // to a suffixed name. // // The journey: one OpenAPI integration with a single OAuth method; run the // real authorization-code flow TWICE with `newConnection: true`, using a raw // human label the client would type (not an already-normalized name). The // first connect mints `workGmail`; the second must resolve to `workGmail2`, // and the typed connections API must show BOTH rows persisting. import { randomBytes } from "node:crypto"; import { createServer } from "node:http"; import { expect } from "@effect/vitest"; import { Effect } from "effect"; import { composePluginApi } from "@executor-js/api/server"; import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; import { AuthTemplateSlug, ConnectionName, IntegrationSlug, OAuthClientSlug, } from "@executor-js/sdk/shared"; import { serveOAuthTestServer } from "@executor-js/sdk/testing"; import { scenario } from "../src/scenario"; import { Api, Target } from "../src/services"; const api = composePluginApi([openApiHttpPlugin()] as const); const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`; /** A trivial upstream on 127.0.0.1: `GET /me` is 200 for any bearer. It exists * only to give the OpenAPI integration a `servers` base URL — the guarantee * under test is about connection identity, not the dispatched call. */ const serveUpstream = () => Effect.acquireRelease( Effect.callback<{ readonly url: string; readonly close: () => void }>((resume) => { const server = createServer((request, response) => { if (request.method === "GET" && (request.url ?? "").startsWith("/me")) { response.writeHead(200, { "content-type": "application/json" }); response.end(JSON.stringify({ ok: true })); return; } response.writeHead(404, { "content-type": "application/json" }); response.end(JSON.stringify({ error: "not_found" })); }); server.listen(0, "127.0.0.1", () => { const address = server.address(); const port = typeof address === "object" && address ? address.port : 0; resume( Effect.succeed({ url: `http://127.0.0.1:${port}`, close: () => { server.close(); server.closeAllConnections(); }, }), ); }); }), (server) => Effect.sync(server.close), ); const spec = ( baseUrl: string, oauth: { readonly authorizationEndpoint: string; readonly tokenEndpoint: string }, ): string => JSON.stringify({ openapi: "3.0.3", info: { title: "Mail API", version: "1.0.0" }, servers: [{ url: baseUrl }], paths: { "/me": { get: { operationId: "readMe", summary: "Read the authenticated profile", security: [{ oauth: ["email"] }], responses: { "200": { description: "ok" } }, }, }, }, components: { securitySchemes: { oauth: { type: "oauth2", flows: { authorizationCode: { authorizationUrl: oauth.authorizationEndpoint, tokenUrl: oauth.tokenEndpoint, scopes: { email: "Read email" }, }, }, }, }, }, }); scenario( "Connections · a second OAuth connect adds a distinct account instead of overwriting the first", {}, Effect.scoped( Effect.gen(function* () { const target = yield* Target; const { client: makeClient } = yield* Api; const identity = yield* target.newIdentity(); const client = yield* makeClient(api, identity); const upstream = yield* serveUpstream(); const oauth = yield* serveOAuthTestServer({ scopes: ["email"] }); const slug = IntegrationSlug.make(unique("oauth2nd")); const clientSlug = OAuthClientSlug.make(unique("oauth2ndc")); // Run the whole authorization-code flow end to end for a requested name. // The mint normalizes the name; the free-name guard must compare against // that same normalized form, so a repeat call resolves to a suffix. const connect = (requestedName: string) => Effect.gen(function* () { const started = yield* client.oauth.start({ payload: { client: clientSlug, clientOwner: "org", owner: "org", name: ConnectionName.make(requestedName), integration: slug, template: AuthTemplateSlug.make("oauth"), newConnection: true, }, }); expect(started.status, "oauth.start redirects to the authorization server").toBe( "redirect", ); if (started.status !== "redirect") return yield* Effect.die("no redirect"); // Drive the test IdP's consent by hand (authorize -> login -> code). // Plain fetch with manual redirects is the e2e-proven path: the // Effect HttpClient's manual-redirect layer is overridden by the // scenario runtime and silently follows the hop to the login page. const code = yield* Effect.promise(async () => { const authorize = await fetch(started.authorizationUrl, { redirect: "manual" }); const loginUrl = authorize.headers.get("location"); if (!loginUrl) throw new Error(`authorize did not redirect: ${authorize.status}`); const login = await fetch(loginUrl, { method: "POST", headers: { authorization: `Basic ${Buffer.from("alice:password").toString("base64")}`, }, redirect: "manual", }); const callbackUrl = login.headers.get("location"); if (!callbackUrl) throw new Error(`login did not redirect: ${login.status}`); const minted = new URL(callbackUrl).searchParams.get("code"); if (!minted) throw new Error("callback carried no authorization code"); return minted; }); // `complete` returns the minted connection projection directly. const connection = yield* client.oauth.complete({ payload: { state: started.state, code }, }); return String(connection.name); }); yield* Effect.ensuring( Effect.gen(function* () { yield* client.openapi.addSpec({ payload: { spec: { kind: "blob", value: spec(upstream.url, oauth) }, slug, baseUrl: upstream.url, authenticationTemplate: [ { slug: "oauth", kind: "oauth2", authorizationUrl: oauth.authorizationEndpoint, tokenUrl: oauth.tokenEndpoint, scopes: ["email"], }, ], }, }); yield* client.oauth.createClient({ payload: { owner: "org", slug: clientSlug, grant: "authorization_code", authorizationUrl: oauth.authorizationEndpoint, tokenUrl: oauth.tokenEndpoint, clientId: "test-client", clientSecret: "test-secret", originIntegration: slug, }, }); // A raw human label the client would type — NOT an already-normalized // name. Before the fix, the second connect overwrote the first row. const first = yield* connect("Work Gmail"); const second = yield* connect("Work Gmail"); expect(first, "the first account is stored under the normalized name").toBe("workGmail"); expect(second, "the second account resolves to a distinct suffixed name").toBe( "workGmail2", ); // The typed API is the black-box proof: both rows persist. Selfhost // shares one workspace, so assert "contains mine", not exact length. const connections = yield* client.connections.list({ query: { integration: slug } }); const names = connections.map((connection) => String(connection.name)); expect(names, "both OAuth accounts persist as distinct connections").toEqual( expect.arrayContaining(["workGmail", "workGmail2"]), ); }), // Best-effort cleanup even on failure: drop both connections, then the // integration — a mid-test failure must not leak state into selfhost. Effect.gen(function* () { for (const name of ["workGmail", "workGmail2"]) { yield* client.connections .remove({ params: { owner: "org", integration: slug, name: ConnectionName.make(name), }, }) .pipe(Effect.ignore); } yield* client.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore); }), ); }), ), );