/** * Org admin auth + requireOrgRole integration tests (ADR-0021). */ import Fastify from "fastify"; import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; import { registerAdminPlugin } from "../../src/admin/plugin.js"; import { mintSessionToken, sessionCookieHeader, } from "../../src/admin/routes/authRoutes.js"; import { OAUTH_STATE_COOKIE_NAME, signOAuthState } from "../../src/admin/auth/session.js"; import { DEFAULT_ORG_ID, prisma, resetDb } from "./helpers.js"; const SESSION_SECRET = "integration-test-session-secret"; const PUBLIC_BASE = "http://127.0.0.1:8788"; async function buildApp(fetchImpl?: typeof fetch) { const app = Fastify({ logger: false }); await registerAdminPlugin(app, { prisma, sessionSecret: SESSION_SECRET, publicBaseUrl: PUBLIC_BASE, feishuAppId: "cli_test", feishuAppSecret: "secret_test", projectWorkspaceRoot: "/tmp/cph-test-workspaces", cookieSecure: false, ...(fetchImpl !== undefined ? { fetchImpl } : {}), }); await app.ready(); return app; } async function seedUser( id: string, openId: string, role: "OWNER" | "ADMIN" | "MEMBER" = "ADMIN", orgId: string = DEFAULT_ORG_ID, ): Promise { await prisma.user.create({ data: { id, feishuOpenId: openId, displayName: id }, }); await prisma.organizationMembership.create({ data: { organizationId: orgId, userId: id, role }, }); } describe("admin auth + org API guards", () => { beforeEach(async () => { await resetDb(); }); afterAll(async () => { await prisma.$disconnect(); }); it("GET /api/me returns 401 without session", async () => { const app = await buildApp(); try { const res = await app.inject({ method: "GET", url: "/api/me" }); expect(res.statusCode).toBe(401); expect(res.json().error.code).toBe("unauthenticated"); } finally { await app.close(); } }); it("GET /api/me returns user and memberships with valid session", async () => { await seedUser("u-admin", "ou_admin", "ADMIN"); const app = await buildApp(); try { const token = mintSessionToken( { userId: "u-admin", feishuOpenId: "ou_admin" }, SESSION_SECRET, ); const res = await app.inject({ method: "GET", url: "/api/me", headers: { cookie: sessionCookieHeader(token) }, }); expect(res.statusCode).toBe(200); const body = res.json() as { user: { id: string; feishuOpenId: string }; organizations: Array<{ slug: string; role: string }>; }; expect(body.user.id).toBe("u-admin"); expect(body.user.feishuOpenId).toBe("ou_admin"); expect(body.organizations).toEqual([ expect.objectContaining({ slug: "test-default", role: "ADMIN" }), ]); } finally { await app.close(); } }); it("org summary requires OWNER or ADMIN", async () => { await seedUser("u-member", "ou_member", "MEMBER"); await seedUser("u-admin", "ou_admin", "ADMIN"); const app = await buildApp(); try { const memberToken = mintSessionToken( { userId: "u-member", feishuOpenId: "ou_member" }, SESSION_SECRET, ); const memberRes = await app.inject({ method: "GET", url: "/api/org/test-default", headers: { cookie: sessionCookieHeader(memberToken) }, }); expect(memberRes.statusCode).toBe(403); const adminToken = mintSessionToken( { userId: "u-admin", feishuOpenId: "ou_admin" }, SESSION_SECRET, ); const adminRes = await app.inject({ method: "GET", url: "/api/org/test-default", headers: { cookie: sessionCookieHeader(adminToken) }, }); expect(adminRes.statusCode).toBe(200); expect(adminRes.json()).toEqual({ organization: expect.objectContaining({ id: DEFAULT_ORG_ID, slug: "test-default", }), actorRole: "ADMIN", }); } finally { await app.close(); } }); it("PATCH settings updates membersCanCreateProjects", async () => { await seedUser("u-owner", "ou_owner", "OWNER"); const app = await buildApp(); try { const token = mintSessionToken( { userId: "u-owner", feishuOpenId: "ou_owner" }, SESSION_SECRET, ); const res = await app.inject({ method: "PATCH", url: "/api/org/test-default/settings", headers: { cookie: sessionCookieHeader(token), "content-type": "application/json", }, payload: { membersCanCreateProjects: false }, }); expect(res.statusCode).toBe(200); expect(res.json()).toEqual({ membersCanCreateProjects: false }); const getRes = await app.inject({ method: "GET", url: "/api/org/test-default/settings", headers: { cookie: sessionCookieHeader(token) }, }); expect(getRes.json()).toEqual({ membersCanCreateProjects: false }); } finally { await app.close(); } }); it("GET /auth/feishu redirects to Feishu authorize URL", async () => { const app = await buildApp(); try { const res = await app.inject({ method: "GET", url: "/auth/feishu?returnTo=/admin/org/test-default", }); expect(res.statusCode).toBe(302); const location = res.headers.location; expect(location).toBeTypeOf("string"); const url = new URL(location as string); expect(url.hostname).toBe("accounts.feishu.cn"); expect(url.searchParams.get("client_id")).toBe("cli_test"); expect(url.searchParams.get("redirect_uri")).toBe( `${PUBLIC_BASE}/auth/feishu/callback`, ); expect(url.searchParams.get("state")).toBeTruthy(); const setCookie = res.headers["set-cookie"]; expect(JSON.stringify(setCookie)).toContain(OAUTH_STATE_COOKIE_NAME); } finally { await app.close(); } }); it("OAuth callback upserts user and sets session cookie", async () => { const fetchImpl = vi.fn(async (input: RequestInfo | URL) => { const url = String(input); if (url.includes("/oauth/token")) { return new Response( JSON.stringify({ code: 0, access_token: "u-tok", token_type: "Bearer", expires_in: 7200 }), { status: 200, headers: { "Content-Type": "application/json" } }, ); } if (url.includes("/user_info")) { return new Response( JSON.stringify({ code: 0, data: { open_id: "ou_new", name: "New User", avatar_url: null }, }), { status: 200, headers: { "Content-Type": "application/json" } }, ); } throw new Error(`unexpected ${url}`); }); const app = await buildApp(fetchImpl as unknown as typeof fetch); try { const nonce = "nonce-test-1"; const state = signOAuthState( { nonce, returnTo: "/admin/org/test-default" }, SESSION_SECRET, ); const res = await app.inject({ method: "GET", url: `/auth/feishu/callback?code=ok&state=${encodeURIComponent(state)}`, headers: { cookie: `${OAUTH_STATE_COOKIE_NAME}=${nonce}` }, }); expect(res.statusCode).toBe(302); expect(res.headers.location).toBe("/admin/org/test-default"); expect(JSON.stringify(res.headers["set-cookie"])).toContain("cph_session="); const user = await prisma.user.findUnique({ where: { feishuOpenId: "ou_new" } }); expect(user?.displayName).toBe("New User"); } finally { await app.close(); } }); it("unknown org slug returns 404 for admin", async () => { await seedUser("u-admin", "ou_admin", "ADMIN"); const app = await buildApp(); try { const token = mintSessionToken( { userId: "u-admin", feishuOpenId: "ou_admin" }, SESSION_SECRET, ); const res = await app.inject({ method: "GET", url: "/api/org/does-not-exist", headers: { cookie: sessionCookieHeader(token) }, }); expect(res.statusCode).toBe(404); } finally { await app.close(); } }); });