import { chmod, mkdtemp, readdir, rm, stat } from "node:fs/promises"; import { dirname, join } from "node:path"; import { tmpdir } from "node:os"; import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { DEFAULT_ORG_ID, prisma, resetDb, seedTestOrganization } from "./helpers.js"; import { archiveFeishuChatBinding, bindFeishuChatToProject, createFolder, createProjectFromFeishuChat, createProjectFromOrgAdmin, moveProjectToFolder, setMembersCanCreateProjects, } from "../../src/projectOnboarding.js"; const workspaceRoots: string[] = []; const itAsNonRoot = typeof process.getuid === "function" && process.getuid() === 0 ? it.skip : it; describe("ADR-0021 project onboarding", () => { beforeEach(async () => { await dropPermissionSettingsFailureTrigger(); await resetDb(); }); afterEach(async () => { await dropPermissionSettingsFailureTrigger(); while (workspaceRoots.length > 0) { const root = workspaceRoots.pop(); if (root !== undefined) await rm(root, { recursive: true, force: true }); } }); afterAll(async () => { await dropPermissionSettingsFailureTrigger(); await prisma.$disconnect(); }); it("lets an org admin create an unbound project in a folder", async () => { await seedUser("u-admin", "ou_admin", "ADMIN"); const folder = await createFolder(prisma, { organizationId: DEFAULT_ORG_ID, name: "Grade 7", sortKey: "010000", }); const workspaceRoot = await tempWorkspaceRoot(); const result = await createProjectFromOrgAdmin(prisma, { organizationId: DEFAULT_ORG_ID, actorFeishuOpenId: "ou_admin", name: "Newton Lesson", folderId: folder.id, workspaceRoot, }); expect(result.folderId).toBe(folder.id); expect(result.chatId).toBeUndefined(); expect((await stat(result.workspaceDir)).isDirectory()).toBe(true); expect(result.workspaceDir).toMatch(/\/o_[A-Za-z0-9_-]{16}\/p_[A-Za-z0-9_-]{16}$/); const grant = await prisma.permissionGrant.findFirst({ where: { resourceType: "PROJECT", resourceId: result.projectId, principalType: "USER", principalId: "ou_admin", revokedAt: null, }, select: { role: true }, }); expect(grant?.role).toBe("MANAGE"); const settings = await prisma.permissionSettings.findUnique({ where: { resourceType_resourceId: { resourceType: "PROJECT", resourceId: result.projectId } }, }); expect(settings?.agentTrigger).toBe("ROLE"); }); it("lets an org member create and bind a project from an unbound Feishu chat", async () => { await seedUser("u-member", "ou_member", "MEMBER"); const workspaceRoot = await tempWorkspaceRoot(); const result = await createProjectFromFeishuChat(prisma, { organizationId: DEFAULT_ORG_ID, actorFeishuOpenId: "ou_member", chatId: "chat-onboard", name: "Poetry Workshop", workspaceRoot, }); expect(result.chatId).toBe("chat-onboard"); const binding = await prisma.projectGroupBinding.findFirst({ where: { projectId: result.projectId, chatId: "chat-onboard", archivedAt: null }, }); expect(binding).not.toBeNull(); const grants = await prisma.permissionGrant.findMany({ where: { resourceType: "PROJECT", resourceId: result.projectId, revokedAt: null }, select: { principalType: true, principalId: true, role: true }, orderBy: { principalType: "asc" }, }); expect(grants).toEqual(expect.arrayContaining([ { principalType: "USER", principalId: "ou_member", role: "MANAGE" }, { principalType: "FEISHU_CHAT", principalId: "chat-onboard", role: "EDIT" }, ])); }); it("blocks ordinary Feishu project creation when the org setting is off", async () => { await seedUser("u-member", "ou_member", "MEMBER"); await setMembersCanCreateProjects(prisma, { organizationId: DEFAULT_ORG_ID, enabled: false, }); await expect(createProjectFromFeishuChat(prisma, { organizationId: DEFAULT_ORG_ID, actorFeishuOpenId: "ou_member", chatId: "chat-denied", name: "Denied Project", workspaceRoot: await tempWorkspaceRoot(), })).rejects.toThrow(/members cannot create projects/); }); it.each(["SUSPENDED", "ARCHIVED"] as const)( "blocks project creation and organization mutations when the org is %s", async (status) => { await seedUser(`u-${status}`, `ou_${status}`, "OWNER"); const folder = await createFolder(prisma, { organizationId: DEFAULT_ORG_ID, name: `Before ${status}`, }); const workspaceRoot = await tempWorkspaceRoot(); const project = await createProjectFromOrgAdmin(prisma, { organizationId: DEFAULT_ORG_ID, actorFeishuOpenId: `ou_${status}`, name: `Existing ${status}`, workspaceRoot, }); const organizationWorkspace = dirname(project.workspaceDir); const workspacesBefore = (await readdir(organizationWorkspace)).sort(); await prisma.organization.update({ where: { id: DEFAULT_ORG_ID }, data: { status } }); await expect(createProjectFromOrgAdmin(prisma, { organizationId: DEFAULT_ORG_ID, actorFeishuOpenId: `ou_${status}`, name: "Blocked", workspaceRoot, })).rejects.toThrow(`organization ${DEFAULT_ORG_ID} is ${status}`); await expect(readdir(organizationWorkspace)).resolves.toEqual(workspacesBefore); await expect(bindFeishuChatToProject(prisma, { projectId: project.projectId, actorFeishuOpenId: `ou_${status}`, chatId: `chat-${status}`, })).rejects.toThrow(`organization ${DEFAULT_ORG_ID} is ${status}`); await expect(createFolder(prisma, { organizationId: DEFAULT_ORG_ID, name: "Blocked folder", })).rejects.toThrow(`organization ${DEFAULT_ORG_ID} is ${status}`); await expect(setMembersCanCreateProjects(prisma, { organizationId: DEFAULT_ORG_ID, enabled: false, })).rejects.toThrow(`organization ${DEFAULT_ORG_ID} is ${status}`); await expect(moveProjectToFolder(prisma, { projectId: project.projectId, folderId: folder.id, })).rejects.toThrow(`organization ${DEFAULT_ORG_ID} is ${status}`); }, ); it("removes a newly created workspace when the database transaction later fails", async () => { await seedUser("u-compensate", "ou_compensate", "ADMIN"); await installPermissionSettingsFailureTrigger(0); const workspaceRoot = await tempWorkspaceRoot(); await expect(createProjectFromOrgAdmin(prisma, { organizationId: DEFAULT_ORG_ID, actorFeishuOpenId: "ou_compensate", name: "Must Roll Back", workspaceRoot, })).rejects.toThrow(/forced permission settings failure/); const organizationWorkspaces = await readdir(workspaceRoot); expect(organizationWorkspaces).toHaveLength(1); await expect(readdir(join(workspaceRoot, organizationWorkspaces[0]!))).resolves.toEqual([]); await expect(prisma.project.count()).resolves.toBe(0); }); itAsNonRoot("surfaces both the transaction and workspace cleanup failures", async () => { await seedUser("u-cleanup-failure", "ou_cleanup_failure", "ADMIN"); await installPermissionSettingsFailureTrigger(1); const workspaceRoot = await tempWorkspaceRoot(); let organizationWorkspace: string | undefined; const pending = createProjectFromOrgAdmin(prisma, { organizationId: DEFAULT_ORG_ID, actorFeishuOpenId: "ou_cleanup_failure", name: "Cleanup Must Fail Loudly", workspaceRoot, }); const outcome = pending.then( () => null, (error: unknown) => error, ); await vi.waitFor(async () => { const organizationWorkspaces = await readdir(workspaceRoot); expect(organizationWorkspaces).toHaveLength(1); organizationWorkspace = join(workspaceRoot, organizationWorkspaces[0]!); expect(await readdir(organizationWorkspace)).toHaveLength(1); }, { timeout: 2_000 }); if (organizationWorkspace === undefined) throw new Error("organization workspace was not allocated"); await chmod(organizationWorkspace, 0o500); try { const error = await outcome; expect(error).toBeInstanceOf(AggregateError); expect((error as AggregateError).errors).toHaveLength(2); await expect(prisma.project.count()).resolves.toBe(0); } finally { await chmod(organizationWorkspace, 0o700); } }); it("binds an existing project once, archives the binding, then allows a new active binding", async () => { await seedUser("u-owner", "ou_owner", "OWNER"); const workspaceRoot = await tempWorkspaceRoot(); const project = await createProjectFromOrgAdmin(prisma, { organizationId: DEFAULT_ORG_ID, actorFeishuOpenId: "ou_owner", name: "Reusable Project", workspaceRoot, }); await bindFeishuChatToProject(prisma, { projectId: project.projectId, actorFeishuOpenId: "ou_owner", chatId: "chat-first", }); await expect(bindFeishuChatToProject(prisma, { projectId: project.projectId, actorFeishuOpenId: "ou_owner", chatId: "chat-second", })).rejects.toThrow(/already bound/); await expect(archiveFeishuChatBinding(prisma, { projectId: project.projectId, actorFeishuOpenId: "ou_owner", })).resolves.toMatchObject({ archived: true, chatId: "chat-first" }); await bindFeishuChatToProject(prisma, { projectId: project.projectId, actorFeishuOpenId: "ou_owner", chatId: "chat-second", }); const activeBindings = await prisma.projectGroupBinding.findMany({ where: { projectId: project.projectId, archivedAt: null }, select: { chatId: true }, }); expect(activeBindings).toEqual([{ chatId: "chat-second" }]); const oldChatGrant = await prisma.permissionGrant.findFirst({ where: { resourceType: "PROJECT", resourceId: project.projectId, principalType: "FEISHU_CHAT", principalId: "chat-first", revokedAt: null, }, }); expect(oldChatGrant).toBeNull(); }); it("rejects selecting an Agent role from another Organization at the database boundary", async () => { await seedUser("u-owner", "ou_owner", "OWNER"); const project = await createProjectFromOrgAdmin(prisma, { organizationId: DEFAULT_ORG_ID, actorFeishuOpenId: "ou_owner", name: "Tenant-scoped role project", workspaceRoot: await tempWorkspaceRoot(), }); await bindFeishuChatToProject(prisma, { projectId: project.projectId, actorFeishuOpenId: "ou_owner", chatId: "chat-tenant-role", }); await seedTestOrganization("org_other", "other"); const otherRole = await prisma.organizationAgentRole.findUniqueOrThrow({ where: { organizationId_roleId: { organizationId: "org_other", roleId: "draft" } }, }); await expect(prisma.projectGroupBinding.updateMany({ where: { projectId: project.projectId, chatId: "chat-tenant-role", archivedAt: null }, data: { selectedAgentRoleId: otherRole.id }, })).rejects.toThrow(); }); }); async function seedUser(id: string, feishuOpenId: string, role: "OWNER" | "ADMIN" | "MEMBER"): Promise { await prisma.user.create({ data: { id, feishuOpenId, displayName: feishuOpenId, organizationMemberships: { create: { organizationId: DEFAULT_ORG_ID, role } }, }, }); } async function tempWorkspaceRoot(): Promise { const root = await mkdtemp(join(tmpdir(), "cph-onboarding-")); workspaceRoots.push(root); return root; } async function installPermissionSettingsFailureTrigger(delaySeconds: number): Promise { await dropPermissionSettingsFailureTrigger(); await prisma.$executeRawUnsafe(` CREATE FUNCTION cph_test_fail_permission_settings() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN PERFORM pg_sleep(${delaySeconds}); RAISE EXCEPTION 'forced permission settings failure'; END; $$ `); await prisma.$executeRawUnsafe(` CREATE TRIGGER cph_test_fail_permission_settings BEFORE INSERT ON "PermissionSettings" FOR EACH ROW EXECUTE FUNCTION cph_test_fail_permission_settings() `); } async function dropPermissionSettingsFailureTrigger(): Promise { await prisma.$executeRawUnsafe( 'DROP TRIGGER IF EXISTS cph_test_fail_permission_settings ON "PermissionSettings"', ); await prisma.$executeRawUnsafe('DROP FUNCTION IF EXISTS cph_test_fail_permission_settings()'); }