const crypto = require("node:crypto"); const Fastify = require("fastify"); const fastifyCookie = require("@fastify/cookie"); const authRoutes = require("../../../src/routes/auth"); const { AppError } = require("../../../src/utils/AppError"); const { hashAuthCode, hashPassword, verifyPassword } = require("../../../src/utils/crypto"); const { signPayload } = require("../../../src/utils/signedUrl"); const EMPLOYEE_ID = "11111111-1111-4111-8111-111111111111"; const JWT_SECRET = "test-auth-secret"; const AUTH_CODE_PEPPER = "test-auth-code-pepper-with-at-least-32-bytes"; function hashEmailLoginCode(code) { return hashAuthCode({ employeeId: EMPLOYEE_ID, code, purpose: "email_login", pepper: AUTH_CODE_PEPPER, }); } function hashPasswordResetCode(code) { return hashAuthCode({ employeeId: EMPLOYEE_ID, code, purpose: "password_reset", pepper: AUTH_CODE_PEPPER, }); } function buildEmployee(passwordHash) { return { 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, email_login_code_failed_count: 0, email_login_code_failed_first_at: null, email_login_code_locked_until: null, email_login_code_last_requested_at: null, password_reset_failed_count: 0, password_reset_failed_first_at: null, password_reset_locked_until: null, password_reset_request_count: 0, password_reset_request_first_at: null, password_reset_last_requested_at: null, }; } async function buildApp(passwordHash) { const app = Fastify(); await app.register(fastifyCookie); const employee = buildEmployee(passwordHash); const employeeFindFirst = vi.fn().mockResolvedValue(employee); const employeeFindUnique = vi.fn().mockResolvedValue(employee); const employeeFindMany = vi.fn().mockResolvedValue([]); const employeeUpdate = vi.fn().mockResolvedValue({}); const employeeUpdateMany = vi.fn().mockResolvedValue({ count: 1 }); const queryRaw = vi.fn().mockResolvedValue([employee]); const authSessionFindFirst = vi.fn().mockResolvedValue(null); const txAuthSessionFindFirst = vi.fn().mockResolvedValue(null); const authSessionFindUnique = vi.fn().mockResolvedValue(null); 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 notificationCreateMany = vi.fn().mockResolvedValue({ count: 1 }); const emailLoginCodeCreate = vi.fn().mockResolvedValue({ id: "email-code-1" }); const emailLoginCodeFindFirst = vi.fn().mockResolvedValue(null); const emailLoginCodeUpdateMany = vi.fn().mockResolvedValue({ count: 1 }); const passwordResetCodeCreate = vi.fn().mockResolvedValue({ id: "password-reset-code-1" }); const passwordResetCodeFindFirst = vi.fn().mockResolvedValue(null); const passwordResetCodeUpdateMany = vi.fn().mockResolvedValue({ count: 1 }); const wecomFindFirst = vi.fn().mockResolvedValue(null); const wecomUpsert = vi.fn().mockResolvedValue({ id: "binding-1" }); const tx = { $queryRaw: queryRaw, employee: { update: employeeUpdate, updateMany: employeeUpdateMany, }, authSession: { findFirst: txAuthSessionFindFirst, findUnique: authSessionFindUnique, findMany: authSessionFindMany, updateMany: authSessionUpdateMany, create: authSessionCreate, }, emailLoginCode: { create: emailLoginCodeCreate, findFirst: emailLoginCodeFindFirst, updateMany: emailLoginCodeUpdateMany, }, passwordResetCode: { create: passwordResetCodeCreate, findFirst: passwordResetCodeFindFirst, updateMany: passwordResetCodeUpdateMany, }, auditLog: { create: auditCreate, }, notification: { createMany: notificationCreateMany, }, 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, emailLoginCodeTtlSeconds: 600, emailLoginCodeMaxAttempts: 5, emailLoginCodeFailWindowSeconds: 1800, emailLoginCodeLockSeconds: 1800, passwordResetCodeTtlSeconds: 600, passwordResetFailWindowSeconds: 1800, passwordResetMaxAttempts: 5, passwordResetLockSeconds: 1800, passwordResetRequestWindowSeconds: 3600, passwordResetRequestMax: 3, authCodeRequestCooldownSeconds: 60, devShowPasswordResetCode: false, emailEnabled: true, emailFrom: "no-reply@hyinda.test", smtpHost: "smtp.hyinda.test", smtpPort: 587, smtpSecure: false, smtpUser: "", smtpPass: "", rateLimitEnabled: false, jwtSecret: JWT_SECRET, authCodePepper: AUTH_CODE_PEPPER, appOrigin: "http://127.0.0.1:3000", wecomAllowedReturnHosts: "127.0.0.1:3000", }); const transaction = vi.fn(async (callback) => callback(tx)); app.decorate("prisma", { employee: { findFirst: employeeFindFirst, findUnique: employeeFindUnique, findMany: employeeFindMany, }, auditLog: { create: auditCreate, }, notification: { createMany: notificationCreateMany, }, authSession: { findFirst: authSessionFindFirst, }, $transaction: transaction, }); app.decorate("issueAccessToken", vi.fn().mockReturnValue("access-token")); app.decorate("authenticate", async (request) => { 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: error.code ?? "SYS_001", message: error.message ?? "服务器内部错误", details: error.details ?? {} }, }); }); await app.register(authRoutes, { prefix: "/api/auth" }); return { app, employeeFindFirst, employeeFindUnique, employeeFindMany, employeeUpdate, employeeUpdateMany, queryRaw, transaction, authSessionFindFirst, txAuthSessionFindFirst, authSessionFindUnique, authSessionFindMany, authSessionUpdateMany, authSessionCreate, auditCreate, notificationCreateMany, emailLoginCodeCreate, emailLoginCodeFindFirst, emailLoginCodeUpdateMany, passwordResetCodeCreate, passwordResetCodeFindFirst, passwordResetCodeUpdateMany, wecomFindFirst, wecomUpsert, }; } describe("auth login route", () => { it.each([ ["/login", {}, "login_input_invalid"], ["/email-login/code", { email: "invalid" }, "email_input_invalid"], ["/email-login/verify", {}, "email_code_input_invalid"], ["/change-password", {}, "password_change_input_invalid"], ["/forgot-password", { email: "invalid" }, "email_input_invalid"], ["/reset-password", {}, "password_reset_input_invalid"], ["/change-password", { oldPassword: "SyntheticOld1234", newPassword: "aaaaaaaaaaaa" }, "password_policy_failed"], ])("explains invalid input at %s without authentication side effects", async (url, payload, reason) => { const state = await buildApp("unused"); try { const response = await state.app.inject({ method: "POST", url: `/api/auth${url}`, payload, headers: { "x-device-fingerprint": "synthetic-input-validation" } }); expect(response.statusCode).toBe(400); expect(response.json().error.details.reasonCode).toBe(`auth.${reason}`); expect(state.employeeUpdate).not.toHaveBeenCalled(); expect(state.authSessionCreate).not.toHaveBeenCalled(); expect(state.emailLoginCodeCreate).not.toHaveBeenCalled(); expect(state.passwordResetCodeCreate).not.toHaveBeenCalled(); expect(state.auditCreate).not.toHaveBeenCalled(); expect(JSON.stringify(response.json())).not.toContain("SyntheticOld1234"); } finally { await state.app.close(); } }); it("explains missing device context before looking up an account", async () => { const state = await buildApp("unused"); try { const response = await state.app.inject({ method: "POST", url: "/api/auth/login", payload: { identifier: "synthetic@example.invalid", password: "SyntheticPassword1234" } }); expect(response.statusCode).toBe(400); expect(response.json().error.details.reasonCode).toBe("auth.device_context_missing"); expect(state.employeeFindFirst).not.toHaveBeenCalled(); expect(state.authSessionCreate).not.toHaveBeenCalled(); } finally { await state.app.close(); } }); 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", reasonCode: "auth.deeplink_target_invalid" }); 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", reasonCode: "auth.deeplink_target_invalid" }); await app.close(); }); it("reports auth delivery capabilities without exposing SMTP values", async () => { const passwordHash = await hashPassword("AdminPassword1234"); const { app } = await buildApp(passwordHash); const readyResponse = await app.inject({ method: "GET", url: "/api/auth/capabilities", }); expect(readyResponse.statusCode).toBe(200); expect(readyResponse.json().data).toEqual({ emailDelivery: { ready: true, statusReasons: [], }, emailLoginCode: { enabled: true, }, passwordReset: { enabled: true, }, }); expect(JSON.stringify(readyResponse.json())).not.toContain("smtp.hyinda.test"); app.config.emailEnabled = false; app.config.smtpHost = ""; const disabledResponse = await app.inject({ method: "GET", url: "/api/auth/capabilities", }); expect(disabledResponse.statusCode).toBe(200); expect(disabledResponse.json().data).toEqual({ emailDelivery: { ready: false, statusReasons: ["email_disabled", "smtp_host_missing"], }, emailLoginCode: { enabled: false, }, passwordReset: { enabled: false, }, }); await app.close(); }); it("builds auth verification emails with Beijing-time expiry wording", () => { expect( authRoutes._test.buildAuthVerificationEmail({ code: "123456", expiresAt: new Date("2026-05-27T01:12:00.000Z"), purpose: "login", }), ).toEqual({ subject: "【海音达OA】登录验证码", body: "验证码:123456\n有效期至:2026年05月27日 09:12(北京时间)\n如非本人操作,请忽略并联系管理员。", }); expect( authRoutes._test.buildAuthVerificationEmail({ code: "654321", expiresAt: new Date("2026-05-27T01:12:00.000Z"), purpose: "password_reset", }), ).toEqual({ subject: "【海音达OA】密码重置验证码", body: "验证码:654321\n有效期至:2026年05月27日 09:12(北京时间)\n如非本人操作,请忽略并联系管理员。", }); }); it("does not use password login to bind a pending wecom identity", 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).not.toHaveBeenCalled(); expect(wecomUpsert).not.toHaveBeenCalled(); expect(response.json().data).toEqual( expect.objectContaining({ accessToken: "access-token", mustChangePassword: false, wecomBindingStatus: null, }), ); expect(response.headers["set-cookie"]).toEqual( expect.arrayContaining([ expect.stringMatching(/wecom_login_bind=;/), ]), ); await app.close(); }); it("requests an email login code without exposing account existence", async () => { const passwordHash = await hashPassword("AdminPassword1234"); const randomSpy = vi.spyOn(crypto, "randomInt").mockReturnValue(123456); const { app, emailLoginCodeCreate, emailLoginCodeUpdateMany, auditCreate } = await buildApp(passwordHash); const response = await app.inject({ method: "POST", url: "/api/auth/email-login/code", headers: { "content-type": "application/json" }, payload: { email: "employee@hyinda.test" }, }); expect(response.statusCode).toBe(200); expect(response.json().data).toEqual({}); expect(emailLoginCodeCreate).toHaveBeenCalledWith({ data: expect.objectContaining({ employee_id: EMPLOYEE_ID, code_hash: hashEmailLoginCode("123456"), }), }); expect(emailLoginCodeUpdateMany).toHaveBeenCalledWith({ where: { employee_id: EMPLOYEE_ID, used_at: null }, data: { used_at: expect.any(Date) }, }); expect(auditCreate).toHaveBeenCalledWith({ data: expect.objectContaining({ action: "AUTH_EMAIL_LOGIN_CODE_REQUESTED", actor_id: EMPLOYEE_ID, }), }); randomSpy.mockRestore(); await app.close(); }); it("rejects email login code requests when delivery is not configured before account lookup", async () => { const passwordHash = await hashPassword("AdminPassword1234"); const { app, transaction, queryRaw, emailLoginCodeCreate } = await buildApp(passwordHash); app.config.emailEnabled = false; const response = await app.inject({ method: "POST", url: "/api/auth/email-login/code", headers: { "content-type": "application/json" }, payload: { email: "employee@hyinda.test" }, }); expect(response.statusCode).toBe(503); expect(response.json().error.details).toEqual({ email: "delivery_not_configured" }); expect(transaction).not.toHaveBeenCalled(); expect(queryRaw).not.toHaveBeenCalled(); expect(emailLoginCodeCreate).not.toHaveBeenCalled(); await app.close(); }); it("does not reveal missing accounts when requesting an email login code", async () => { const passwordHash = await hashPassword("AdminPassword1234"); const { app, queryRaw, emailLoginCodeCreate } = await buildApp(passwordHash); queryRaw.mockResolvedValueOnce([]); const response = await app.inject({ method: "POST", url: "/api/auth/email-login/code", headers: { "content-type": "application/json" }, payload: { email: "missing@hyinda.test" }, }); expect(response.statusCode).toBe(200); expect(response.json().data).toEqual({}); expect(emailLoginCodeCreate).not.toHaveBeenCalled(); await app.close(); }); it("logs in with a valid email code and creates a refresh session", async () => { const passwordHash = await hashPassword("AdminPassword1234"); const { app, emailLoginCodeFindFirst, emailLoginCodeUpdateMany, authSessionCreate, auditCreate } = await buildApp(passwordHash); emailLoginCodeFindFirst.mockResolvedValueOnce({ id: "email-code-1", code_hash: hashEmailLoginCode("654321"), attempts_count: 0, }); const response = await app.inject({ method: "POST", url: "/api/auth/email-login/verify", headers: { "content-type": "application/json", "x-device-fingerprint": "email-code-device", }, payload: { email: "employee@hyinda.test", code: "654321" }, }); expect(response.statusCode).toBe(200); expect(response.json().data).toEqual( expect.objectContaining({ accessToken: "access-token", mustChangePassword: false, }), ); expect(emailLoginCodeUpdateMany).toHaveBeenCalledWith({ where: { id: "email-code-1", employee_id: EMPLOYEE_ID, code_hash: hashEmailLoginCode("654321"), used_at: null, expires_at: { gt: expect.any(Date) }, }, data: { used_at: expect.any(Date) }, }); expect(authSessionCreate).toHaveBeenCalledWith({ data: expect.objectContaining({ employee_id: EMPLOYEE_ID, device_fingerprint: "email-code-device", }), }); expect(auditCreate).toHaveBeenCalledWith({ data: expect.objectContaining({ action: "AUTH_EMAIL_LOGIN_SUCCESS", actor_id: EMPLOYEE_ID, }), }); expect(response.headers["set-cookie"]).toEqual( expect.arrayContaining([ expect.stringContaining("refresh_token="), expect.stringContaining("has_session=1"), ]), ); await app.close(); }); it("rejects an invalid email login code and records the failed attempt", async () => { const passwordHash = await hashPassword("AdminPassword1234"); const { app, employeeUpdate, emailLoginCodeFindFirst, emailLoginCodeUpdateMany, auditCreate } = await buildApp(passwordHash); emailLoginCodeFindFirst.mockResolvedValueOnce({ id: "email-code-1", code_hash: hashEmailLoginCode("654321"), attempts_count: 0, }); const response = await app.inject({ method: "POST", url: "/api/auth/email-login/verify", headers: { "content-type": "application/json", "x-device-fingerprint": "email-code-device", }, payload: { email: "employee@hyinda.test", code: "000000" }, }); expect(response.statusCode).toBe(400); expect(response.json().error.details).toEqual({ code: "invalid_or_expired", reasonCode: "auth.code_invalid_or_expired" }); expect(emailLoginCodeUpdateMany).toHaveBeenCalledWith({ where: { id: "email-code-1", used_at: null }, data: { attempts_count: { increment: 1 } }, }); expect(employeeUpdate).toHaveBeenCalledWith({ where: { id: EMPLOYEE_ID }, data: expect.objectContaining({ email_login_code_failed_count: 1, email_login_code_locked_until: null, }), }); expect(auditCreate).toHaveBeenCalledWith({ data: expect.objectContaining({ action: "AUTH_EMAIL_LOGIN_FAILED", actor_id: EMPLOYEE_ID, }), }); await app.close(); }); it("sends password reset codes with Beijing-time expiry wording", async () => { const passwordHash = await hashPassword("AdminPassword1234"); const randomSpy = vi.spyOn(crypto, "randomInt").mockReturnValue(654321); const { app, passwordResetCodeCreate, passwordResetCodeUpdateMany, auditCreate, notificationCreateMany, } = await buildApp(passwordHash); const response = await app.inject({ method: "POST", url: "/api/auth/forgot-password", headers: { "content-type": "application/json" }, payload: { email: " employee@hyinda.test " }, }); expect(response.statusCode).toBe(200); expect(response.json().data).toEqual({}); expect(passwordResetCodeCreate).toHaveBeenCalledWith({ data: expect.objectContaining({ employee_id: EMPLOYEE_ID, code_hash: hashPasswordResetCode("654321"), }), }); expect(passwordResetCodeUpdateMany).toHaveBeenCalledWith({ where: { employee_id: EMPLOYEE_ID, used_at: null, invalidated_at: null, }, data: { invalidated_at: expect.any(Date) }, }); expect(auditCreate).toHaveBeenCalledWith({ data: expect.objectContaining({ action: "AUTH_PASSWORD_RESET_REQUESTED", actor_id: EMPLOYEE_ID, }), }); expect(notificationCreateMany).toHaveBeenCalled(); randomSpy.mockRestore(); await app.close(); }); it("rejects password reset code requests when delivery is not configured before account lookup", async () => { const passwordHash = await hashPassword("AdminPassword1234"); const { app, transaction, queryRaw, passwordResetCodeCreate } = await buildApp(passwordHash); app.config.smtpHost = ""; const response = await app.inject({ method: "POST", url: "/api/auth/forgot-password", headers: { "content-type": "application/json" }, payload: { email: "employee@hyinda.test" }, }); expect(response.statusCode).toBe(503); expect(response.json().error.details).toEqual({ email: "delivery_not_configured" }); expect(transaction).not.toHaveBeenCalled(); expect(queryRaw).not.toHaveBeenCalled(); expect(passwordResetCodeCreate).not.toHaveBeenCalled(); await app.close(); }); it.each([ ["missing", []], ["archived", [{ ...buildEmployee("unused"), status: "archived" }]], [ "locked", [ { ...buildEmployee("unused"), password_reset_locked_until: new Date(Date.now() + 60_000), }, ], ], ])("returns the same forgot-password response without writes for a %s account", async (_case, rows) => { const passwordHash = await hashPassword("AdminPassword1234"); const { app, queryRaw, employeeUpdate, passwordResetCodeCreate, passwordResetCodeUpdateMany, auditCreate, } = await buildApp(passwordHash); queryRaw.mockResolvedValueOnce(rows); const response = await app.inject({ method: "POST", url: "/api/auth/forgot-password", headers: { "content-type": "application/json" }, payload: { email: "account@hyinda.test" }, }); expect(response.statusCode).toBe(200); expect(response.json().data).toEqual({}); expect(employeeUpdate).not.toHaveBeenCalled(); expect(passwordResetCodeCreate).not.toHaveBeenCalled(); expect(passwordResetCodeUpdateMany).not.toHaveBeenCalled(); expect(auditCreate).not.toHaveBeenCalled(); await app.close(); }); it("does not create a session when password state changed after verification", async () => { const passwordHash = await hashPassword("AdminPassword1234"); const { app, employeeUpdateMany, authSessionCreate } = await buildApp(passwordHash); employeeUpdateMany.mockResolvedValueOnce({ count: 0 }); const response = await app.inject({ method: "POST", url: "/api/auth/login", headers: { "content-type": "application/json", "x-device-fingerprint": "stale-password-login", }, payload: { identifier: "employee@hyinda.test", password: "AdminPassword1234", }, }); expect(response.statusCode).toBe(401); expect(response.json().error.code).toBe("AUTH_001"); expect(authSessionCreate).not.toHaveBeenCalled(); expect(response.headers["set-cookie"]).toBeUndefined(); await app.close(); }); it("locks and rechecks the employee before rotating a refresh session", async () => { const passwordHash = await hashPassword("AdminPassword1234"); const { app, employeeFindUnique, queryRaw, authSessionFindFirst, authSessionFindUnique, authSessionUpdateMany, authSessionCreate, } = await buildApp(passwordHash); const activeSession = { id: "session-1", employee_id: EMPLOYEE_ID, device_fingerprint: "refresh-device", expires_at: new Date(Date.now() + 60_000), revoked_at: null, }; authSessionFindFirst.mockResolvedValueOnce(activeSession); authSessionFindUnique.mockResolvedValueOnce(activeSession); authSessionUpdateMany.mockResolvedValueOnce({ count: 1 }); const response = await app.inject({ method: "POST", url: "/api/auth/refresh", headers: { cookie: "refresh_token=old-refresh-token", origin: "http://127.0.0.1:3000", "x-device-fingerprint": "refresh-device", }, payload: {}, }); expect(response.statusCode).toBe(200); expect(queryRaw).toHaveBeenCalledTimes(1); expect(authSessionFindUnique).toHaveBeenCalledWith({ where: { id: "session-1" }, select: expect.objectContaining({ id: true, employee_id: true, revoked_at: true }), }); expect(queryRaw.mock.invocationCallOrder[0]).toBeLessThan( authSessionFindUnique.mock.invocationCallOrder[0], ); expect(employeeFindUnique).not.toHaveBeenCalled(); expect(authSessionCreate).toHaveBeenCalledWith({ data: expect.objectContaining({ employee_id: EMPLOYEE_ID, device_fingerprint: "refresh-device", }), }); await app.close(); }); it("suppresses email login code requests during the account cooldown", async () => { const passwordHash = await hashPassword("AdminPassword1234"); const { app, queryRaw, emailLoginCodeCreate, auditCreate } = await buildApp(passwordHash); queryRaw.mockResolvedValueOnce([ { ...buildEmployee(passwordHash), email_login_code_last_requested_at: new Date(), }, ]); const response = await app.inject({ method: "POST", url: "/api/auth/email-login/code", headers: { "content-type": "application/json" }, payload: { email: "employee@hyinda.test" }, }); expect(response.statusCode).toBe(200); expect(response.json().data).toEqual({}); expect(emailLoginCodeCreate).not.toHaveBeenCalled(); expect(auditCreate).not.toHaveBeenCalled(); await app.close(); }); it("locks the account-level email code entry on the fifth failed attempt", async () => { const passwordHash = await hashPassword("AdminPassword1234"); const { app, queryRaw, employeeUpdate, emailLoginCodeFindFirst, emailLoginCodeUpdateMany, auditCreate, } = await buildApp(passwordHash); queryRaw.mockResolvedValueOnce([ { ...buildEmployee(passwordHash), email_login_code_failed_count: 4, email_login_code_failed_first_at: new Date(), }, ]); emailLoginCodeFindFirst.mockResolvedValueOnce({ id: "email-code-1", code_hash: hashEmailLoginCode("654321"), attempts_count: 4, }); const response = await app.inject({ method: "POST", url: "/api/auth/email-login/verify", headers: { "content-type": "application/json", "x-device-fingerprint": "email-lock-test", }, payload: { email: "employee@hyinda.test", code: "000000" }, }); expect(response.statusCode).toBe(400); expect(employeeUpdate).toHaveBeenCalledWith({ where: { id: EMPLOYEE_ID }, data: expect.objectContaining({ email_login_code_failed_count: 5, email_login_code_locked_until: expect.any(Date), }), }); expect(emailLoginCodeUpdateMany).toHaveBeenCalledWith({ where: { id: "email-code-1", used_at: null }, data: { attempts_count: { increment: 1 }, used_at: expect.any(Date), }, }); expect(auditCreate).toHaveBeenCalledWith({ data: expect.objectContaining({ action: "AUTH_EMAIL_LOGIN_LOCKED" }), }); await app.close(); }); it("does not count a failure when no active email-login challenge exists", async () => { const passwordHash = await hashPassword("AdminPassword1234"); const { app, employeeUpdate, emailLoginCodeUpdateMany, auditCreate } = await buildApp(passwordHash); const response = await app.inject({ method: "POST", url: "/api/auth/email-login/verify", headers: { "content-type": "application/json", "x-device-fingerprint": "missing-email-challenge", }, payload: { email: "employee@hyinda.test", code: "000000" }, }); expect(response.statusCode).toBe(400); expect(response.json().error.details).toEqual({ code: "invalid_or_expired", reasonCode: "auth.code_invalid_or_expired" }); expect(employeeUpdate).not.toHaveBeenCalled(); expect(emailLoginCodeUpdateMany).not.toHaveBeenCalled(); expect(auditCreate).not.toHaveBeenCalled(); await app.close(); }); it("keeps password reset failure counts when a new request is suppressed", async () => { const passwordHash = await hashPassword("AdminPassword1234"); const { app, queryRaw, employeeUpdate, passwordResetCodeCreate } = await buildApp(passwordHash); queryRaw.mockResolvedValueOnce([ { ...buildEmployee(passwordHash), password_reset_failed_count: 4, password_reset_failed_first_at: new Date(), password_reset_last_requested_at: new Date(), }, ]); const response = await app.inject({ method: "POST", url: "/api/auth/forgot-password", headers: { "content-type": "application/json" }, payload: { email: "employee@hyinda.test" }, }); expect(response.statusCode).toBe(200); expect(response.json().data).toEqual({}); expect(passwordResetCodeCreate).not.toHaveBeenCalled(); expect(employeeUpdate).not.toHaveBeenCalled(); await app.close(); }); it("invalidates any active password reset code before creating its replacement", async () => { const passwordHash = await hashPassword("AdminPassword1234"); const randomSpy = vi.spyOn(crypto, "randomInt").mockReturnValue(111111); const { app, passwordResetCodeCreate, passwordResetCodeUpdateMany } = await buildApp(passwordHash); const response = await app.inject({ method: "POST", url: "/api/auth/forgot-password", headers: { "content-type": "application/json" }, payload: { email: "employee@hyinda.test" }, }); expect(response.statusCode).toBe(200); expect(passwordResetCodeCreate).toHaveBeenCalledTimes(1); expect(passwordResetCodeCreate.mock.calls[0][0].data.code_hash).toBe( hashPasswordResetCode("111111"), ); expect(passwordResetCodeUpdateMany).toHaveBeenCalledTimes(1); expect(passwordResetCodeUpdateMany.mock.invocationCallOrder[0]).toBeLessThan( passwordResetCodeCreate.mock.invocationCallOrder[0], ); randomSpy.mockRestore(); await app.close(); }); it("locks password recovery on the fifth failed code without rolling back the counters", async () => { const passwordHash = await hashPassword("AdminPassword1234"); const { app, queryRaw, employeeUpdate, passwordResetCodeFindFirst, passwordResetCodeUpdateMany, auditCreate, } = await buildApp(passwordHash); queryRaw.mockResolvedValueOnce([ { ...buildEmployee(passwordHash), password_reset_failed_count: 4, password_reset_failed_first_at: new Date(), }, ]); passwordResetCodeFindFirst.mockResolvedValueOnce({ id: "password-reset-code-1", code_hash: hashPasswordResetCode("654321"), attempts_count: 4, }); const response = await app.inject({ method: "POST", url: "/api/auth/reset-password", headers: { "content-type": "application/json" }, payload: { email: "employee@hyinda.test", code: "000000", newPassword: "ReplacementPassword1234", }, }); expect(response.statusCode).toBe(400); expect(response.json().error.details).toEqual({ code: "invalid_or_expired", reasonCode: "auth.code_invalid_or_expired" }); expect(employeeUpdate).toHaveBeenCalledWith({ where: { id: EMPLOYEE_ID }, data: expect.objectContaining({ password_reset_failed_count: 5, password_reset_locked_until: expect.any(Date), }), }); expect(passwordResetCodeUpdateMany).toHaveBeenCalledWith({ where: { id: "password-reset-code-1", used_at: null, invalidated_at: null, }, data: { attempts_count: { increment: 1 }, invalidated_at: expect.any(Date), }, }); expect(auditCreate).toHaveBeenCalledWith({ data: expect.objectContaining({ action: "AUTH_PASSWORD_RESET_LOCKED" }), }); await app.close(); }); it("does not count a failure when no active password-reset challenge exists", async () => { const passwordHash = await hashPassword("AdminPassword1234"); const { app, employeeUpdate, passwordResetCodeUpdateMany, auditCreate } = await buildApp(passwordHash); const response = await app.inject({ method: "POST", url: "/api/auth/reset-password", headers: { "content-type": "application/json" }, payload: { email: "employee@hyinda.test", code: "000000", newPassword: "ReplacementPassword1234", }, }); expect(response.statusCode).toBe(400); expect(response.json().error.details).toEqual({ code: "invalid_or_expired", reasonCode: "auth.code_invalid_or_expired" }); expect(employeeUpdate).not.toHaveBeenCalled(); expect(passwordResetCodeUpdateMany).not.toHaveBeenCalled(); expect(auditCreate).not.toHaveBeenCalled(); await app.close(); }); it("returns the same reset error for missing, inactive, locked, and wrong-code accounts", async () => { const passwordHash = await hashPassword("AdminPassword1234"); const { app, queryRaw, passwordResetCodeFindFirst } = await buildApp(passwordHash); queryRaw .mockReset() .mockResolvedValueOnce([]) .mockResolvedValueOnce([{ ...buildEmployee(passwordHash), status: "disabled" }]) .mockResolvedValueOnce([ { ...buildEmployee(passwordHash), password_reset_locked_until: new Date(Date.now() + 60_000), }, ]) .mockResolvedValueOnce([buildEmployee(passwordHash)]); passwordResetCodeFindFirst.mockResolvedValueOnce({ id: "password-reset-code-1", code_hash: hashPasswordResetCode("654321"), attempts_count: 0, }); const errors = []; for (const email of [ "missing@hyinda.test", "inactive@hyinda.test", "locked@hyinda.test", "active@hyinda.test", ]) { const response = await app.inject({ method: "POST", url: "/api/auth/reset-password", headers: { "content-type": "application/json" }, payload: { email, code: "000000", newPassword: "ReplacementPassword1234", }, }); expect(response.statusCode).toBe(400); errors.push(response.json().error); } for (const error of errors) { expect(error).toEqual(errors[0]); } await app.close(); }); it("resets the password once and revokes every challenge and refresh session", async () => { const passwordHash = await hashPassword("AdminPassword1234"); const { app, employeeUpdate, authSessionUpdateMany, emailLoginCodeUpdateMany, passwordResetCodeFindFirst, passwordResetCodeUpdateMany, auditCreate, } = await buildApp(passwordHash); passwordResetCodeFindFirst.mockResolvedValueOnce({ id: "password-reset-code-1", code_hash: hashPasswordResetCode("654321"), attempts_count: 0, }); const response = await app.inject({ method: "POST", url: "/api/auth/reset-password", headers: { "content-type": "application/json" }, payload: { email: "employee@hyinda.test", code: "654321", newPassword: "ReplacementPassword1234", }, }); expect(response.statusCode).toBe(200); const passwordUpdate = employeeUpdate.mock.calls.find( ([argument]) => typeof argument.data.password_hash === "string", )[0]; expect(await verifyPassword("ReplacementPassword1234", passwordUpdate.data.password_hash)).toBe(true); expect(passwordUpdate.data).toEqual( expect.objectContaining({ token_version: { increment: 1 }, password_reset_failed_count: 0, password_reset_request_count: 0, email_login_code_failed_count: 0, }), ); expect(authSessionUpdateMany).toHaveBeenCalledWith({ where: { employee_id: EMPLOYEE_ID, revoked_at: null }, data: { revoked_at: expect.any(Date), last_seen_at: expect.any(Date) }, }); expect(passwordResetCodeUpdateMany).toHaveBeenCalledWith({ where: { employee_id: EMPLOYEE_ID, invalidated_at: null }, data: { invalidated_at: expect.any(Date) }, }); expect(emailLoginCodeUpdateMany).toHaveBeenCalledWith({ where: { employee_id: EMPLOYEE_ID, used_at: null }, data: { used_at: expect.any(Date) }, }); expect(auditCreate).toHaveBeenCalledWith({ data: expect.objectContaining({ action: "AUTH_PASSWORD_RESET_SUCCEEDED" }), }); await app.close(); }); it("allows only one concurrent consumer of the same password reset code", async () => { const passwordHash = await hashPassword("AdminPassword1234"); const { app, passwordResetCodeFindFirst, passwordResetCodeUpdateMany, auditCreate, } = await buildApp(passwordHash); passwordResetCodeFindFirst.mockResolvedValue({ id: "password-reset-code-1", code_hash: hashPasswordResetCode("654321"), attempts_count: 0, }); let consumed = false; passwordResetCodeUpdateMany.mockImplementation(async ({ where, data }) => { if (where.id && data.used_at) { if (consumed) return { count: 0 }; consumed = true; } return { count: 1 }; }); const responses = await Promise.all( Array.from({ length: 2 }, () => app.inject({ method: "POST", url: "/api/auth/reset-password", headers: { "content-type": "application/json" }, payload: { email: "employee@hyinda.test", code: "654321", newPassword: "ReplacementPassword1234", }, }), ), ); expect(responses.map((response) => response.statusCode).sort()).toEqual([200, 400]); expect( auditCreate.mock.calls.filter( ([argument]) => argument.data.action === "AUTH_PASSWORD_RESET_SUCCEEDED", ), ).toHaveLength(1); await app.close(); }); it("invalidates outstanding challenges when the user changes the password", async () => { const passwordHash = await hashPassword("AdminPassword1234"); const { app, employeeUpdateMany, authSessionUpdateMany, authSessionCreate, emailLoginCodeUpdateMany, passwordResetCodeUpdateMany, } = await buildApp(passwordHash); const response = await app.inject({ method: "POST", url: "/api/auth/change-password", headers: { "content-type": "application/json", "x-device-fingerprint": "change-password-device", }, payload: { oldPassword: "AdminPassword1234", newPassword: "ChangedPassword1234", }, }); expect(response.statusCode).toBe(200); expect(employeeUpdateMany).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ id: EMPLOYEE_ID, password_hash: passwordHash, token_version: 3, }), }), ); expect(authSessionUpdateMany).toHaveBeenCalledWith({ where: { employee_id: EMPLOYEE_ID, revoked_at: null }, data: { revoked_at: expect.any(Date), last_seen_at: expect.any(Date) }, }); expect(passwordResetCodeUpdateMany).toHaveBeenCalledWith({ where: { employee_id: EMPLOYEE_ID, invalidated_at: null }, data: { invalidated_at: expect.any(Date) }, }); expect(emailLoginCodeUpdateMany).toHaveBeenCalledWith({ where: { employee_id: EMPLOYEE_ID, used_at: null }, data: { used_at: expect.any(Date) }, }); expect(authSessionCreate).toHaveBeenCalledWith({ data: expect.objectContaining({ device_fingerprint: "change-password-device" }), }); await app.close(); }); });