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 { clearWecomAppAccessTokenCache } = require("../../../src/services/integrations/wecomAccessToken"); const { clearWecomJsApiTicketCache } = require("../../../src/services/integrations/wecomJsSdk"); 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, wecomPcWebLoginEnabled: false, 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; clearWecomAppAccessTokenCache(); clearWecomJsApiTicketCache(); 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", pcWebLoginEnabled: false, pcLoginUrl: null, })); await app.close(); }); it("returns an independently gated PC Web login entry", async () => { const app = await buildApp({ config: { wecomPcWebLoginEnabled: true } }); const response = await app.inject({ method: "GET", url: "/api/auth/wecom/config" }); expect(response.statusCode).toBe(200); expect(response.json().data).toEqual(expect.objectContaining({ entryEnabled: true, pcWebLoginEnabled: true, pcLoginUrl: "/api/auth/wecom/login?returnTo=%2Fportal&entrySource=pc_qr", })); await app.close(); }); it("builds the hosted PC Web login URL and browser correlation cookie", async () => { const app = await buildApp({ config: { wecomPcWebLoginEnabled: true } }); const response = await app.inject({ method: "GET", url: "/api/auth/wecom/login?entrySource=pc_qr&returnTo=%2Freimbursements&deviceFingerprint=pc-device-1", }); expect(response.statusCode).toBe(302); const location = new URL(response.headers.location); expect(location.origin).toBe("https://login.work.weixin.qq.com"); expect(location.pathname).toBe("/wwlogin/sso/login"); expect(location.searchParams.get("login_type")).toBe("CorpApp"); expect(location.searchParams.get("appid")).toBe("wx-test-corp"); expect(location.searchParams.get("agentid")).toBe("1000001"); expect(location.searchParams.get("redirect_uri")).toBe("https://oa.hyinda.com/api/auth/wecom/callback"); const state = verifyToken(location.searchParams.get("state"), JWT_SECRET); expect(state).toEqual(expect.objectContaining({ ok: true, payload: expect.objectContaining({ returnTo: "/reimbursements", mode: "login", entrySource: "pc_qr", deviceFingerprint: "pc-device-1", }), })); const setCookie = String(response.headers["set-cookie"] ?? ""); expect(setCookie).toContain("wecom_pc_login_state="); expect(setCookie).toContain("Path=/api/auth/wecom"); expect(setCookie).toContain("HttpOnly"); expect(setCookie).toContain("Secure"); expect(setCookie).toContain("SameSite=Lax"); await app.close(); }); it("rejects PC entry for bind/onboard and while its independent switch is off", async () => { const disabledApp = await buildApp(); const disabled = await disabledApp.inject({ method: "GET", url: "/api/auth/wecom/login?entrySource=pc_qr&returnTo=%2Fportal", }); expect(disabled.statusCode).toBe(404); await disabledApp.close(); const app = await buildApp({ config: { wecomPcWebLoginEnabled: true } }); const unsupported = await app.inject({ method: "GET", url: "/api/auth/wecom/login?entrySource=pc_qr&mode=onboard&returnTo=%2Fportal", }); expect(unsupported.statusCode).toBe(400); expect(unsupported.json().error.details).toEqual({ entrySource: "unsupported_for_mode" }); 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("fails closed without a password-binding cookie when the WeCom profile is unavailable", 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=account_mapping_required", ); expect(String(response.headers["set-cookie"] ?? "")).not.toMatch(/wecom_login_bind=[^;]/); await app.close(); }); it("accepts lowercase userid and fails closed when no directory profile is available", 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=account_mapping_required", ); expect(String(response.headers["set-cookie"] ?? "")).not.toMatch(/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("auto-binds a unique active OA employee by enterprise email and logs in without a password", 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-auto-user", OpenId: "open-auto-user" }), }) .mockResolvedValueOnce({ ok: true, json: async () => ({ errcode: 0, userid: "wecom-auto-user", status: 1, biz_mail: "member@example.test", }), }); const authSession = { findMany: vi.fn().mockResolvedValue([]), updateMany: vi.fn().mockResolvedValue({ count: 0 }), create: vi.fn().mockResolvedValue({ id: "session-1" }), }; const prisma = buildPrisma({ authSession, employee: { findMany: vi.fn().mockResolvedValue([{ id: EMPLOYEE_ID, employee_no: "E0001", email: "member@example.test", status: "active", archived_at: null, token_version: 0, }]), }, wecomIdentityBinding: { findUnique: vi.fn() .mockResolvedValueOnce(null) .mockResolvedValueOnce(null) .mockResolvedValueOnce(null), create: vi.fn().mockResolvedValue({ id: "binding-auto" }), }, }); prisma.$transaction = vi.fn(async (callback) => callback(prisma)); const app = await buildApp({ prisma }); const state = signPayload({ exp: Date.now() + 60_000, nonce: "nonce-auto-bind", returnTo: "/mobile", mode: "login", deviceFingerprint: "auto-bind-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.wecomIdentityBinding.create).toHaveBeenCalledWith({ data: expect.objectContaining({ employee_id: EMPLOYEE_ID, external_user_id: "wecom-auto-user", }), }); expect(prisma.auditLog.create).toHaveBeenCalledWith({ data: expect.objectContaining({ action: "AUTH_WECOM_AUTO_BOUND", extra: { matchBy: "email", emailMatched: true }, }), }); expect(prisma.auditLog.create).toHaveBeenCalledWith({ data: expect.objectContaining({ action: "AUTH_WECOM_LOGIN_SUCCESS", extra: { bindingSource: "auto", entrySource: "wecom_workbench" }, }), }); expect(authSession.create).toHaveBeenCalledWith({ data: expect.objectContaining({ employee_id: EMPLOYEE_ID, device_fingerprint: "auto-bind-device" }), }); expect(String(response.headers["set-cookie"] ?? "")).not.toMatch(/wecom_login_bind=[^;]/); 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("fails closed before code exchange when the PC browser correlation cookie is missing", async () => { global.fetch = vi.fn(); const app = await buildApp({ config: { wecomPcWebLoginEnabled: true } }); const state = signPayload({ exp: Date.now() + 60_000, nonce: "pc-nonce-missing-cookie", returnTo: "/portal", mode: "login", entrySource: "pc_qr", deviceFingerprint: "pc-device-missing-cookie", }, 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.code).toBe("AUTH_004"); expect(global.fetch).not.toHaveBeenCalled(); expect(String(response.headers["set-cookie"] ?? "")).toContain("wecom_pc_login_state=;"); await app.close(); }); it("fails closed before code exchange when the PC browser nonce does not match", async () => { global.fetch = vi.fn(); const app = await buildApp({ config: { wecomPcWebLoginEnabled: true } }); const state = signPayload({ exp: Date.now() + 60_000, nonce: "pc-nonce-state", returnTo: "/portal", mode: "login", entrySource: "pc_qr", }, JWT_SECRET); const cookie = signPayload({ exp: Date.now() + 60_000, nonce: "pc-nonce-cookie", entrySource: "pc_qr", }, JWT_SECRET); const response = await app.inject({ method: "GET", url: `/api/auth/wecom/callback?state=${encodeURIComponent(state)}&code=test-code`, headers: { cookie: `wecom_pc_login_state=${cookie}` }, }); expect(response.statusCode).toBe(401); expect(global.fetch).not.toHaveBeenCalled(); await app.close(); }); it("clears PC correlation state for expired state, missing code, or a disabled PC switch", async () => { global.fetch = vi.fn(); const nonce = "pc-nonce-terminal-failures"; const cookie = signPayload({ exp: Date.now() + 60_000, nonce, entrySource: "pc_qr", }, JWT_SECRET); const cookieHeader = { cookie: `wecom_pc_login_state=${cookie}` }; const enabledApp = await buildApp({ config: { wecomPcWebLoginEnabled: true } }); const expiredState = signPayload({ exp: Date.now() - 1, nonce, returnTo: "/portal", mode: "login", entrySource: "pc_qr", }, JWT_SECRET); const expired = await enabledApp.inject({ method: "GET", url: `/api/auth/wecom/callback?state=${encodeURIComponent(expiredState)}&code=test-code`, headers: cookieHeader, }); expect(expired.statusCode).toBe(401); expect(String(expired.headers["set-cookie"] ?? "")).toContain("wecom_pc_login_state=;"); const validState = signPayload({ exp: Date.now() + 60_000, nonce, returnTo: "/portal", mode: "login", entrySource: "pc_qr", }, JWT_SECRET); const missingCode = await enabledApp.inject({ method: "GET", url: `/api/auth/wecom/callback?state=${encodeURIComponent(validState)}`, headers: cookieHeader, }); expect(missingCode.statusCode).toBe(401); expect(String(missingCode.headers["set-cookie"] ?? "")).toContain("wecom_pc_login_state=;"); await enabledApp.close(); const disabledApp = await buildApp(); const disabled = await disabledApp.inject({ method: "GET", url: `/api/auth/wecom/callback?state=${encodeURIComponent(validState)}&code=test-code`, headers: cookieHeader, }); expect(disabled.statusCode).toBe(404); expect(String(disabled.headers["set-cookie"] ?? "")).toContain("wecom_pc_login_state=;"); await disabledApp.close(); expect(global.fetch).not.toHaveBeenCalled(); }); it("reuses the mapped login flow for PC Web login and records a sanitized entry source", 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-pc-user", OpenId: "wecom-open-pc" }), }) .mockResolvedValueOnce({ ok: true, json: async () => ({ errcode: 0, access_token: "access-token-replay" }), }) .mockResolvedValueOnce({ ok: true, json: async () => ({ errcode: 40029, errmsg: "invalid code" }), }); const authSession = { findMany: vi.fn().mockResolvedValue([]), updateMany: vi.fn().mockResolvedValue({ count: 0 }), create: vi.fn().mockResolvedValue({ id: "session-pc" }), }; 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({ config: { wecomPcWebLoginEnabled: true }, prisma }); const nonce = "pc-nonce-success"; const state = signPayload({ exp: Date.now() + 60_000, nonce, returnTo: "/reimbursements", mode: "login", entrySource: "pc_qr", deviceFingerprint: "pc-device-success", }, JWT_SECRET); const cookie = signPayload({ exp: Date.now() + 60_000, nonce, entrySource: "pc_qr", }, JWT_SECRET); const response = await app.inject({ method: "GET", url: `/api/auth/wecom/callback?state=${encodeURIComponent(state)}&code=test-code`, headers: { cookie: `wecom_pc_login_state=${cookie}` }, }); expect(response.statusCode).toBe(302); expect(response.headers.location).toBe("https://oa.hyinda.com/reimbursements"); expect(String(response.headers["set-cookie"] ?? "")).toContain("wecom_pc_login_state=;"); expect(authSession.create).toHaveBeenCalledWith({ data: expect.objectContaining({ employee_id: EMPLOYEE_ID, device_fingerprint: "pc-device-success", }), }); expect(prisma.auditLog.create).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ action: "AUTH_WECOM_LOGIN_SUCCESS", extra: { bindingSource: "existing", entrySource: "pc_qr" }, }), })); expect(JSON.stringify(prisma.auditLog.create.mock.calls)).not.toContain("test-code"); expect(JSON.stringify(prisma.auditLog.create.mock.calls)).not.toContain(state); expect(JSON.stringify(prisma.auditLog.create.mock.calls)).not.toContain("wecom-pc-user"); const replay = await app.inject({ method: "GET", url: `/api/auth/wecom/callback?state=${encodeURIComponent(state)}&code=test-code`, headers: { cookie: `wecom_pc_login_state=${cookie}` }, }); expect(replay.statusCode).toBe(401); expect(replay.json().error).toEqual(expect.objectContaining({ code: "AUTH_003", details: { wecom: "userinfo_exchange_failed" }, })); expect(authSession.create).toHaveBeenCalledTimes(1); await app.close(); }); it("rejects a signed but unknown callback entry source before code exchange", async () => { global.fetch = vi.fn(); const app = await buildApp({ config: { wecomPcWebLoginEnabled: true } }); const state = signPayload({ exp: Date.now() + 60_000, nonce: "pc-nonce-unknown-source", returnTo: "/portal", mode: "login", entrySource: "unknown_source", }, 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(global.fetch).not.toHaveBeenCalled(); 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(); }); it("signs a same-origin page URL without exposing WeCom credentials", async () => { global.fetch = vi.fn() .mockResolvedValueOnce({ ok: true, json: async () => ({ errcode: 0, access_token: "synthetic-access-token", expires_in: 7200 }), }) .mockResolvedValueOnce({ ok: true, json: async () => ({ errcode: 0, ticket: "synthetic-jsapi-ticket", expires_in: 7200 }), }); const app = await buildApp(); const response = await app.inject({ method: "POST", url: "/api/auth/wecom/js-sdk-signature", headers: { authorization: "Bearer test-token", "content-type": "application/json" }, payload: { url: "https://oa.hyinda.com/mobile/reimbursements/test?source=wecom" }, }); expect(response.statusCode).toBe(200); expect(response.json().data).toEqual({ corpId: "wx-test-corp", timestamp: expect.any(Number), nonceStr: expect.any(String), signature: expect.stringMatching(/^[a-f0-9]{40}$/), }); const serialized = JSON.stringify(response.json()).toLowerCase(); expect(serialized).not.toContain("synthetic-access-token"); expect(serialized).not.toContain("synthetic-jsapi-ticket"); expect(serialized).not.toContain("wecom-secret"); expect(global.fetch).toHaveBeenCalledTimes(2); await app.close(); }); it("rejects cross-origin and credential-bearing JS-SDK signing URLs before provider access", async () => { global.fetch = vi.fn(); const app = await buildApp(); const crossOrigin = await app.inject({ method: "POST", url: "/api/auth/wecom/js-sdk-signature", headers: { authorization: "Bearer test-token", "content-type": "application/json" }, payload: { url: "https://evil.example/mobile" }, }); const credentialUrl = await app.inject({ method: "POST", url: "/api/auth/wecom/js-sdk-signature", headers: { authorization: "Bearer test-token", "content-type": "application/json" }, payload: { url: "https://user:password@oa.hyinda.com/mobile" }, }); const fragmentUrl = await app.inject({ method: "POST", url: "/api/auth/wecom/js-sdk-signature", headers: { authorization: "Bearer test-token", "content-type": "application/json" }, payload: { url: "https://oa.hyinda.com/mobile#detail" }, }); expect(crossOrigin.statusCode).toBe(403); expect(credentialUrl.statusCode).toBe(400); expect(fragmentUrl.statusCode).toBe(400); expect(global.fetch).not.toHaveBeenCalled(); await app.close(); }); it("returns a stable redacted error when the JSAPI ticket provider fails", async () => { global.fetch = vi.fn() .mockResolvedValueOnce({ ok: true, json: async () => ({ errcode: 0, access_token: "synthetic-access-token", expires_in: 7200 }), }) .mockResolvedValueOnce({ ok: true, json: async () => ({ errcode: 40001, errmsg: "provider-secret-detail" }), }); const app = await buildApp(); const response = await app.inject({ method: "POST", url: "/api/auth/wecom/js-sdk-signature", headers: { authorization: "Bearer test-token", "content-type": "application/json" }, payload: { url: "https://oa.hyinda.com/mobile" }, }); expect(response.statusCode).toBe(503); expect(response.json().error.details).toEqual({ wecom: "jsapi_ticket_unavailable" }); expect(JSON.stringify(response.json())).not.toContain("provider-secret-detail"); expect(JSON.stringify(response.json())).not.toContain("synthetic-access-token"); await app.close(); }); it("fails closed without provider access when JS-SDK credentials are incomplete", async () => { global.fetch = vi.fn(); const app = await buildApp({ config: { wecomAgentSecret: "" } }); const response = await app.inject({ method: "POST", url: "/api/auth/wecom/js-sdk-signature", headers: { authorization: "Bearer test-token", "content-type": "application/json" }, payload: { url: "https://oa.hyinda.com/mobile" }, }); expect(response.statusCode).toBe(503); expect(response.json().error.details).toEqual({ wecom: "jsapi_ticket_unavailable" }); expect(global.fetch).not.toHaveBeenCalled(); await app.close(); }); });