Files
hongjr03 7f09fb1f13 feat(hub): drop redundant /admin/org/:slug path + release v0.0.35 (#11)
Silo hostname already carries tenancy. Admin SPA routes become /admin/..., legacy bookmarks redirect, login lands on /admin.

Co-authored-by: Hong Jiarong <me@jrhim.com>
Co-committed-by: Hong Jiarong <me@jrhim.com>
2026-07-19 01:36:10 +08:00

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",
});
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" },
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");
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/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/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");
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]!;
}