import { describe, expect, it } from "@effect/vitest"; import { Effect, Layer } from "effect"; import { ApiKeyService, OrgApiKeyNotFound } from "./api-keys"; import { ApiKeyManagementError } from "./errors"; import { WorkOSClient, type WorkOSClientService } from "./workos"; // --------------------------------------------------------------------------- // Minting, listing, and revoking ORGANIZATION-owned api keys — the privileged // credential that resolves to the read-only platform view (`/admin/*`). // // The decode boundary is what these pin. WorkOS returns an org-owned key with // the ORG in `owner.id` and no member at all, which is a different shape from // the user-owned keys the console's personal-key CRUD handles. The workspace // api key is also workspace-WIDE, so a response naming a different org than the // one asked for must not be surfaced as that org's key. // // NOTE (emulator gap): the WorkOS emulator's `serializeApiKey` hardcodes // `owner.type: "user"` and exposes no org-key mint route, so this path cannot // be exercised over the wire in e2e today. These stubs encode the REAL WorkOS // API shape (`organizations.createOrganizationApiKey` / // `listOrganizationApiKeys`, per the installed SDK's types). // --------------------------------------------------------------------------- const ORG = "org_123"; const orgKeyResponse = (organizationId: string, id: string, name: string) => ({ object: "api_key", id, owner: { type: "organization", id: organizationId }, name, obfuscatedValue: "sk_org…a1b2", lastUsedAt: null, permissions: [], createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", }); const stubWorkOS = (overrides: Record = {}) => Layer.succeed( WorkOSClient, new Proxy({} as WorkOSClientService, { get: (_target, prop) => { const override = overrides[String(prop)]; if (override) return override; return () => Effect.die(`unexpected WorkOSClient.${String(prop)} call`); }, }), ); const serviceWith = (overrides: Record) => ApiKeyService.asEffect().pipe( Effect.provide(ApiKeyService.WorkOS.pipe(Layer.provide(stubWorkOS(overrides)))), ); describe("organization API keys", () => { it.effect("mints an org-owned key and returns the one-time secret", () => Effect.gen(function* () { const apiKeys = yield* serviceWith({ createOrgApiKey: (params: { organizationId: string; name: string }) => Effect.succeed({ ...orgKeyResponse(params.organizationId, "key_org_1", params.name), value: "sk_org_plaintext", }), }); const created = yield* apiKeys.createOrgKey({ organizationId: ORG, name: "acme-backend" }); expect(created.id).toBe("key_org_1"); expect(created.name).toBe("acme-backend"); expect(created.value, "create returns the one-time plaintext secret").toBe( "sk_org_plaintext", ); expect(created.obfuscatedValue, "the display value is masked, not the secret").not.toBe( created.value, ); }), ); it.effect("lists the organization's own keys, masked", () => Effect.gen(function* () { const apiKeys = yield* serviceWith({ listOrgApiKeys: (organizationId: string) => Effect.succeed({ object: "list", data: [ orgKeyResponse(organizationId, "key_org_1", "acme-backend"), orgKeyResponse(organizationId, "key_org_2", "ci"), ], listMetadata: { before: null, after: null }, }), }); const keys = yield* apiKeys.listOrgKeys({ organizationId: ORG }); expect(keys.map((key) => key.name)).toEqual(["acme-backend", "ci"]); expect(JSON.stringify(keys), "the list never carries a plaintext secret").not.toContain( "sk_org_plaintext", ); }), ); it.effect("drops a key owned by a DIFFERENT organization", () => Effect.gen(function* () { // The WorkOS workspace api key is workspace-wide, so a response naming // another org must never be surfaced as this org's key. const apiKeys = yield* serviceWith({ listOrgApiKeys: () => Effect.succeed({ object: "list", data: [ orgKeyResponse(ORG, "key_mine", "mine"), orgKeyResponse("org_other", "key_theirs", "theirs"), ], listMetadata: { before: null, after: null }, }), }); const keys = yield* apiKeys.listOrgKeys({ organizationId: ORG }); expect( keys.map((key) => key.id), "only this org's key survives", ).toEqual(["key_mine"]); }), ); it.effect("refuses a create response that names another organization", () => Effect.gen(function* () { const apiKeys = yield* serviceWith({ createOrgApiKey: () => Effect.succeed({ ...orgKeyResponse("org_other", "key_theirs", "theirs"), value: "sk_org_plaintext", }), }); const error = yield* Effect.flip( apiKeys.createOrgKey({ organizationId: ORG, name: "acme-backend" }), ); expect(error).toBeInstanceOf(ApiKeyManagementError); }), ); it.effect("ignores a user-owned key in an org-key listing", () => Effect.gen(function* () { // The org and user shapes are decoded separately on purpose; a user key // appearing here would mean the caller could mistake a member's personal // credential for a workspace one. const apiKeys = yield* serviceWith({ listOrgApiKeys: () => Effect.succeed({ object: "list", data: [ orgKeyResponse(ORG, "key_mine", "mine"), { object: "api_key", id: "key_user", owner: { type: "user", id: "user_1", organization_id: ORG }, name: "personal", obfuscatedValue: "sk_user…c3d4", lastUsedAt: null, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", }, ], listMetadata: { before: null, after: null }, }), }); const keys = yield* apiKeys.listOrgKeys({ organizationId: ORG }); expect( keys.map((key) => key.id), "a user-owned key is not an org key", ).toEqual(["key_mine"]); }), ); }); // --------------------------------------------------------------------------- // Revoking an org key. The mint is only half a credential lifecycle: an org key // reads every user and connection in the tenant, so it has to be destroyable. // // Revoke cannot go through `revokeUserKey`/`revokeApiKey` — that path resolves // ownership against the CALLER's personal keys, so an org key id is never in // its owned set. It also cannot just call `deleteApiKey` directly: that route is // scope-blind and would happily destroy a member's personal key, or a key // belonging to a sibling org in the same WorkOS workspace. So ownership is // resolved against the ORG's decoded listing first, and that filter IS the // authorization. These pin that filter. // --------------------------------------------------------------------------- describe("revoking an organization API key", () => { const listing = (...keys: ReadonlyArray) => ({ object: "list", data: keys, listMetadata: { before: null, after: null }, }); it.effect("revokes a key the organization owns", () => Effect.gen(function* () { const deleted: string[] = []; const apiKeys = yield* serviceWith({ listOrgApiKeys: (organizationId: string) => Effect.succeed(listing(orgKeyResponse(organizationId, "key_org_1", "acme-backend"))), deleteApiKey: (id: string) => Effect.sync(() => { deleted.push(id); }), }); yield* apiKeys.revokeOrgKey({ organizationId: ORG, keyId: "key_org_1" }); expect(deleted, "the delete reaches WorkOS exactly once, for that key").toEqual([ "key_org_1", ]); }), ); it.effect("refuses a USER-owned key id, without deleting anything", () => Effect.gen(function* () { // The regression this guards: a member's personal credential must not be // destroyable through the org route just because an admin can call it. const deleted: string[] = []; const apiKeys = yield* serviceWith({ listOrgApiKeys: () => Effect.succeed( listing(orgKeyResponse(ORG, "key_org_1", "acme-backend"), { object: "api_key", id: "key_user", owner: { type: "user", id: "user_1", organization_id: ORG }, name: "personal", obfuscatedValue: "sk_user…c3d4", lastUsedAt: null, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", }), ), deleteApiKey: (id: string) => Effect.sync(() => { deleted.push(id); }), }); const error = yield* Effect.flip( apiKeys.revokeOrgKey({ organizationId: ORG, keyId: "key_user" }), ); expect(error).toBeInstanceOf(OrgApiKeyNotFound); expect(deleted, "no delete is issued for a key the org does not own").toEqual([]); }), ); it.effect("refuses a key belonging to ANOTHER organization", () => Effect.gen(function* () { // The WorkOS workspace key is workspace-wide, so an admin of one org must // not be able to revoke a sibling org's credential by id. const deleted: string[] = []; const apiKeys = yield* serviceWith({ listOrgApiKeys: () => Effect.succeed( listing( orgKeyResponse(ORG, "key_mine", "mine"), orgKeyResponse("org_other", "key_theirs", "theirs"), ), ), deleteApiKey: (id: string) => Effect.sync(() => { deleted.push(id); }), }); const error = yield* Effect.flip( apiKeys.revokeOrgKey({ organizationId: ORG, keyId: "key_theirs" }), ); expect(error).toBeInstanceOf(OrgApiKeyNotFound); expect(deleted).toEqual([]); }), ); it.effect("refuses an unknown key id", () => Effect.gen(function* () { const apiKeys = yield* serviceWith({ listOrgApiKeys: () => Effect.succeed(listing()), deleteApiKey: () => Effect.die("nothing should be deleted"), }); const error = yield* Effect.flip( apiKeys.revokeOrgKey({ organizationId: ORG, keyId: "key_nope" }), ); expect(error).toBeInstanceOf(OrgApiKeyNotFound); }), ); it.effect("surfaces an upstream delete failure as a management error", () => Effect.gen(function* () { // A failed delete must not read as a successful revoke — the console // would tell an admin a live credential is gone. const apiKeys = yield* serviceWith({ listOrgApiKeys: (organizationId: string) => Effect.succeed(listing(orgKeyResponse(organizationId, "key_org_1", "acme-backend"))), deleteApiKey: () => Effect.fail("workos_unavailable"), }); const error = yield* Effect.flip( apiKeys.revokeOrgKey({ organizationId: ORG, keyId: "key_org_1" }), ); expect(error).toBeInstanceOf(ApiKeyManagementError); }), ); });