const Fastify = require("fastify"); const fastifyCookie = require("@fastify/cookie"); const { AppError } = require("../../../src/utils/AppError"); const wecomAuthRoutes = require("../../../src/routes/wecomAuth"); const { signPayload, verifyToken } = require("../../../src/utils/signedUrl"); const NOW = new Date("2026-03-22T11:00:00.000Z"); const EMPLOYEE_ID = "11111111-1111-4111-8111-111111111111"; const JWT_SECRET = "test-wecom-secret"; const originalFetch = global.fetch; function buildPrisma(overrides = {}) { return { wecomIdentityBinding: { deleteMany: vi.fn().mockResolvedValue({ count: 1 }), findFirst: vi.fn().mockResolvedValue(null), findUnique: vi.fn().mockResolvedValue(null), upsert: vi.fn().mockResolvedValue({ id: "binding-1" }), ...(overrides.wecomIdentityBinding ?? {}), }, employee: { findUnique: vi.fn().mockResolvedValue({ id: EMPLOYEE_ID, status: "active" }), ...(overrides.employee ?? {}), }, auditLog: { create: vi.fn().mockResolvedValue({ id: "audit-1" }), ...(overrides.auditLog ?? {}), }, ...overrides, }; } async function buildApp({ config = {}, prisma = buildPrisma(), fastifyOptions = {} } = {}) { const app = Fastify(fastifyOptions); await app.register(fastifyCookie); app.decorate("config", { appOrigin: "https://oa.hyinda.com", jwtSecret: JWT_SECRET, refreshCookieName: "refresh_token", refreshTtlSeconds: 7 * 24 * 60 * 60, cookieSecure: true, wecomEnabled: true, wecomEntryEnabled: true, wecomOauthEnabled: true, wecomCorpId: "wx-test-corp", wecomAgentId: "1000001", wecomAgentSecret: "wecom-secret", wecomRedirectUri: "https://oa.hyinda.com/api/auth/wecom/callback", wecomAllowedReturnHosts: "oa.hyinda.com", ...config, }); app.decorate("prisma", prisma); app.decorate("issueAccessToken", vi.fn().mockReturnValue("access-token")); app.decorate("authenticate", async (request) => { if (!request.headers.authorization) { throw new AppError({ code: "AUTH_001", message: "未登录", statusCode: 401 }); } request.employee = { id: EMPLOYEE_ID }; }); app.setErrorHandler((error, request, reply) => { if (error instanceof AppError) { return reply.status(error.statusCode).send({ requestId: request.id, error: { code: error.code, message: error.message, details: error.details ?? {} }, }); } return reply.status(500).send({ requestId: request.id, error: { code: "SYS_001", message: "服务器内部错误", details: {} }, }); }); await app.register(wecomAuthRoutes, { prefix: "/api/auth" }); return app; } function mockWecomFetch({ userId = "wecom-user-1", openId = "wecom-open-1" } = {}) { global.fetch = vi.fn() .mockResolvedValueOnce({ ok: true, json: async () => ({ errcode: 0, access_token: "access-token" }), }) .mockResolvedValueOnce({ ok: true, json: async () => ({ errcode: 0, UserId: userId, OpenId: openId, DeviceId: "device-1" }), }); } function mockWecomFetchWithPayload(payload) { global.fetch = vi.fn() .mockResolvedValueOnce({ ok: true, json: async () => ({ errcode: 0, access_token: "access-token" }), }) .mockResolvedValueOnce({ ok: true, json: async () => payload, }); } afterEach(() => { global.fetch = originalFetch; vi.restoreAllMocks(); }); describe("wecom auth routes R27", () => { it("returns entry-disabled config while keeping bind available", async () => { const app = await buildApp({ config: { wecomEntryEnabled: false } }); const response = await app.inject({ method: "GET", url: "/api/auth/wecom/config" }); expect(response.statusCode).toBe(200); expect(response.json().data).toEqual(expect.objectContaining({ enabled: false, entryEnabled: false, bindEnabled: true, oauthEnabled: true, status: "disabled", defaultReturnTo: "/portal", loginUrl: null, statusReasons: expect.arrayContaining(["entry_disabled"]), })); await app.close(); }); it("returns misconfigured status when corp id is missing", async () => { const app = await buildApp({ config: { wecomCorpId: "" } }); const response = await app.inject({ method: "GET", url: "/api/auth/wecom/config" }); expect(response.statusCode).toBe(200); expect(response.json().data).toEqual(expect.objectContaining({ enabled: false, bindEnabled: false, status: "misconfigured", statusReasons: expect.arrayContaining(["corp_id_missing"]), corpId: "", agentId: "1000001", })); await app.close(); }); it("sanitizes login returnTo to allowed hosts or local paths", async () => { const app = await buildApp(); const response = await app.inject({ method: "GET", url: "/api/auth/wecom/login?returnTo=https%3A%2F%2Fevil.example.com%2Fsteal", }); expect(response.statusCode).toBe(302); const location = response.headers.location; expect(location).toContain("open.weixin.qq.com/connect/oauth2/authorize"); const state = new URL(location).searchParams.get("state"); const verified = verifyToken(state, JWT_SECRET); expect(verified.ok).toBe(true); expect(verified.payload.returnTo).toBe("/portal"); expect(verified.payload.mode).toBe("login"); await app.close(); }); it("redirects unbound users back to login with wecom hint", async () => { mockWecomFetch({ userId: "wecom-unbound" }); const prisma = buildPrisma({ wecomIdentityBinding: { findUnique: vi.fn().mockResolvedValue(null), }, }); const app = await buildApp({ prisma }); const state = signPayload({ exp: Date.now() + 60_000, nonce: "nonce-login", returnTo: "/portal", mode: "login", }, JWT_SECRET); const response = await app.inject({ method: "GET", url: `/api/auth/wecom/callback?state=${encodeURIComponent(state)}&code=test-code`, }); expect(response.statusCode).toBe(302); expect(response.headers.location).toBe("https://oa.hyinda.com/login?redirect=%2Fportal&wecom=unbound"); expect(String(response.headers["set-cookie"] ?? "")).toContain("wecom_login_bind="); await app.close(); }); it("accepts lowercase userid and redirects unbound users back to login", async () => { mockWecomFetchWithPayload({ errcode: 0, errmsg: "ok", userid: "wecom-unbound-lowercase", }); const prisma = buildPrisma({ wecomIdentityBinding: { findUnique: vi.fn().mockResolvedValue(null), }, }); const app = await buildApp({ prisma }); const state = signPayload({ exp: Date.now() + 60_000, nonce: "nonce-login-lowercase-userid", returnTo: "/portal", mode: "login", }, JWT_SECRET); const response = await app.inject({ method: "GET", url: `/api/auth/wecom/callback?state=${encodeURIComponent(state)}&code=test-code`, }); expect(response.statusCode).toBe(302); expect(response.headers.location).toBe("https://oa.hyinda.com/login?redirect=%2Fportal&wecom=unbound"); expect(String(response.headers["set-cookie"] ?? "")).toContain("wecom_login_bind="); expect(prisma.wecomIdentityBinding.findUnique).toHaveBeenCalledWith({ where: { external_user_id: "wecom-unbound-lowercase" }, select: { employee_id: true, employee: { select: { id: true, status: true, token_version: true } } }, }); await app.close(); }); it("returns structured diagnostics when wecom callback has only open id", async () => { const logs = []; mockWecomFetchWithPayload({ errcode: 0, errmsg: "ok", openid: "lowercase-openid-value", user_ticket: "ticket-value", corpid: "corp-id-value", }); const app = await buildApp({ fastifyOptions: { logger: { level: "warn", stream: { write(message) { logs.push(JSON.parse(message)); }, }, }, }, }); const state = signPayload({ exp: Date.now() + 60_000, nonce: "nonce-openid-only", returnTo: "/portal", mode: "login", }, JWT_SECRET); const response = await app.inject({ method: "GET", url: `/api/auth/wecom/callback?state=${encodeURIComponent(state)}&code=test-code`, }); expect(response.statusCode).toBe(401); expect(response.json().error).toEqual(expect.objectContaining({ code: "AUTH_003", message: "企业微信认证失败", details: expect.objectContaining({ wecom: "missing_user_id", hasOpenId: true, mode: "login", }), })); expect(logs.some((entry) => ( entry.msg === "wecom callback missing internal user id" && entry.mode === "login" && entry.hasUserId === false && entry.hasOpenId === true && entry.hasDeviceId === false && entry.wecomErrCode === 0 && entry.wecomErrMsg === "ok" && Array.isArray(entry.payloadKeys) && entry.payloadKeys.join(",") === "corpid,errcode,errmsg,openid,user_ticket" && entry.fieldPresence?.UserId === false && entry.fieldPresence?.userid === false && entry.fieldPresence?.OpenId === false && entry.fieldPresence?.openid === true && entry.fieldPresence?.external_userid === false && entry.fieldPresence?.user_ticket === true && entry.fieldPresence?.corpid === true ))).toBe(true); expect(logs.some((entry) => ( entry.msg === "wecom callback missing internal user id" && typeof entry.requestId === "string" ))).toBe(true); expect(response.json().error).toEqual(expect.objectContaining({ code: "AUTH_003", message: "企业微信认证失败", details: expect.objectContaining({ wecom: "missing_user_id", hasOpenId: true, mode: "login", }), })); await app.close(); }); it("creates bind audit log on successful wecom bind callback", async () => { mockWecomFetch({ userId: "wecom-bind-1" }); const prisma = buildPrisma(); const app = await buildApp({ prisma }); const state = signPayload({ exp: Date.now() + 60_000, nonce: "nonce-bind", returnTo: "/settings/profile", mode: "bind", }, JWT_SECRET); const bindCookie = signPayload({ exp: Date.now() + 60_000, employeeId: EMPLOYEE_ID, returnTo: "/settings/profile", }, JWT_SECRET); const response = await app.inject({ method: "GET", url: `/api/auth/wecom/callback?state=${encodeURIComponent(state)}&code=test-code`, headers: { cookie: `wecom_bind=${bindCookie}`, }, }); expect(response.statusCode).toBe(302); expect(response.headers.location).toBe("https://oa.hyinda.com/settings/profile?wecomBind=success"); expect(prisma.wecomIdentityBinding.upsert).toHaveBeenCalled(); expect(prisma.auditLog.create).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ actor_id: EMPLOYEE_ID, action: "AUTH_WECOM_BIND", target_type: "employee", target_id: EMPLOYEE_ID, }), })); await app.close(); }); it("writes audit log when unbinding wecom identity", async () => { const prisma = buildPrisma(); const app = await buildApp({ prisma }); const response = await app.inject({ method: "POST", url: "/api/auth/wecom/unbind", headers: { authorization: "Bearer test-token", }, payload: {}, }); expect(response.statusCode).toBe(200); expect(prisma.wecomIdentityBinding.deleteMany).toHaveBeenCalledWith({ where: { employee_id: EMPLOYEE_ID } }); expect(prisma.auditLog.create).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ actor_id: EMPLOYEE_ID, action: "AUTH_WECOM_UNBIND", }), })); await app.close(); }); });