/** * 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, seedTestOrganization, testSecretEnvelope } from "./helpers.js"; import { FeishuApplicationConnectionService } from "../../src/connections/feishuApplicationConnections.js"; import { upsertScopedFeishuIdentity } from "../../src/feishu/identityNamespace.js"; const SESSION_SECRET = "integration-test-session-secret"; const PUBLIC_BASE = "http://127.0.0.1:8788"; async function buildApp(fetchImpl?: typeof fetch, allowLegacyFeishuOAuth = true) { 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", secretEnvelope: testSecretEnvelope, allowLegacyFeishuOAuth, 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("does not expose unscoped OAuth when the explicit compatibility switch is off", async () => { const app = await buildApp(undefined, false); try { const start = await app.inject({ method: "GET", url: "/auth/feishu" }); expect(start.statusCode).toBe(404); const nonce = "legacy-disabled"; const state = signOAuthState({ nonce, returnTo: "/admin" }, SESSION_SECRET); const callback = await app.inject({ method: "GET", url: `/auth/feishu/callback?code=ok&state=${encodeURIComponent(state)}`, headers: { cookie: `${OAUTH_STATE_COOKIE_NAME}=${nonce}` }, }); expect(callback.statusCode).toBe(302); expect(callback.headers.location).toContain("oauth_failed"); } 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("binds Organization OAuth state, identity, and session to the intended connection", async () => { await seedUser("scoped-owner", "legacy_scoped_owner", "OWNER"); const connections = new FeishuApplicationConnectionService(prisma, testSecretEnvelope, async () => {}); const connection = await connections.rotateCustomerApplication({ organizationId: DEFAULT_ORG_ID, actorUserId: "scoped-owner", appId: "cli_customer_oauth", appSecret: "customer-oauth-secret", botOpenId: "ou_customer_bot", }); const identity = await upsertScopedFeishuIdentity(prisma, { connectionId: connection.id, openId: "ou_scoped_user", displayName: "Invited User", }); await seedTestOrganization("org_scoped_other", "scoped-other"); await prisma.organizationMembership.create({ data: { organizationId: "org_scoped_other", userId: identity.userId, role: "ADMIN" }, }); const fetchImpl = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { const url = String(input); if (url.includes("/oauth/token")) { expect(JSON.parse(String(init?.body))).toMatchObject({ client_id: "cli_customer_oauth" }); return Response.json({ code: 0, access_token: "scoped-user-token" }); } if (url.includes("/user_info")) { return Response.json({ code: 0, data: { open_id: "ou_scoped_user", union_id: "on_scoped_union", name: "Scoped User", }, }); } throw new Error(`unexpected ${url}`); }); const app = await buildApp(fetchImpl as unknown as typeof fetch); try { const start = await app.inject({ method: "GET", url: "/auth/feishu/test-default?returnTo=/admin/org/test-default/settings", }); expect(start.statusCode).toBe(302); const authorize = new URL(String(start.headers.location)); expect(authorize.searchParams.get("client_id")).toBe("cli_customer_oauth"); const state = authorize.searchParams.get("state"); expect(state).not.toBeNull(); const nonceCookie = cookiePair(start.headers["set-cookie"], OAUTH_STATE_COOKIE_NAME); const callback = await app.inject({ method: "GET", url: `/auth/feishu/callback?code=ok&state=${encodeURIComponent(state!)}`, headers: { cookie: nonceCookie }, }); expect(callback.statusCode).toBe(302); expect(callback.headers.location).toBe("/admin/org/test-default/settings"); const sessionCookie = cookiePair(callback.headers["set-cookie"], "cph_session"); const me = await app.inject({ method: "GET", url: "/api/me", headers: { cookie: sessionCookie } }); expect(me.statusCode).toBe(200); expect(me.json()).toMatchObject({ user: { id: identity.userId, displayName: "Scoped User" }, organizations: [expect.objectContaining({ slug: "test-default", role: "MEMBER" })], }); expect((me.json() as { organizations: unknown[] }).organizations).toHaveLength(1); const crossOrganization = await app.inject({ method: "GET", url: "/api/org/scoped-other", headers: { cookie: sessionCookie }, }); expect(crossOrganization.statusCode).toBe(403); expect(crossOrganization.json()).toMatchObject({ error: { code: "forbidden" } }); await expect(prisma.feishuUserIdentity.findUniqueOrThrow({ where: { id: identity.identityId }, select: { unionId: true }, })).resolves.toEqual({ unionId: "on_scoped_union" }); await expect(prisma.auditEntry.count({ where: { organizationId: DEFAULT_ORG_ID, actorUserId: identity.userId, action: "organization_member.oauth_auto_joined", }, })).resolves.toBe(1); const defaultStart = await app.inject({ method: "GET", url: "/auth/feishu/test-default" }); const defaultAuthorize = new URL(String(defaultStart.headers.location)); const defaultState = defaultAuthorize.searchParams.get("state"); expect(defaultState).not.toBeNull(); const defaultCallback = await app.inject({ method: "GET", url: `/auth/feishu/callback?code=ok&state=${encodeURIComponent(defaultState!)}`, headers: { cookie: cookiePair(defaultStart.headers["set-cookie"], OAUTH_STATE_COOKIE_NAME) }, }); expect(defaultCallback.statusCode).toBe(302); expect(defaultCallback.headers.location).toBe( "/auth/feishu/complete?org=Test%20Default%20Organization", ); const complete = await app.inject({ method: "GET", url: defaultCallback.headers.location! }); expect(complete.statusCode).toBe(200); expect(complete.headers["content-type"]).toContain("text/html"); expect(complete.body).toContain("登录并加入组织成功"); expect(complete.body).toContain("Test Default Organization"); await connections.disable({ organizationId: DEFAULT_ORG_ID, actorUserId: "scoped-owner" }); const revoked = await app.inject({ method: "GET", url: "/api/me", headers: { cookie: sessionCookie } }); expect(revoked.statusCode).toBe(401); } finally { await app.close(); } }); it("does not restore a revoked Organization membership during scoped OAuth", async () => { await seedUser("revoked-owner", "legacy_revoked_owner", "OWNER"); const connections = new FeishuApplicationConnectionService(prisma, testSecretEnvelope, async () => {}); const connection = await connections.rotateCustomerApplication({ organizationId: DEFAULT_ORG_ID, actorUserId: "revoked-owner", appId: "cli_revoked_oauth", appSecret: "revoked-oauth-secret", botOpenId: "ou_revoked_bot", }); const identity = await upsertScopedFeishuIdentity(prisma, { connectionId: connection.id, openId: "ou_revoked_user", displayName: "Revoked User", }); await prisma.organizationMembership.create({ data: { organizationId: DEFAULT_ORG_ID, userId: identity.userId, role: "MEMBER", revokedAt: new Date(), }, }); const fetchImpl = vi.fn(async (input: RequestInfo | URL) => { const url = String(input); if (url.includes("/oauth/token")) { return Response.json({ code: 0, access_token: "revoked-user-token" }); } if (url.includes("/user_info")) { return Response.json({ code: 0, data: { open_id: "ou_revoked_user", name: "Revoked User" }, }); } throw new Error(`unexpected ${url}`); }); const app = await buildApp(fetchImpl as unknown as typeof fetch); try { const start = await app.inject({ method: "GET", url: "/auth/feishu/test-default" }); const authorize = new URL(String(start.headers.location)); const state = authorize.searchParams.get("state"); expect(state).not.toBeNull(); const callback = await app.inject({ method: "GET", url: `/auth/feishu/callback?code=ok&state=${encodeURIComponent(state!)}`, headers: { cookie: cookiePair(start.headers["set-cookie"], OAUTH_STATE_COOKIE_NAME) }, }); expect(callback.statusCode).toBe(302); await expect(prisma.organizationMembership.count({ where: { organizationId: DEFAULT_ORG_ID, userId: identity.userId, revokedAt: null }, })).resolves.toBe(0); await expect(prisma.auditEntry.count({ where: { action: "organization_member.oauth_auto_joined", actorUserId: identity.userId }, })).resolves.toBe(0); } 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(); } }); }); function cookiePair(header: string | string[] | undefined, name: string): string { const values = Array.isArray(header) ? header : [header ?? ""]; const cookie = values.find((value) => value.startsWith(`${name}=`)); if (cookie === undefined) throw new Error(`missing cookie: ${name}`); return cookie.split(";", 1)[0]!; }