/** * Org members + teams + team-access API tests. */ import Fastify from "fastify"; import { afterAll, beforeEach, describe, expect, it } from "vitest"; import { registerAdminPlugin } from "../../src/admin/plugin.js"; import { mintSessionToken, sessionCookieHeader, } from "../../src/admin/routes/authRoutes.js"; import { DEFAULT_ORG_ID, prisma, resetDb, testSecretEnvelope } from "./helpers.js"; import { addOrgMember } from "../../src/org/members.js"; import { addTeamMember, archiveTeam, createTeam, updateTeam } from "../../src/org/teams.js"; import { FeishuApplicationConnectionService } from "../../src/connections/feishuApplicationConnections.js"; const SESSION_SECRET = "integration-test-session-secret"; async function buildApp() { 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: "/tmp/cph-test-ws", secretEnvelope: testSecretEnvelope, cookieSecure: false, }); await app.ready(); return app; } async function seedOwner(): Promise { await prisma.user.create({ data: { id: "u-owner", feishuOpenId: "ou_owner", displayName: "Owner" }, }); await prisma.organizationMembership.create({ data: { organizationId: DEFAULT_ORG_ID, userId: "u-owner", role: "OWNER" }, }); return mintSessionToken({ userId: "u-owner", feishuOpenId: "ou_owner" }, SESSION_SECRET); } describe("admin members + teams API", () => { beforeEach(async () => { await resetDb(); }); afterAll(async () => { await prisma.$disconnect(); }); it("adds members and enforces last-OWNER protection", async () => { const token = await seedOwner(); const connection = await new FeishuApplicationConnectionService(prisma, testSecretEnvelope, async () => {}) .rotateCustomerApplication({ organizationId: DEFAULT_ORG_ID, actorUserId: "u-owner", appId: "cli_members", appSecret: "members-secret", botOpenId: "ou_members_bot", }); const app = await buildApp(); try { const cookie = sessionCookieHeader(token); const add = await app.inject({ method: "POST", url: "/api/org/test-default/members", headers: { cookie, "content-type": "application/json" }, payload: { feishuOpenId: "ou_teacher", displayName: "Teacher", role: "ADMIN" }, }); expect(add.statusCode).toBe(201); const teacher = add.json() as { userId: string; role: string }; expect(teacher.role).toBe("ADMIN"); await expect(prisma.feishuUserIdentity.findUnique({ where: { connectionId_openId: { connectionId: connection.id, openId: "ou_teacher", }, }, })).resolves.not.toBeNull(); const list = await app.inject({ method: "GET", url: "/api/org/test-default/members", headers: { cookie }, }); expect(list.statusCode).toBe(200); const members = (list.json() as { members: Array<{ userId: string; feishuOpenId: string | null; identityStatus: string }>; }).members; expect(members).toHaveLength(2); expect(members.find((member) => member.userId === "u-owner")).toMatchObject({ feishuOpenId: null, identityStatus: "UNLINKED", }); expect(members.find((member) => member.userId === teacher.userId)).toMatchObject({ feishuOpenId: "ou_teacher", identityStatus: "SCOPED", }); const refuse = await app.inject({ method: "POST", url: "/api/org/test-default/members/u-owner/revoke", headers: { cookie }, }); expect(refuse.statusCode).toBe(403); expect(JSON.stringify(refuse.json())).toContain("last OWNER"); } finally { await app.close(); } }); it("creates team, adds member, grants project access, archives team", async () => { const token = await seedOwner(); await prisma.user.create({ data: { id: "u-m", feishuOpenId: "ou_m", displayName: "M" }, }); await prisma.organizationMembership.create({ data: { organizationId: DEFAULT_ORG_ID, userId: "u-m", role: "MEMBER" }, }); await prisma.project.create({ data: { id: "p1", organizationId: DEFAULT_ORG_ID, name: "P1", workspaceDir: "/tmp/p1", }, }); const app = await buildApp(); try { const cookie = sessionCookieHeader(token); const teamRes = await app.inject({ method: "POST", url: "/api/org/test-default/teams", headers: { cookie, "content-type": "application/json" }, payload: { slug: "math", name: "Math Team" }, }); expect(teamRes.statusCode).toBe(201); const team = teamRes.json() as { id: string }; const memberRes = await app.inject({ method: "POST", url: `/api/org/test-default/teams/${team.id}/members`, headers: { cookie, "content-type": "application/json" }, payload: { userId: "u-m" }, }); expect(memberRes.statusCode).toBe(201); const grantRes = await app.inject({ method: "PUT", url: "/api/org/test-default/projects/p1/team-access", headers: { cookie, "content-type": "application/json" }, payload: { teamId: team.id, role: "EDIT" }, }); expect(grantRes.statusCode).toBe(200); expect(grantRes.json()).toEqual( expect.objectContaining({ teamId: team.id, role: "EDIT" }), ); const listAccess = await app.inject({ method: "GET", url: "/api/org/test-default/projects/p1/team-access", headers: { cookie }, }); expect((listAccess.json() as { access: unknown[] }).access).toHaveLength(1); const archive = await app.inject({ method: "POST", url: `/api/org/test-default/teams/${team.id}/archive`, headers: { cookie }, }); expect(archive.statusCode).toBe(200); expect(archive.json()).toEqual( expect.objectContaining({ archived: true, teamId: team.id }), ); const after = await app.inject({ method: "GET", url: "/api/org/test-default/projects/p1/team-access", headers: { cookie }, }); expect((after.json() as { access: unknown[] }).access).toHaveLength(0); } finally { await app.close(); } }); it("blocks direct member and team mutations for a suspended organization", async () => { await seedOwner(); await prisma.user.create({ data: { id: "u-direct-member", feishuOpenId: "ou_direct", displayName: "Direct" } }); await prisma.organizationMembership.create({ data: { organizationId: DEFAULT_ORG_ID, userId: "u-direct-member", role: "MEMBER" }, }); const team = await prisma.team.create({ data: { organizationId: DEFAULT_ORG_ID, slug: "existing", name: "Existing" }, }); await prisma.organization.update({ where: { id: DEFAULT_ORG_ID }, data: { status: "SUSPENDED" } }); const expected = `organization ${DEFAULT_ORG_ID} is SUSPENDED`; await expect(addOrgMember(prisma, { organizationId: DEFAULT_ORG_ID, actorRole: "OWNER", feishuOpenId: "ou_blocked", role: "MEMBER", })).rejects.toThrow(expected); await expect(createTeam(prisma, { organizationId: DEFAULT_ORG_ID, slug: "blocked", name: "Blocked", })).rejects.toThrow(expected); await expect(updateTeam(prisma, { organizationId: DEFAULT_ORG_ID, teamId: team.id, name: "Changed", })).rejects.toThrow(expected); await expect(addTeamMember(prisma, { organizationId: DEFAULT_ORG_ID, teamId: team.id, userId: "u-direct-member", })).rejects.toThrow(expected); await expect(archiveTeam(prisma, { organizationId: DEFAULT_ORG_ID, teamId: team.id, })).rejects.toThrow(expected); await expect(prisma.user.findUnique({ where: { feishuOpenId: "ou_blocked" } })).resolves.toBeNull(); await expect(prisma.team.findUniqueOrThrow({ where: { id: team.id } })).resolves.toMatchObject({ name: "Existing", archivedAt: null, }); }); });