修复认证验证码投递与登录入口体验 05-27-oa-auth-delivery-wecom-entry-fix
这个提交包含在:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
在新工单中引用
屏蔽一个用户