修复认证验证码投递与登录入口体验 05-27-oa-auth-delivery-wecom-entry-fix

这个提交包含在:
2026-05-27 10:14:49 +08:00
父节点 347d337c0c
当前提交 511efa0b2d
共修改 17 个文件,包含 746 行新增和 63 行删除
+169 -1
查看文件
@@ -4,7 +4,7 @@ const fastifyCookie = require("@fastify/cookie");
const authRoutes = require("../../../src/routes/auth");
const { AppError } = require("../../../src/utils/AppError");
const { hashPassword } = require("../../../src/utils/crypto");
const { hashPassword, sha256Hex } = require("../../../src/utils/crypto");
const { signPayload } = require("../../../src/utils/signedUrl");
const EMPLOYEE_ID = "11111111-1111-4111-8111-111111111111";
@@ -28,15 +28,18 @@ async function buildApp(passwordHash) {
login_failed_first_at: null,
login_locked_until: null,
});
const employeeFindMany = vi.fn().mockResolvedValue([]);
const employeeUpdate = vi.fn().mockResolvedValue({});
const authSessionFindMany = vi.fn().mockResolvedValue([]);
const authSessionUpdateMany = vi.fn().mockResolvedValue({ count: 0 });
const authSessionCreate = vi.fn().mockResolvedValue({ id: "session-1" });
const auditCreate = vi.fn().mockResolvedValue({ id: "audit-1" });
const notificationCreateMany = vi.fn().mockResolvedValue({ count: 1 });
const emailLoginCodeCreate = vi.fn().mockResolvedValue({ id: "email-code-1" });
const emailLoginCodeFindFirst = vi.fn().mockResolvedValue(null);
const emailLoginCodeUpdate = vi.fn().mockResolvedValue({ id: "email-code-1" });
const emailLoginCodeUpdateMany = vi.fn().mockResolvedValue({ count: 1 });
const passwordResetCodeCreate = vi.fn().mockResolvedValue({ id: "password-reset-code-1" });
const wecomFindFirst = vi.fn().mockResolvedValue(null);
const wecomUpsert = vi.fn().mockResolvedValue({ id: "binding-1" });
@@ -56,6 +59,9 @@ async function buildApp(passwordHash) {
auditLog: {
create: auditCreate,
},
notification: {
createMany: notificationCreateMany,
},
wecomIdentityBinding: {
findFirst: wecomFindFirst,
upsert: wecomUpsert,
@@ -72,6 +78,14 @@ async function buildApp(passwordHash) {
loginFailMaxAttempts: 5,
emailLoginCodeTtlSeconds: 600,
emailLoginCodeMaxAttempts: 5,
passwordResetCodeTtlSeconds: 600,
emailEnabled: true,
emailFrom: "[email protected]",
smtpHost: "smtp.hyinda.test",
smtpPort: 587,
smtpSecure: false,
smtpUser: "",
smtpPass: "",
rateLimitEnabled: false,
jwtSecret: JWT_SECRET,
appOrigin: "http://127.0.0.1:3000",
@@ -80,14 +94,21 @@ async function buildApp(passwordHash) {
app.decorate("prisma", {
employee: {
findFirst: employeeFindFirst,
findMany: employeeFindMany,
},
emailLoginCode: {
create: emailLoginCodeCreate,
findFirst: emailLoginCodeFindFirst,
},
passwordResetCode: {
create: passwordResetCodeCreate,
},
auditLog: {
create: auditCreate,
},
notification: {
createMany: notificationCreateMany,
},
$transaction: vi.fn(async (callback) => callback(tx)),
});
app.decorate("issueAccessToken", vi.fn().mockReturnValue("access-token"));
@@ -110,15 +131,18 @@ async function buildApp(passwordHash) {
return {
app,
employeeFindFirst,
employeeFindMany,
employeeUpdate,
authSessionFindMany,
authSessionUpdateMany,
authSessionCreate,
auditCreate,
notificationCreateMany,
emailLoginCodeCreate,
emailLoginCodeFindFirst,
emailLoginCodeUpdate,
emailLoginCodeUpdateMany,
passwordResetCodeCreate,
wecomFindFirst,
wecomUpsert,
};
@@ -209,6 +233,78 @@ describe("auth login route", () => {
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("auto-binds pending wecom identity after password login", async () => {
const passwordHash = await hashPassword("AdminPassword1234");
const { app, wecomFindFirst, wecomUpsert } = await buildApp(passwordHash);
@@ -303,6 +399,26 @@ describe("auth login route", () => {
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, employeeFindFirst, 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(employeeFindFirst).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, employeeFindFirst, emailLoginCodeCreate } = await buildApp(passwordHash);
@@ -408,4 +524,56 @@ describe("auth login route", () => {
await app.close();
});
it("sends password reset codes with Beijing-time expiry wording", async () => {
const passwordHash = await hashPassword("AdminPassword1234");
const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0.654321);
const { app, passwordResetCodeCreate, 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: sha256Hex("654321"),
}),
});
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, employeeFindFirst, 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(employeeFindFirst).not.toHaveBeenCalled();
expect(passwordResetCodeCreate).not.toHaveBeenCalled();
await app.close();
});
});