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, wecomOauthOnboardingEnabled: false, 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: "/mobile", loginUrl: null, statusReasons: expect.arrayContaining(["entry_disabled"]), })); await app.close(); }); it("returns mobile-first login url when entry is ready", async () => { const app = await buildApp(); 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: true, entryEnabled: true, status: "ready", defaultReturnTo: "/mobile", loginUrl: "/api/auth/wecom/login?returnTo=%2Fmobile", })); await app.close(); }); it("keeps onboarding disabled by default", async () => { const app = await buildApp(); const configResponse = await app.inject({ method: "GET", url: "/api/auth/wecom/config" }); const loginResponse = await app.inject({ method: "GET", url: "/api/auth/wecom/login?mode=onboard&returnTo=%2Fmobile", }); expect(configResponse.statusCode).toBe(200); expect(configResponse.json().data).toEqual(expect.objectContaining({ onboardingEnabled: false, onboardingUrl: null, })); expect(loginResponse.statusCode).toBe(404); await app.close(); }); it("uses private-info scope only for the explicit onboarding entry", async () => { const app = await buildApp({ config: { wecomOauthOnboardingEnabled: true } }); const configResponse = await app.inject({ method: "GET", url: "/api/auth/wecom/config" }); const onboardingResponse = await app.inject({ method: "GET", url: "/api/auth/wecom/login?mode=onboard&returnTo=%2Fmobile", }); const loginResponse = await app.inject({ method: "GET", url: "/api/auth/wecom/login?returnTo=%2Fmobile", }); expect(configResponse.json().data).toEqual(expect.objectContaining({ onboardingEnabled: true, onboardingUrl: "/api/auth/wecom/login?returnTo=%2Fmobile&mode=onboard", })); expect(new URL(onboardingResponse.headers.location).searchParams.get("scope")).toBe("snsapi_privateinfo"); expect(new URL(loginResponse.headers.location).searchParams.get("scope")).toBe("snsapi_base"); await app.close(); }); it("does not provision when private-info consent returns no user ticket", async () => { mockWecomFetch({ userId: "wecom-onboard-no-ticket" }); const app = await buildApp({ config: { wecomOauthOnboardingEnabled: true } }); const state = signPayload({ exp: Date.now() + 60_000, nonce: "nonce-onboard-no-ticket", returnTo: "/mobile", mode: "onboard", }, 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=%2Fmobile&wecom=consent_required", ); await app.close(); }); it("does not return a raw provider payload when code exchange fails", async () => { const privateValue = "private-provider-value-must-not-leak"; global.fetch = vi.fn() .mockResolvedValueOnce({ ok: true, json: async () => ({ errcode: 0, access_token: "access-token" }), }) .mockResolvedValueOnce({ ok: true, json: async () => ({ errcode: 40029, errmsg: "invalid code", privateValue }), }); const app = await buildApp({ config: { wecomOauthOnboardingEnabled: true } }); const state = signPayload({ exp: Date.now() + 60_000, nonce: "nonce-onboard-provider-failure", returnTo: "/mobile", mode: "onboard", }, 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.details).toEqual({ wecom: "userinfo_exchange_failed" }); expect(response.body).not.toContain(privateValue); await app.close(); }); it("does not expose credentials from provider network errors", async () => { const privateValue = "network-error-secret-must-not-leak"; global.fetch = vi.fn().mockRejectedValue(new Error(`request failed ${privateValue}`)); const app = await buildApp({ config: { wecomOauthOnboardingEnabled: true, wecomAgentSecret: privateValue, }, }); const state = signPayload({ exp: Date.now() + 60_000, nonce: "nonce-onboard-network-failure", returnTo: "/mobile", mode: "onboard", }, 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.details).toEqual({ wecom: "token_exchange_failed" }); expect(response.body).not.toContain(privateValue); await app.close(); }); it("self-provisions an employee-only account after explicit private-info consent", async () => { global.fetch = vi.fn() .mockResolvedValueOnce({ ok: true, json: async () => ({ errcode: 0, access_token: "access-token" }), }) .mockResolvedValueOnce({ ok: true, json: async () => ({ errcode: 0, userid: "wecom-onboard-user", user_ticket: "private-ticket", }), }) .mockResolvedValueOnce({ ok: true, json: async () => ({ errcode: 0, userid: "wecom-onboard-user", mobile: "19900000001", email: "onboard@example.test", }), }) .mockResolvedValueOnce({ ok: true, json: async () => ({ errcode: 0, userid: "wecom-onboard-user", name: "测试成员", status: 1, department: [2], position: "工程师", }), }) .mockResolvedValueOnce({ ok: true, json: async () => ({ errcode: 0, department: [{ id: 2, name: "研发部" }] }), }); const prisma = buildPrisma({ wecomIdentityBinding: { findUnique: vi.fn().mockResolvedValue(null), upsert: vi.fn().mockResolvedValue({ id: "binding-1" }), }, employee: { findMany: vi.fn().mockResolvedValue([]), create: vi.fn().mockResolvedValue({ id: EMPLOYEE_ID, token_version: 0 }), }, integrationBinding: { findFirst: vi.fn().mockResolvedValue(null), create: vi.fn().mockResolvedValue({ id: "department-binding-1" }), }, department: { findMany: vi.fn().mockResolvedValue([{ id: "department-1" }]), }, employeeRole: { create: vi.fn().mockResolvedValue({ employee_id: EMPLOYEE_ID, role_code: "employee" }), }, authSession: { findMany: vi.fn().mockResolvedValue([]), updateMany: vi.fn().mockResolvedValue({ count: 0 }), create: vi.fn().mockResolvedValue({ id: "session-1" }), }, }); prisma.$transaction = vi.fn(async (callback) => callback(prisma)); const app = await buildApp({ config: { wecomOauthOnboardingEnabled: true }, prisma }); const state = signPayload({ exp: Date.now() + 60_000, nonce: "nonce-onboard-success", returnTo: "/mobile", mode: "onboard", deviceFingerprint: "onboard-device", }, 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/mobile"); expect(prisma.employeeRole.create).toHaveBeenCalledWith({ data: expect.objectContaining({ role_code: "employee" }), }); expect(prisma.wecomIdentityBinding.upsert).toHaveBeenCalled(); expect(prisma.authSession.create).toHaveBeenCalledWith({ data: expect.objectContaining({ employee_id: EMPLOYEE_ID, device_fingerprint: "onboard-device" }), }); expect(JSON.stringify(prisma.auditLog.create.mock.calls)).not.toContain("private-ticket"); 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("/mobile"); expect(verified.payload.mode).toBe("login"); expect(String(verified.payload.deviceFingerprint ?? "")).toMatch(/^wecom:/); await app.close(); }); it("keeps frontend device fingerprint in wecom login state", async () => { const app = await buildApp(); const response = await app.inject({ method: "GET", url: "/api/auth/wecom/login?returnTo=%2Fportal&deviceFingerprint=browser-device-1", }); expect(response.statusCode).toBe(302); const state = new URL(response.headers.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"); expect(verified.payload.deviceFingerprint).toBe("browser-device-1"); 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("sets session hint cookie after successful wecom login callback", async () => { mockWecomFetch({ userId: "wecom-bound-user" }); const authSession = { findMany: vi.fn().mockResolvedValue([]), updateMany: vi.fn().mockResolvedValue({ count: 0 }), create: vi.fn().mockResolvedValue({ id: "session-1" }), }; const prisma = buildPrisma({ authSession, wecomIdentityBinding: { findUnique: vi.fn().mockResolvedValue({ employee_id: EMPLOYEE_ID, employee: { id: EMPLOYEE_ID, status: "active", token_version: 0, }, }), }, }); prisma.$transaction = vi.fn(async (callback) => callback(prisma)); const app = await buildApp({ prisma }); const state = signPayload({ exp: Date.now() + 60_000, nonce: "nonce-login-success", returnTo: "/portal", mode: "login", deviceFingerprint: "browser-device-1", }, 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/portal"); const setCookieHeader = String(response.headers["set-cookie"] ?? ""); expect(setCookieHeader).toContain("refresh_token="); expect(setCookieHeader).toContain("has_session=1"); expect(setCookieHeader).toContain("hyb_oa_device_fingerprint=browser-device-1"); expect(authSession.create).toHaveBeenCalledWith({ data: expect.objectContaining({ employee_id: EMPLOYEE_ID, device_fingerprint: "browser-device-1", }), }); expect(prisma.auditLog.create).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ actor_id: EMPLOYEE_ID, action: "AUTH_WECOM_LOGIN_SUCCESS", target_type: "employee", target_id: EMPLOYEE_ID, }), })); 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(); }); });