forked from EduCraft/curriculum-project-hub
15f9443d3d
Alpha silo bootstrap created the root Inbox without kind=SYSTEM_INBOX, so Feishu card project creation tried to insert a second Inbox and hit the sibling-name unique index. Tag the bootstrap folder correctly and promote any legacy root Inbox on ensure.
369 lines
14 KiB
TypeScript
369 lines
14 KiB
TypeScript
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("promotes a legacy root Inbox when Feishu chat creates a project", async () => {
|
|
await seedUser("u-member", "ou_member", "MEMBER");
|
|
// Production drift after bootstrap omitted kind=SYSTEM_INBOX. The protect
|
|
// trigger refuses demotion, so the test plants the legacy shape directly.
|
|
await prisma.$executeRawUnsafe(`ALTER TABLE "Folder" DISABLE TRIGGER cph_protect_system_inbox`);
|
|
try {
|
|
await prisma.folder.updateMany({
|
|
where: { organizationId: DEFAULT_ORG_ID, kind: "SYSTEM_INBOX", archivedAt: null },
|
|
data: { kind: "REGULAR" },
|
|
});
|
|
} finally {
|
|
await prisma.$executeRawUnsafe(`ALTER TABLE "Folder" ENABLE TRIGGER cph_protect_system_inbox`);
|
|
}
|
|
const legacy = await prisma.folder.findFirstOrThrow({
|
|
where: { organizationId: DEFAULT_ORG_ID, parentId: null, name: "Inbox", archivedAt: null },
|
|
select: { id: true, kind: true },
|
|
});
|
|
expect(legacy.kind).toBe("REGULAR");
|
|
|
|
const result = await createProjectFromFeishuChat(prisma, {
|
|
organizationId: DEFAULT_ORG_ID,
|
|
actorFeishuOpenId: "ou_member",
|
|
chatId: "chat-legacy-inbox",
|
|
name: "Recovered Inbox Project",
|
|
workspaceRoot: await tempWorkspaceRoot(),
|
|
});
|
|
|
|
expect(result.folderId).toBe(legacy.id);
|
|
await expect(prisma.folder.findUniqueOrThrow({
|
|
where: { id: legacy.id },
|
|
select: { kind: true },
|
|
})).resolves.toEqual({ kind: "SYSTEM_INBOX" });
|
|
expect(await prisma.folder.count({
|
|
where: { organizationId: DEFAULT_ORG_ID, name: "Inbox", archivedAt: null },
|
|
})).toBe(1);
|
|
});
|
|
|
|
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<void> {
|
|
await prisma.user.create({
|
|
data: {
|
|
id,
|
|
feishuOpenId,
|
|
displayName: feishuOpenId,
|
|
organizationMemberships: { create: { organizationId: DEFAULT_ORG_ID, role } },
|
|
},
|
|
});
|
|
}
|
|
|
|
async function tempWorkspaceRoot(): Promise<string> {
|
|
const root = await mkdtemp(join(tmpdir(), "cph-onboarding-"));
|
|
workspaceRoots.push(root);
|
|
return root;
|
|
}
|
|
|
|
async function installPermissionSettingsFailureTrigger(delaySeconds: number): Promise<void> {
|
|
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<void> {
|
|
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()');
|
|
}
|