1227 行
40 KiB
JavaScript
1227 行
40 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("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("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" });
|
|
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" });
|
|
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" });
|
|
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" });
|
|
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();
|
|
});
|
|
});
|