/** * Org explorer API integration tests (ADR-0021). */ import { mkdtemp, rm, stat } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import Fastify from "fastify"; import { afterAll, afterEach, beforeEach, describe, expect, it } from "vitest"; import { registerAdminPlugin } from "../../src/admin/plugin.js"; import { archiveFolder, archiveProject, moveOrgProjectToFolder, renameFolder, renameProject, } from "../../src/org/explorer.js"; import { mintSessionToken, sessionCookieHeader, } from "../../src/admin/routes/authRoutes.js"; import { DEFAULT_ORG_ID, prisma, resetDb } from "./helpers.js"; const SESSION_SECRET = "integration-test-session-secret"; const workspaceRoots: string[] = []; async function buildApp(workspaceRoot: string) { const app = Fastify({ logger: false }); await registerAdminPlugin(app, { prisma, sessionSecret: SESSION_SECRET, publicBaseUrl: "http://127.0.0.1:8788", feishuAppId: "cli_test", feishuAppSecret: "secret_test", projectWorkspaceRoot: workspaceRoot, cookieSecure: false, }); await app.ready(); return app; } async function seedAdmin(): Promise { await prisma.user.create({ data: { id: "u-admin", feishuOpenId: "ou_admin", displayName: "Admin" }, }); await prisma.organizationMembership.create({ data: { organizationId: DEFAULT_ORG_ID, userId: "u-admin", role: "ADMIN" }, }); return mintSessionToken({ userId: "u-admin", feishuOpenId: "ou_admin" }, SESSION_SECRET); } async function tempWorkspaceRoot(): Promise { const root = await mkdtemp(join(tmpdir(), "cph-admin-explorer-")); workspaceRoots.push(root); return root; } describe("admin explorer API", () => { 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("creates folder + project and lists them in explorer", async () => { const token = await seedAdmin(); const workspaceRoot = await tempWorkspaceRoot(); const app = await buildApp(workspaceRoot); try { const folderRes = await app.inject({ method: "POST", url: "/api/org/test-default/folders", headers: { cookie: sessionCookieHeader(token), "content-type": "application/json", }, payload: { name: "Grade 7", sortKey: "010000" }, }); expect(folderRes.statusCode).toBe(201); const folder = folderRes.json() as { id: string }; const projectRes = await app.inject({ method: "POST", url: "/api/org/test-default/projects", headers: { cookie: sessionCookieHeader(token), "content-type": "application/json", }, payload: { name: "Newton", folderId: folder.id }, }); expect(projectRes.statusCode).toBe(201); const project = projectRes.json() as { projectId: string; folderId: string; workspaceDir: string; }; expect(project.folderId).toBe(folder.id); expect((await stat(project.workspaceDir)).isDirectory()).toBe(true); const explorerRes = await app.inject({ method: "GET", url: "/api/org/test-default/explorer", headers: { cookie: sessionCookieHeader(token) }, }); expect(explorerRes.statusCode).toBe(200); const explorer = explorerRes.json() as { folders: Array<{ id: string; name: string; projectCount: number }>; projects: Array<{ id: string; name: string; folderId: string | null }>; }; expect(explorer.folders.some((f) => f.id === folder.id && f.projectCount === 1)).toBe(true); expect(explorer.projects).toEqual( expect.arrayContaining([ expect.objectContaining({ id: project.projectId, name: "Newton", folderId: folder.id, }), ]), ); } finally { await app.close(); } }); it("moves project between folders and refuses archive of non-empty folder", async () => { const token = await seedAdmin(); const workspaceRoot = await tempWorkspaceRoot(); const app = await buildApp(workspaceRoot); try { const cookie = sessionCookieHeader(token); const a = ( await app.inject({ method: "POST", url: "/api/org/test-default/folders", headers: { cookie, "content-type": "application/json" }, payload: { name: "A" }, }) ).json() as { id: string }; const b = ( await app.inject({ method: "POST", url: "/api/org/test-default/folders", headers: { cookie, "content-type": "application/json" }, payload: { name: "B" }, }) ).json() as { id: string }; const project = ( await app.inject({ method: "POST", url: "/api/org/test-default/projects", headers: { cookie, "content-type": "application/json" }, payload: { name: "P", folderId: a.id }, }) ).json() as { projectId: string }; const moveRes = await app.inject({ method: "PATCH", url: `/api/org/test-default/projects/${project.projectId}/folder`, headers: { cookie, "content-type": "application/json" }, payload: { folderId: b.id }, }); expect(moveRes.statusCode).toBe(200); expect(moveRes.json()).toEqual({ projectId: project.projectId, folderId: b.id, }); const refuse = await app.inject({ method: "POST", url: `/api/org/test-default/folders/${b.id}/archive`, headers: { cookie }, }); expect({ status: refuse.statusCode, body: refuse.json() }).toEqual({ status: 403, body: expect.objectContaining({ error: expect.objectContaining({ code: "forbidden" }), }), }); await app.inject({ method: "POST", url: `/api/org/test-default/projects/${project.projectId}/archive`, headers: { cookie }, }); const ok = await app.inject({ method: "POST", url: `/api/org/test-default/folders/${b.id}/archive`, headers: { cookie }, }); expect(ok.statusCode).toBe(200); expect(ok.json()).toEqual({ archived: true, folderId: b.id }); } finally { await app.close(); } }); it("blocks cross-org project access by id", async () => { const token = await seedAdmin(); await prisma.organization.create({ data: { id: "org_other", slug: "other", name: "Other" }, }); await prisma.project.create({ data: { id: "p-other", organizationId: "org_other", name: "Other project", workspaceDir: "/tmp/other", }, }); const workspaceRoot = await tempWorkspaceRoot(); const app = await buildApp(workspaceRoot); try { const res = await app.inject({ method: "GET", url: "/api/org/test-default/projects/p-other", headers: { cookie: sessionCookieHeader(token) }, }); expect(res.statusCode).toBe(404); } finally { await app.close(); } }); it.each(["SUSPENDED", "ARCHIVED"] as const)( "blocks direct explorer mutations when the organization is %s", async (status) => { await Promise.all([ prisma.folder.create({ data: { id: `folder-rename-${status}`, organizationId: DEFAULT_ORG_ID, name: "Rename" } }), prisma.folder.create({ data: { id: `folder-archive-${status}`, organizationId: DEFAULT_ORG_ID, name: "Archive" } }), prisma.folder.create({ data: { id: `folder-target-${status}`, organizationId: DEFAULT_ORG_ID, name: "Target" } }), prisma.project.create({ data: { id: `project-rename-${status}`, organizationId: DEFAULT_ORG_ID, name: "Rename", workspaceDir: `/tmp/rename-${status}` }, }), prisma.project.create({ data: { id: `project-archive-${status}`, organizationId: DEFAULT_ORG_ID, name: "Archive", workspaceDir: `/tmp/archive-${status}` }, }), prisma.project.create({ data: { id: `project-move-${status}`, organizationId: DEFAULT_ORG_ID, name: "Move", workspaceDir: `/tmp/move-${status}` }, }), ]); await prisma.organization.update({ where: { id: DEFAULT_ORG_ID }, data: { status } }); const expected = `organization ${DEFAULT_ORG_ID} is ${status}`; await expect(renameFolder(prisma, { organizationId: DEFAULT_ORG_ID, folderId: `folder-rename-${status}`, name: "Changed", })).rejects.toThrow(expected); await expect(archiveFolder(prisma, { organizationId: DEFAULT_ORG_ID, folderId: `folder-archive-${status}`, })).rejects.toThrow(expected); await expect(renameProject(prisma, { organizationId: DEFAULT_ORG_ID, projectId: `project-rename-${status}`, name: "Changed", })).rejects.toThrow(expected); await expect(archiveProject(prisma, { organizationId: DEFAULT_ORG_ID, projectId: `project-archive-${status}`, })).rejects.toThrow(expected); await expect(moveOrgProjectToFolder(prisma, { organizationId: DEFAULT_ORG_ID, projectId: `project-move-${status}`, folderId: `folder-target-${status}`, })).rejects.toThrow(expected); await expect(prisma.folder.findUniqueOrThrow({ where: { id: `folder-rename-${status}` } })).resolves.toMatchObject({ name: "Rename", archivedAt: null, }); await expect(prisma.project.findUniqueOrThrow({ where: { id: `project-rename-${status}` } })).resolves.toMatchObject({ name: "Rename", archivedAt: null, }); }, ); });