diff --git a/packages/server/rpc/accounts/engine.integration.test.ts b/packages/server/rpc/accounts/engine.integration.test.ts index ace0392..3e3e635 100644 --- a/packages/server/rpc/accounts/engine.integration.test.ts +++ b/packages/server/rpc/accounts/engine.integration.test.ts @@ -13,6 +13,7 @@ import { type Identity, } from "@memory-engine/accounts"; import { TestDatabase as AccountsTestDatabase } from "@memory-engine/accounts/migrate/test-utils"; +import { createEngineDB } from "@memory-engine/engine"; import { bootstrap } from "@memory-engine/engine/migrate/bootstrap"; import { TestDatabase as EngineTestDatabase } from "@memory-engine/engine/migrate/test-utils"; import { SQL } from "bun"; @@ -329,3 +330,179 @@ describe("engine.create integration", () => { expect(engine?.id).toBe(result.id); }); }); + +// --------------------------------------------------------------------------- +// engine.setupAccess Tests +// --------------------------------------------------------------------------- + +describe("engine.setupAccess integration", () => { + // Create a dedicated engine for setupAccess tests + let setupAccessEngineId: string; + let setupAccessEngineSlug: string; + + beforeAll(async () => { + const createHandler = engineMethods.get("engine.create")?.handler; + if (!createHandler) throw new Error("engine.create handler not found"); + + const result = (await createHandler( + { orgId: testOrgId, name: "SetupAccess Test Engine" }, + createContext(testIdentity), + )) as { id: string; slug: string }; + + setupAccessEngineId = result.id; + setupAccessEngineSlug = result.slug; + }); + + function getHandler() { + const handler = engineMethods.get("engine.setupAccess")?.handler; + if (!handler) throw new Error("engine.setupAccess handler not found"); + return handler; + } + + test("owner gets superuser + createrole user and API key", async () => { + const handler = getHandler(); + const context = createContext(testIdentity); + + const result = (await handler( + { engineId: setupAccessEngineId }, + context, + )) as { + rawKey: string; + engineSlug: string; + userId: string; + engineName: string; + orgName: string; + }; + + expect(result.rawKey).toBeDefined(); + expect(result.rawKey.length).toBeGreaterThan(0); + expect(result.engineSlug).toBe(setupAccessEngineSlug); + expect(result.userId).toBeDefined(); + expect(result.engineName).toBe("SetupAccess Test Engine"); + expect(result.orgName).toBe("Engine Test Org"); + + // Verify the engine user has superuser privileges + const engineDb = createEngineDB(engineSql, `me_${setupAccessEngineSlug}`); + const user = await engineDb.getUser(result.userId); + expect(user).not.toBeNull(); + expect(user?.superuser).toBe(true); + expect(user?.createrole).toBe(true); + expect(user?.identityId).toBe(testIdentity.id); + }); + + test("admin gets superuser + createrole user and API key", async () => { + const handler = getHandler(); + + const admin = await accountsDb.createIdentity({ + email: "setup-admin@example.com", + name: "Setup Admin", + }); + await accountsDb.addMember(testOrgId, admin.id, "admin"); + + const context = createContext(admin); + const result = (await handler( + { engineId: setupAccessEngineId }, + context, + )) as { userId: string; rawKey: string }; + + expect(result.rawKey).toBeDefined(); + + const engineDb = createEngineDB(engineSql, `me_${setupAccessEngineSlug}`); + const user = await engineDb.getUser(result.userId); + expect(user?.superuser).toBe(true); + expect(user?.createrole).toBe(true); + }); + + test("member gets vanilla user (no superuser) and API key", async () => { + const handler = getHandler(); + + const member = await accountsDb.createIdentity({ + email: "setup-member@example.com", + name: "Setup Member", + }); + await accountsDb.addMember(testOrgId, member.id, "member"); + + const context = createContext(member); + const result = (await handler( + { engineId: setupAccessEngineId }, + context, + )) as { userId: string; rawKey: string }; + + expect(result.rawKey).toBeDefined(); + + const engineDb = createEngineDB(engineSql, `me_${setupAccessEngineSlug}`); + const user = await engineDb.getUser(result.userId); + expect(user?.superuser).toBe(false); + expect(user?.createrole).toBe(false); + }); + + test("non-member is forbidden", async () => { + const handler = getHandler(); + + const outsider = await accountsDb.createIdentity({ + email: "setup-outsider@example.com", + name: "Setup Outsider", + }); + + const context = createContext(outsider); + + await expect( + handler({ engineId: setupAccessEngineId }, context), + ).rejects.toThrow("Not a member of the organization"); + }); + + test("engine not found returns error", async () => { + const handler = getHandler(); + const context = createContext(testIdentity); + + await expect( + handler({ engineId: "019d694f-79f6-7595-8faf-b70b01c11f98" }, context), + ).rejects.toThrow("Engine not found"); + }); + + test("idempotent: second call reuses user, creates new API key", async () => { + const handler = getHandler(); + + const idempotentUser = await accountsDb.createIdentity({ + email: "setup-idempotent@example.com", + name: "Idempotent User", + }); + await accountsDb.addMember(testOrgId, idempotentUser.id, "owner"); + + const context = createContext(idempotentUser); + + // First call + const result1 = (await handler( + { engineId: setupAccessEngineId }, + context, + )) as { userId: string; rawKey: string }; + + // Second call + const result2 = (await handler( + { engineId: setupAccessEngineId }, + context, + )) as { userId: string; rawKey: string }; + + // Same user, different API keys + expect(result2.userId).toBe(result1.userId); + expect(result2.rawKey).not.toBe(result1.rawKey); + }); + + test("custom API key name is used", async () => { + const handler = getHandler(); + + const namedKeyUser = await accountsDb.createIdentity({ + email: "setup-named@example.com", + name: "Named Key User", + }); + await accountsDb.addMember(testOrgId, namedKeyUser.id, "owner"); + + const context = createContext(namedKeyUser); + const result = (await handler( + { engineId: setupAccessEngineId, apiKeyName: "my-custom-key" }, + context, + )) as { rawKey: string }; + + expect(result.rawKey).toBeDefined(); + }); +}); diff --git a/packages/server/rpc/accounts/schemas.test.ts b/packages/server/rpc/accounts/schemas.test.ts index 6c8928a..e72ee74 100644 --- a/packages/server/rpc/accounts/schemas.test.ts +++ b/packages/server/rpc/accounts/schemas.test.ts @@ -7,6 +7,7 @@ import { engineCreateSchema, engineGetSchema, engineListSchema, + engineSetupAccessSchema, engineStatusSchema, engineUpdateSchema, invitationAcceptSchema, @@ -447,3 +448,44 @@ describe("invitationAcceptSchema", () => { expect(result.success).toBe(false); }); }); + +// ============================================================================= +// Engine SetupAccess Schema Tests +// ============================================================================= + +describe("engineSetupAccessSchema", () => { + test("accepts valid engineId only", () => { + const result = engineSetupAccessSchema.safeParse({ + engineId: "019d694f-79f6-7595-8faf-b70b01c11f98", + }); + expect(result.success).toBe(true); + }); + + test("accepts engineId with apiKeyName", () => { + const result = engineSetupAccessSchema.safeParse({ + engineId: "019d694f-79f6-7595-8faf-b70b01c11f98", + apiKeyName: "my-cli-key", + }); + expect(result.success).toBe(true); + }); + + test("rejects missing engineId", () => { + const result = engineSetupAccessSchema.safeParse({}); + expect(result.success).toBe(false); + }); + + test("rejects invalid UUID", () => { + const result = engineSetupAccessSchema.safeParse({ + engineId: "not-a-uuid", + }); + expect(result.success).toBe(false); + }); + + test("rejects empty apiKeyName", () => { + const result = engineSetupAccessSchema.safeParse({ + engineId: "019d694f-79f6-7595-8faf-b70b01c11f98", + apiKeyName: "", + }); + expect(result.success).toBe(false); + }); +}); diff --git a/packages/server/rpc/engine/owner.test.ts b/packages/server/rpc/engine/owner.test.ts new file mode 100644 index 0000000..c077663 --- /dev/null +++ b/packages/server/rpc/engine/owner.test.ts @@ -0,0 +1,186 @@ +/** + * Unit tests for owner RPC handlers. + * + * Uses mocked EngineDB to test handler logic in isolation. + */ +import { describe, expect, mock, test } from "bun:test"; +import type { HandlerContext } from "../types"; +import { ownerMethods } from "./owner"; + +function createMockContext( + dbOverrides: Record = {}, +): HandlerContext { + return { + request: new Request("http://localhost"), + db: { + getUserId: mock(() => "user-123"), + setTreeOwner: mock(() => Promise.resolve()), + getTreeOwner: mock(() => Promise.resolve(null)), + removeTreeOwner: mock(() => Promise.resolve(false)), + listTreeOwners: mock(() => Promise.resolve([])), + ...dbOverrides, + }, + userId: "user-123", + apiKeyId: "key-456", + engine: { + id: "eng-1", + orgId: "org-1", + slug: "test", + name: "Test", + status: "active" as const, + }, + } as unknown as HandlerContext; +} + +describe("owner.set", () => { + test("calls setTreeOwner and returns { set: true }", async () => { + const handler = ownerMethods.get("owner.set")?.handler; + if (!handler) throw new Error("owner.set handler not found"); + + const setTreeOwner = mock(() => Promise.resolve()); + const context = createMockContext({ setTreeOwner }); + + const result = await handler( + { + userId: "019d694f-79f6-7595-8faf-b70b01c11f98", + treePath: "work.projects", + }, + context, + ); + + expect(result).toEqual({ set: true }); + expect(setTreeOwner).toHaveBeenCalledTimes(1); + }); +}); + +describe("owner.get", () => { + test("returns owner when found", async () => { + const handler = ownerMethods.get("owner.get")?.handler; + if (!handler) throw new Error("owner.get handler not found"); + + const now = new Date("2026-01-15T00:00:00.000Z"); + const getTreeOwner = mock(() => + Promise.resolve({ + treePath: "work.projects", + userId: "019d694f-79f6-7595-8faf-b70b01c11f98", + createdBy: "user-123", + createdAt: now, + }), + ); + const context = createMockContext({ getTreeOwner }); + + const result = (await handler({ treePath: "work.projects" }, context)) as { + treePath: string; + userId: string; + createdBy: string; + createdAt: string; + }; + + expect(result.treePath).toBe("work.projects"); + expect(result.userId).toBe("019d694f-79f6-7595-8faf-b70b01c11f98"); + expect(result.createdBy).toBe("user-123"); + expect(result.createdAt).toBe("2026-01-15T00:00:00.000Z"); + }); + + test("throws NOT_FOUND when no owner", async () => { + const handler = ownerMethods.get("owner.get")?.handler; + if (!handler) throw new Error("owner.get handler not found"); + + const context = createMockContext({ + getTreeOwner: mock(() => Promise.resolve(null)), + }); + + try { + await handler({ treePath: "work.projects" }, context); + throw new Error("Expected handler to throw"); + } catch (error) { + expect((error as { code: string }).code).toBe("NOT_FOUND"); + } + }); +}); + +describe("owner.remove", () => { + test("returns { removed: true } when found", async () => { + const handler = ownerMethods.get("owner.remove")?.handler; + if (!handler) throw new Error("owner.remove handler not found"); + + const context = createMockContext({ + removeTreeOwner: mock(() => Promise.resolve(true)), + }); + + const result = await handler({ treePath: "work.projects" }, context); + expect(result).toEqual({ removed: true }); + }); + + test("throws NOT_FOUND when no owner to remove", async () => { + const handler = ownerMethods.get("owner.remove")?.handler; + if (!handler) throw new Error("owner.remove handler not found"); + + const context = createMockContext({ + removeTreeOwner: mock(() => Promise.resolve(false)), + }); + + try { + await handler({ treePath: "work.projects" }, context); + throw new Error("Expected handler to throw"); + } catch (error) { + expect((error as { code: string }).code).toBe("NOT_FOUND"); + } + }); +}); + +describe("owner.list", () => { + test("returns owners list", async () => { + const handler = ownerMethods.get("owner.list")?.handler; + if (!handler) throw new Error("owner.list handler not found"); + + const now = new Date("2026-01-15T00:00:00.000Z"); + const listTreeOwners = mock(() => + Promise.resolve([ + { + treePath: "work.projects", + userId: "019d694f-79f6-7595-8faf-b70b01c11f98", + createdBy: "user-123", + createdAt: now, + }, + ]), + ); + const context = createMockContext({ listTreeOwners }); + + const result = (await handler({}, context)) as { + owners: Array<{ treePath: string }>; + }; + + expect(result.owners).toHaveLength(1); + expect(result.owners[0]?.treePath).toBe("work.projects"); + }); + + test("returns empty list when no owners", async () => { + const handler = ownerMethods.get("owner.list")?.handler; + if (!handler) throw new Error("owner.list handler not found"); + + const context = createMockContext({ + listTreeOwners: mock(() => Promise.resolve([])), + }); + + const result = (await handler({}, context)) as { + owners: Array<{ treePath: string }>; + }; + + expect(result.owners).toHaveLength(0); + }); + + test("passes userId filter when provided", async () => { + const handler = ownerMethods.get("owner.list")?.handler; + if (!handler) throw new Error("owner.list handler not found"); + + const listTreeOwners = mock(() => Promise.resolve([])); + const context = createMockContext({ listTreeOwners }); + + await handler({ userId: "019d694f-79f6-7595-8faf-b70b01c11f98" }, context); + + expect(listTreeOwners).toHaveBeenCalledWith( + "019d694f-79f6-7595-8faf-b70b01c11f98", + ); + }); +}); diff --git a/packages/server/rpc/engine/schemas.test.ts b/packages/server/rpc/engine/schemas.test.ts index a134662..efd6b48 100644 --- a/packages/server/rpc/engine/schemas.test.ts +++ b/packages/server/rpc/engine/schemas.test.ts @@ -21,6 +21,10 @@ import { memorySearchSchema, memoryTreeSchema, memoryUpdateSchema, + ownerGetSchema, + ownerListSchema, + ownerRemoveSchema, + ownerSetSchema, roleAddMemberSchema, roleCreateSchema, roleListForUserSchema, @@ -717,3 +721,88 @@ describe("apiKeyDeleteSchema", () => { expect(result.success).toBe(true); }); }); + +// ============================================================================= +// Owner Schema Tests +// ============================================================================= + +describe("ownerSetSchema", () => { + test("accepts valid params", () => { + const result = ownerSetSchema.safeParse({ + userId: "019d694f-79f6-7595-8faf-b70b01c11f98", + treePath: "work.projects", + }); + expect(result.success).toBe(true); + }); + + test("rejects missing userId", () => { + const result = ownerSetSchema.safeParse({ + treePath: "work.projects", + }); + expect(result.success).toBe(false); + }); + + test("rejects missing treePath", () => { + const result = ownerSetSchema.safeParse({ + userId: "019d694f-79f6-7595-8faf-b70b01c11f98", + }); + expect(result.success).toBe(false); + }); + + test("rejects invalid UUID", () => { + const result = ownerSetSchema.safeParse({ + userId: "not-a-uuid", + treePath: "work.projects", + }); + expect(result.success).toBe(false); + }); +}); + +describe("ownerGetSchema", () => { + test("accepts valid treePath", () => { + const result = ownerGetSchema.safeParse({ + treePath: "work.projects.alpha", + }); + expect(result.success).toBe(true); + }); + + test("rejects missing treePath", () => { + const result = ownerGetSchema.safeParse({}); + expect(result.success).toBe(false); + }); +}); + +describe("ownerRemoveSchema", () => { + test("accepts valid treePath", () => { + const result = ownerRemoveSchema.safeParse({ + treePath: "work.projects", + }); + expect(result.success).toBe(true); + }); + + test("rejects missing treePath", () => { + const result = ownerRemoveSchema.safeParse({}); + expect(result.success).toBe(false); + }); +}); + +describe("ownerListSchema", () => { + test("accepts with userId", () => { + const result = ownerListSchema.safeParse({ + userId: "019d694f-79f6-7595-8faf-b70b01c11f98", + }); + expect(result.success).toBe(true); + }); + + test("accepts without userId", () => { + const result = ownerListSchema.safeParse({}); + expect(result.success).toBe(true); + }); + + test("rejects invalid userId", () => { + const result = ownerListSchema.safeParse({ + userId: "not-a-uuid", + }); + expect(result.success).toBe(false); + }); +}); diff --git a/packages/server/rpc/engine/schemas.ts b/packages/server/rpc/engine/schemas.ts index 3be35f0..21c1dda 100644 --- a/packages/server/rpc/engine/schemas.ts +++ b/packages/server/rpc/engine/schemas.ts @@ -51,6 +51,17 @@ export { memoryTreeParams as memoryTreeSchema, memoryUpdateParams as memoryUpdateSchema, } from "@memory-engine/protocol/engine/memory"; +export { + type OwnerGetParams, + type OwnerListParams, + type OwnerRemoveParams, + type OwnerSetParams, + ownerGetParams as ownerGetSchema, + // Owner params + ownerListParams as ownerListSchema, + ownerRemoveParams as ownerRemoveSchema, + ownerSetParams as ownerSetSchema, +} from "@memory-engine/protocol/engine/owner"; export { type RoleAddMemberParams, type RoleCreateParams,