文件
hyb-oa/server/tests/unit/routes/auth.login.test.js
T
2026-09-16 20:20:11 +08:00

1268 行
42 KiB
JavaScript

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: "[email protected]",
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}`);
if (reason === "password_policy_failed") {
expect(response.json().error.details.password).toEqual(expect.arrayContaining([
"密码必须包含大写字母", "密码必须包含数字",
]));
expect(response.json().error.details.password).not.toContain("密码必须包含小写字母");
}
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: "[email protected]", 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: "[email protected]",
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: "[email protected]" },
});
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: "[email protected]" },
});
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: "[email protected]" },
});
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: "[email protected]", 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: "[email protected]", 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: " [email protected] " },
});
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: "[email protected]" },
});
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: "[email protected]" },
});
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: "[email protected]",
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: "[email protected]" },
});
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: "[email protected]", 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: "[email protected]", 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: "[email protected]" },
});
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: "[email protected]" },
});
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: "[email protected]",
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: "[email protected]",
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 [
"[email protected]",
"[email protected]",
"[email protected]",
"[email protected]",
]) {
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: "[email protected]",
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: "[email protected]",
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();
});
});