const Fastify = require("fastify"); const fastifyCookie = require("@fastify/cookie"); const authRoutes = require("../../../src/routes/auth"); const { AppError } = require("../../../src/utils/AppError"); const { hashPassword } = require("../../../src/utils/crypto"); const { signPayload } = require("../../../src/utils/signedUrl"); const EMPLOYEE_ID = "11111111-1111-4111-8111-111111111111"; const JWT_SECRET = "test-auth-secret"; async function buildApp(passwordHash) { const app = Fastify(); await app.register(fastifyCookie); const employeeFindFirst = vi.fn().mockResolvedValue({ id: EMPLOYEE_ID, status: "active", password_hash: passwordHash, token_version: 3, must_change_password: false, login_failed_count: 0, login_failed_first_at: null, login_locked_until: null, }); const employeeUpdate = vi.fn().mockResolvedValue({}); const authSessionFindMany = vi.fn().mockResolvedValue([]); const authSessionUpdateMany = vi.fn().mockResolvedValue({ count: 0 }); const authSessionCreate = vi.fn().mockResolvedValue({ id: "session-1" }); const auditCreate = vi.fn().mockResolvedValue({ id: "audit-1" }); const wecomFindFirst = vi.fn().mockResolvedValue(null); const wecomUpsert = vi.fn().mockResolvedValue({ id: "binding-1" }); const tx = { employee: { update: employeeUpdate, }, authSession: { findMany: authSessionFindMany, updateMany: authSessionUpdateMany, create: authSessionCreate, }, auditLog: { create: auditCreate, }, wecomIdentityBinding: { findFirst: wecomFindFirst, upsert: wecomUpsert, }, }; app.decorate("config", { accessTokenTtl: "15m", refreshTtlSeconds: 7 * 24 * 60 * 60, refreshCookieName: "refresh_token", cookieSecure: false, loginFailWindowSeconds: 300, loginLockSeconds: 900, loginFailMaxAttempts: 5, rateLimitEnabled: false, jwtSecret: JWT_SECRET, appOrigin: "http://127.0.0.1:3000", wecomAllowedReturnHosts: "127.0.0.1:3000", }); app.decorate("prisma", { employee: { findFirst: employeeFindFirst, }, $transaction: vi.fn(async (callback) => callback(tx)), }); app.decorate("issueAccessToken", vi.fn().mockReturnValue("access-token")); app.decorate("authenticate", async () => undefined); 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: error.code ?? "SYS_001", message: error.message ?? "服务器内部错误", details: error.details ?? {} }, }); }); await app.register(authRoutes, { prefix: "/api/auth" }); return { app, employeeFindFirst, employeeUpdate, authSessionFindMany, authSessionUpdateMany, authSessionCreate, auditCreate, wecomFindFirst, wecomUpsert, }; } describe("auth login route", () => { it("keeps signed deeplink usable after legacy exp and redirects normal browsers to target", async () => { const passwordHash = await hashPassword("AdminPassword1234"); const { app } = await buildApp(passwordHash); const token = signPayload({ targetPath: "/mobile/approvals?taskId=task-1", exp: Math.floor(Date.now() / 1000) - 60, }, JWT_SECRET); const response = await app.inject({ method: "GET", url: `/api/auth/deeplink?token=${encodeURIComponent(token)}`, }); expect(response.statusCode).toBe(302); expect(response.headers.location).toBe("http://127.0.0.1:3000/mobile/approvals?taskId=task-1"); await app.close(); }); it("routes signed deeplink through wecom login inside Enterprise WeChat", async () => { const passwordHash = await hashPassword("AdminPassword1234"); const { app } = await buildApp(passwordHash); const token = signPayload({ targetPath: "/mobile/approvals?taskId=task-1", exp: Math.floor(Date.now() / 1000) - 60, }, JWT_SECRET); const response = await app.inject({ method: "GET", url: `/api/auth/deeplink?token=${encodeURIComponent(token)}`, headers: { "user-agent": "Mozilla/5.0 wxwork/4.1.20 MicroMessenger/8.0.1", }, }); expect(response.statusCode).toBe(302); expect(response.headers.location).toBe( "http://127.0.0.1:3000/api/auth/wecom/login?returnTo=%2Fmobile%2Fapprovals%3FtaskId%3Dtask-1", ); await app.close(); }); it("rejects deeplink targets outside internal OA paths", async () => { const passwordHash = await hashPassword("AdminPassword1234"); const { app } = await buildApp(passwordHash); const token = signPayload({ targetPath: "//evil.example.com/mobile" }, JWT_SECRET); const response = await app.inject({ method: "GET", url: `/api/auth/deeplink?token=${encodeURIComponent(token)}`, }); expect(response.statusCode).toBe(400); expect(response.json().error.details).toEqual({ targetPath: "invalid_target_path" }); await app.close(); }); it("rejects tampered deeplink signatures and absolute URL targets", async () => { const passwordHash = await hashPassword("AdminPassword1234"); const { app } = await buildApp(passwordHash); const validToken = signPayload({ targetPath: "/mobile" }, JWT_SECRET); const tamperedResponse = await app.inject({ method: "GET", url: `/api/auth/deeplink?token=${encodeURIComponent(`${validToken}x`)}`, }); expect(tamperedResponse.statusCode).toBe(401); expect(tamperedResponse.json().error.details).toEqual({ token: "invalid_signature" }); const absoluteUrlToken = signPayload({ targetPath: "https://evil.example.com/mobile" }, JWT_SECRET); const absoluteUrlResponse = await app.inject({ method: "GET", url: `/api/auth/deeplink?token=${encodeURIComponent(absoluteUrlToken)}`, }); expect(absoluteUrlResponse.statusCode).toBe(400); expect(absoluteUrlResponse.json().error.details).toEqual({ targetPath: "invalid_target_path" }); await app.close(); }); it("auto-binds pending wecom identity after password login", async () => { const passwordHash = await hashPassword("AdminPassword1234"); const { app, wecomFindFirst, wecomUpsert } = await buildApp(passwordHash); const pendingBindToken = signPayload({ exp: Date.now() + 60_000, externalUserId: "wecom-user-1", externalOpenUserId: "openid-1", returnTo: "/portal", }, JWT_SECRET); const response = await app.inject({ method: "POST", url: "/api/auth/login", headers: { "content-type": "application/json", "x-device-fingerprint": "pw-e2e-admin", cookie: `wecom_login_bind=${pendingBindToken}`, }, payload: { identifier: "admin@hyinda.com", password: "AdminPassword1234", }, }); expect(response.statusCode).toBe(200); expect(wecomFindFirst).toHaveBeenCalledWith({ where: { external_user_id: "wecom-user-1", NOT: { employee_id: EMPLOYEE_ID }, }, select: { employee_id: true }, }); expect(wecomUpsert).toHaveBeenCalledWith({ where: { employee_id: EMPLOYEE_ID }, update: { external_user_id: "wecom-user-1", external_open_user_id: "openid-1", updated_by: EMPLOYEE_ID, }, create: { employee_id: EMPLOYEE_ID, external_user_id: "wecom-user-1", external_open_user_id: "openid-1", created_by: EMPLOYEE_ID, updated_by: EMPLOYEE_ID, }, }); expect(response.json().data).toEqual( expect.objectContaining({ accessToken: "access-token", mustChangePassword: false, wecomBindingStatus: "bound", }), ); expect(response.headers["set-cookie"]).toEqual( expect.arrayContaining([ expect.stringContaining("wecom_login_bind="), ]), ); await app.close(); }); });