forked from bai/curriculum-project-hub
11de9e81db
Default returnTo=/admin previously landed on the static complete page meant for chat onboarding; send users to /admin/org/:slug instead.
444 lines
16 KiB
TypeScript
444 lines
16 KiB
TypeScript
/**
|
|
* 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<void> {
|
|
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("/admin/org/test-default");
|
|
|
|
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]!;
|
|
}
|