import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "@effect/vitest"; import { Effect } from "effect"; import { OAuthClientSlug, ToolAddress } from "./ids"; import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config"; // removeClient permanently deletes an owner-scoped oauth_client row, keyed by // (owner, slug). The owner policy on `oauth_client` prevents removing another // subject's user app. The op is idempotent and never cascades into connections. const plugins = [memoryCredentialsPlugin()] as const; const ORG_CLIENT = OAuthClientSlug.make("acme-org"); const USER_CLIENT = OAuthClientSlug.make("acme-user"); describe("oauth.removeClient", () => { it.effect("removes a client so it no longer lists", () => Effect.scoped( Effect.gen(function* () { const { executor } = yield* makeTestWorkspaceHarness({ plugins }); yield* executor.oauth.createClient({ owner: "user", slug: USER_CLIENT, authorizationUrl: "https://acme.test/authorize", tokenUrl: "https://acme.test/token", grant: "authorization_code", clientId: "user-client-id", clientSecret: "user-secret", }); const before = yield* executor.oauth.listClients(); expect(before.map((client) => String(client.slug))).toContain(String(USER_CLIENT)); yield* executor.oauth.removeClient("user", USER_CLIENT); const after = yield* executor.oauth.listClients(); expect(after.map((client) => String(client.slug))).not.toContain(String(USER_CLIENT)); }), ), ); it.effect("is idempotent — removing a non-existent client succeeds", () => Effect.scoped( Effect.gen(function* () { const { executor } = yield* makeTestWorkspaceHarness({ plugins }); // No client was ever created; removing it must not error. yield* executor.oauth.removeClient("user", OAuthClientSlug.make("never-existed")); const clients = yield* executor.oauth.listClients(); expect(clients).toEqual([]); }), ), ); it.effect("removing an org client leaves a user client intact (and vice versa)", () => Effect.scoped( Effect.gen(function* () { const { executor } = yield* makeTestWorkspaceHarness({ plugins }); yield* executor.oauth.createClient({ owner: "org", slug: ORG_CLIENT, authorizationUrl: "https://acme.test/authorize", tokenUrl: "https://acme.test/token", grant: "authorization_code", clientId: "org-client-id", clientSecret: "org-secret", }); yield* executor.oauth.createClient({ owner: "user", slug: USER_CLIENT, authorizationUrl: "https://byo.test/authorize", tokenUrl: "https://byo.test/token", grant: "client_credentials", clientId: "user-client-id", clientSecret: "user-secret", }); // Removing the org client leaves the user client untouched. yield* executor.oauth.removeClient("org", ORG_CLIENT); const afterOrg = yield* executor.oauth.listClients(); expect(afterOrg.map((client) => String(client.slug)).sort()).toEqual([String(USER_CLIENT)]); // Removing the remaining user client empties the list. yield* executor.oauth.removeClient("user", USER_CLIENT); const afterUser = yield* executor.oauth.listClients(); expect(afterUser).toEqual([]); }), ), ); it.effect("one subject cannot remove another subject's user client", () => Effect.scoped( Effect.gen(function* () { // Two executors bind to different subjects in the same tenant against a // shared on-disk database, so each owns its own `owner:"user"` rows. const dataDir = mkdtempSync(join(tmpdir(), "oauth-remove-client-")); const tenant = "shared-tenant"; const a = yield* makeTestWorkspaceHarness({ plugins, tenant, subject: "subject-a", dataDir, }); yield* a.executor.oauth.createClient({ owner: "user", slug: OAuthClientSlug.make("a-only"), authorizationUrl: "https://a.test/authorize", tokenUrl: "https://a.test/token", grant: "authorization_code", clientId: "a-client-id", clientSecret: "a-secret", }); const b = yield* makeTestWorkspaceHarness({ plugins, tenant, subject: "subject-b", dataDir, }); // B attempts to remove A's user client. The owner policy scopes the // delete to B's own subject rows, so A's client is untouched. The op // succeeds (idempotent no-op) but must not delete across subjects. yield* b.executor.oauth.removeClient("user", OAuthClientSlug.make("a-only")); const clientsA = yield* a.executor.oauth.listClients(); expect(clientsA.map((client) => String(client.slug))).toContain("a-only"); }), ), ); it.effect("removing an org client removes it for all subjects in the tenant", () => Effect.scoped( Effect.gen(function* () { const dataDir = mkdtempSync(join(tmpdir(), "oauth-remove-client-org-")); const tenant = "shared-tenant"; const a = yield* makeTestWorkspaceHarness({ plugins, tenant, subject: "subject-a", dataDir, }); yield* a.executor.oauth.createClient({ owner: "org", slug: OAuthClientSlug.make("shared-org"), authorizationUrl: "https://shared.test/authorize", tokenUrl: "https://shared.test/token", grant: "authorization_code", clientId: "shared-client-id", clientSecret: "shared-secret", }); const b = yield* makeTestWorkspaceHarness({ plugins, tenant, subject: "subject-b", dataDir, }); // B (a different subject in the same tenant) removes the shared org app. yield* b.executor.oauth.removeClient("org", OAuthClientSlug.make("shared-org")); // It is gone for A too — org rows are tenant-shared. const clientsA = yield* a.executor.oauth.listClients(); expect(clientsA.map((client) => String(client.slug))).not.toContain("shared-org"); }), ), ); // The `oauth.clients.remove` TOOL reports `removed` honestly on top of the // idempotent service call above, so an agent sweeping a list of slugs cannot // read a no-op as a deletion. it.effect("the remove tool distinguishes a real deletion from a no-op", () => Effect.scoped( Effect.gen(function* () { const { executor } = yield* makeTestWorkspaceHarness({ plugins, coreTools: {}, }); const remove = ToolAddress.make("executor.coreTools.oauth.clients.remove"); // The same slug registered under BOTH owners — the shape that made a // hardcoded `owner: "user"` sweep silently skip the org copy. for (const owner of ["org", "user"] as const) { yield* executor.oauth.createClient({ owner, slug: ORG_CLIENT, authorizationUrl: "https://acme.test/authorize", tokenUrl: "https://acme.test/token", grant: "authorization_code", clientId: `${owner}-client-id`, clientSecret: `${owner}-secret`, }); } // A slug that never existed is not a removal. expect( yield* executor.execute( remove, { owner: "user", slug: "never-existed" }, { onElicitation: "accept-all" }, ), ).toEqual({ removed: false }); // Removing the user copy leaves the org copy, which still reports as a // real removal of its own rather than as already-gone. expect( yield* executor.execute( remove, { owner: "user", slug: String(ORG_CLIENT) }, { onElicitation: "accept-all" }, ), ).toEqual({ removed: true }); expect( yield* executor.execute( remove, { owner: "org", slug: String(ORG_CLIENT) }, { onElicitation: "accept-all" }, ), ).toEqual({ removed: true }); // Both are gone, and a repeat of either is now a no-op. expect(yield* executor.oauth.listClients()).toEqual([]); expect( yield* executor.execute( remove, { owner: "org", slug: String(ORG_CLIENT) }, { onElicitation: "accept-all" }, ), ).toEqual({ removed: false }); }), ), ); });