forked from bai/curriculum-project-hub
feat: add org admin project onboarding foundation
This commit is contained in:
@@ -44,6 +44,8 @@ export async function resetDb(): Promise<void> {
|
||||
"AgentRun",
|
||||
"AgentSession",
|
||||
"ProjectGroupBinding",
|
||||
"Folder",
|
||||
"OrganizationProjectSettings",
|
||||
"OrganizationMembership",
|
||||
"PlatformRoleAssignment",
|
||||
"User",
|
||||
@@ -68,6 +70,25 @@ export async function seedTestOrganization(
|
||||
name: "Test Default Organization",
|
||||
},
|
||||
});
|
||||
await prisma.organizationProjectSettings.upsert({
|
||||
where: { organizationId: id },
|
||||
update: {},
|
||||
create: { organizationId: id, membersCanCreateProjects: true },
|
||||
});
|
||||
const inbox = await prisma.folder.findFirst({
|
||||
where: { organizationId: id, parentId: null, name: "Inbox", archivedAt: null },
|
||||
select: { id: true },
|
||||
});
|
||||
if (inbox === null) {
|
||||
await prisma.folder.create({
|
||||
data: {
|
||||
id: `folder_inbox_${id}`,
|
||||
organizationId: id,
|
||||
name: "Inbox",
|
||||
sortKey: "000000",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** A logger that discards everything (tests don't need fastify's pino). */
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import { mkdtemp, rm, stat } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { DEFAULT_ORG_ID, prisma, resetDb, seedTestOrganization } from "./helpers.js";
|
||||
import {
|
||||
archiveFeishuChatBinding,
|
||||
bindFeishuChatToProject,
|
||||
createFolder,
|
||||
createProjectFromFeishuChat,
|
||||
createProjectFromOrgAdmin,
|
||||
setMembersCanCreateProjects,
|
||||
} from "../../src/projectOnboarding.js";
|
||||
|
||||
const workspaceRoots: string[] = [];
|
||||
|
||||
describe("ADR-0021 project onboarding", () => {
|
||||
beforeEach(async () => {
|
||||
await resetDb();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
while (workspaceRoots.length > 0) {
|
||||
const root = workspaceRoots.pop();
|
||||
if (root !== undefined) await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
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);
|
||||
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("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();
|
||||
});
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -255,7 +255,7 @@ function mockPrisma(): PrismaClient {
|
||||
create: vi.fn(async () => ({ id: "receipt-1" })),
|
||||
},
|
||||
projectGroupBinding: {
|
||||
findUnique: vi.fn(async () => ({ projectId: "project-1" })),
|
||||
findFirst: vi.fn(async () => ({ projectId: "project-1" })),
|
||||
},
|
||||
project: {
|
||||
findUnique: vi.fn(async () => ({ workspaceDir: "/tmp/cph-project" })),
|
||||
|
||||
Reference in New Issue
Block a user