import { describe, expect, it } from "@effect/vitest"; import { Effect, Exit } from "effect"; import { AuthTemplateSlug, ConnectionName, IntegrationSlug, OAuthClientSlug, ProviderItemId, ProviderKey, ToolName, } from "./ids"; import { definePlugin } from "./plugin"; import { makeTestExecutor } from "./test-config"; import { serveOAuthTestServer } from "./testing/oauth-test-server"; // A plugin's `removeConnection` runs INSIDE core's removal transaction, which is // what makes its database work atomic with the row deletions. The same property // makes anything reaching outside the database unsafe there: revoking a token at // the provider's API cannot be rolled back with the transaction, so an abort // leaves the connection restored and the token already dead. // // `ctx.afterCommit` is the way out, and these pin both directions of its // contract — it runs when the removal is durable, and it is discarded when the // removal is not. const INTEG = IntegrationSlug.make("vercel"); const TEMPLATE = AuthTemplateSlug.make("apiKey"); const revokingPlugin = (revoked: string[]) => definePlugin(() => { const store = new Map(); return { id: "demo" as const, credentialProviders: [ { key: ProviderKey.make("memory"), writable: true as const, get: (id: ProviderItemId) => Effect.sync(() => store.get(String(id)) ?? null), set: (id: ProviderItemId, value: string) => Effect.sync(() => { store.set(String(id), value); }), delete: (id: ProviderItemId) => Effect.sync(() => { store.delete(String(id)); }), }, ], storage: () => ({}), resolveTools: () => Effect.succeed({ tools: [{ name: ToolName.make("deploy"), description: "deploy" }] }), describeAuthMethods: () => [ { id: "oauth", label: "OAuth", kind: "oauth" as const, template: String(TEMPLATE), oauth: { scopes: [] }, }, ], invokeTool: ({ toolRow }) => Effect.succeed({ ran: toolRow.name }), /** Stands in for "revoke the token at the provider's API" — the archetypal * irreversible, outside-the-database cleanup. */ removeConnection: ({ ctx, connection }) => ctx.afterCommit( Effect.sync(() => { revoked.push(String(connection.name)); }), ), extension: (ctx) => ({ seed: () => ctx.core.integrations.register({ slug: INTEG, description: "Vercel", config: {} }), inTransaction: (effect: Effect.Effect) => ctx.transaction(effect), createThenRollback: () => ctx.transaction( Effect.gen(function* () { yield* ctx.connections.create({ ...REF, template: TEMPLATE, value: "rolled-back-secret", }); return yield* Effect.fail("rollback" as const); }), ), createInTransaction: () => ctx.transaction( ctx.connections.create({ ...REF, template: TEMPLATE, value: "committed-secret", }), ), credentialValues: () => Effect.sync(() => [...store.values()]), resolveValue: () => ctx.connections.resolveValue(REF), }), }; })(); const setup = (revoked: string[]) => makeTestExecutor({ plugins: [revokingPlugin(revoked)] as const }).pipe( Effect.tap((executor) => executor.demo.seed()), ); const REF = { owner: "org", integration: INTEG, name: ConnectionName.make("main"), } as const; describe("ctx.afterCommit inside a lifecycle hook", () => { it.effect("runs the deferred cleanup once the removal is durable", () => Effect.gen(function* () { const revoked: string[] = []; const executor = yield* setup(revoked); yield* executor.connections.create({ ...REF, template: TEMPLATE, value: "secret-token" }); yield* executor.connections.remove(REF); // Deferring must not mean dropping: an ordinary removal still revokes. expect(revoked).toEqual(["main"]); }), ); it.effect("discards the deferred cleanup when the removal rolls back", () => Effect.gen(function* () { const revoked: string[] = []; const executor = yield* setup(revoked); yield* executor.connections.create({ ...REF, template: TEMPLATE, value: "secret-token" }); const outcome = yield* Effect.exit( executor.demo.inTransaction( Effect.gen(function* () { yield* executor.connections.remove(REF); return yield* Effect.fail("rollback" as const); }), ), ); expect(Exit.isFailure(outcome)).toBe(true); // The connection survived, so revoking its token would have destroyed a // live credential with nothing left to undo it. const stillThere = yield* executor.connections.get(REF); expect(String(stillThere?.name)).toBe("main"); expect(revoked).toEqual([]); }), ); it.effect("discards required credential writes when an outer transaction rolls back", () => Effect.gen(function* () { const executor = yield* setup([]); const outcome = yield* Effect.exit(executor.demo.createThenRollback()); expect(Exit.isFailure(outcome)).toBe(true); expect(yield* executor.connections.get(REF)).toBeNull(); expect(yield* executor.demo.credentialValues()).toEqual([]); }), ); it.effect("finishes required credential writes before a committed outer call returns", () => Effect.gen(function* () { const executor = yield* setup([]); yield* executor.demo.createInTransaction(); expect(yield* executor.connections.get(REF)).not.toBeNull(); expect(yield* executor.demo.credentialValues()).toEqual(["committed-secret"]); }), ); it.effect( "discards new and replacement OAuth-client secrets when an outer transaction rolls back", () => Effect.gen(function* () { const executor = yield* setup([]); const slug = OAuthClientSlug.make("transactional-client"); const client = (clientId: string, clientSecret: string) => ({ owner: "org" as const, slug, authorizationUrl: "https://example.test/authorize", tokenUrl: "https://example.test/token", grant: "client_credentials" as const, clientId, clientSecret, }); const newResult = yield* Effect.exit( executor.demo.inTransaction( executor.oauth .createClient(client("new-id", "new-secret")) .pipe(Effect.andThen(Effect.fail("rollback" as const))), ), ); expect(Exit.isFailure(newResult)).toBe(true); expect(yield* executor.oauth.listClients()).toEqual([]); expect(yield* executor.demo.credentialValues()).toEqual([]); yield* executor.oauth.createClient(client("original-id", "original-secret")); const before = yield* executor.demo.credentialValues(); const replacementResult = yield* Effect.exit( executor.demo.inTransaction( executor.oauth .createClient(client("replacement-id", "replacement-secret")) .pipe(Effect.andThen(Effect.fail("rollback" as const))), ), ); expect(Exit.isFailure(replacementResult)).toBe(true); expect(yield* executor.oauth.listClients()).toEqual([ expect.objectContaining({ clientId: "original-id" }), ]); expect(yield* executor.demo.credentialValues()).toEqual(before); }), ); it.effect( "discards new and replacement OAuth-connection tokens when an outer transaction rolls back", () => Effect.gen(function* () { const server = yield* serveOAuthTestServer({ clients: { "transaction-client": "transaction-secret" }, }); const executor = yield* setup([]); const slug = OAuthClientSlug.make("transaction-connection-client"); yield* executor.oauth.createClient({ owner: "org", slug, authorizationUrl: server.authorizationEndpoint, tokenUrl: server.tokenEndpoint, grant: "client_credentials", clientId: "transaction-client", clientSecret: "transaction-secret", }); const start = executor.oauth.start({ ...REF, client: slug, clientOwner: "org" as const, template: TEMPLATE, }); const beforeNew = yield* executor.demo.credentialValues(); const newResult = yield* Effect.exit( executor.demo.inTransaction(start.pipe(Effect.andThen(Effect.fail("rollback" as const)))), ); expect(Exit.isFailure(newResult)).toBe(true); expect(yield* executor.connections.get(REF)).toBeNull(); expect(yield* executor.demo.credentialValues()).toEqual(beforeNew); yield* start; const originalValue = yield* executor.demo.resolveValue(); const beforeReplacement = yield* executor.demo.credentialValues(); const replacementResult = yield* Effect.exit( executor.demo.inTransaction(start.pipe(Effect.andThen(Effect.fail("rollback" as const)))), ); expect(Exit.isFailure(replacementResult)).toBe(true); expect(yield* executor.demo.resolveValue()).toBe(originalValue); expect(yield* executor.demo.credentialValues()).toEqual(beforeReplacement); }).pipe(Effect.scoped), ); });