From 511efa0b2d41a17601e609b2b28bef76fa0206a4 Mon Sep 17 00:00:00 2001 From: shunli Date: Wed, 27 May 2026 10:14:49 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=AE=A4=E8=AF=81=E9=AA=8C?= =?UTF-8?q?=E8=AF=81=E7=A0=81=E6=8A=95=E9=80=92=E4=B8=8E=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E5=85=A5=E5=8F=A3=E4=BD=93=E9=AA=8C=2005-27-oa-auth-delivery-w?= =?UTF-8?q?ecom-entry-fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../check.jsonl | 4 + .../implement.jsonl | 4 + .../implement.md | 15 ++ .../prd.md | 124 +++++++++++++ .../task.json | 34 ++++ client/src/api/auth.ts | 17 ++ client/src/api/errorMessages.ts | 4 + client/src/pages/ForgotPasswordPage.tsx | 46 ++++- client/src/pages/LoginPage.tsx | 72 +++++++- client/tests/e2e/login-portal-ux.spec.ts | 68 ++++++- client/tests/e2e/wecom-entry.spec.ts | 65 ++++--- scripts/qa/check-live-smoke-env.mjs | 12 +- server/src/routes/auth.js | 87 ++++++++- server/src/scripts/smoke-external-deps.js | 18 +- server/src/services/email/index.js | 32 ++++ server/tests/unit/routes/auth.login.test.js | 170 +++++++++++++++++- server/tests/unit/services/email.test.js | 37 ++++ 17 files changed, 746 insertions(+), 63 deletions(-) create mode 100644 .trellis/tasks/05-27-oa-auth-delivery-wecom-entry-fix/check.jsonl create mode 100644 .trellis/tasks/05-27-oa-auth-delivery-wecom-entry-fix/implement.jsonl create mode 100644 .trellis/tasks/05-27-oa-auth-delivery-wecom-entry-fix/implement.md create mode 100644 .trellis/tasks/05-27-oa-auth-delivery-wecom-entry-fix/prd.md create mode 100644 .trellis/tasks/05-27-oa-auth-delivery-wecom-entry-fix/task.json create mode 100644 server/tests/unit/services/email.test.js diff --git a/.trellis/tasks/05-27-oa-auth-delivery-wecom-entry-fix/check.jsonl b/.trellis/tasks/05-27-oa-auth-delivery-wecom-entry-fix/check.jsonl new file mode 100644 index 00000000..ad6da1d0 --- /dev/null +++ b/.trellis/tasks/05-27-oa-auth-delivery-wecom-entry-fix/check.jsonl @@ -0,0 +1,4 @@ +{"file": ".trellis/spec/hyb-oa/backend-fastify-prisma.md", "reason": "Check backend syntax, focused route tests and Prisma validation."} +{"file": ".trellis/spec/hyb-oa/frontend-react-vite-design.md", "reason": "Check frontend typecheck, build, Playwright auth flows and screenshot evidence."} +{"file": ".trellis/spec/hyb-oa/security-privacy.md", "reason": "Check test output and task notes do not expose SMTP secrets, real verification codes or sensitive accounts."} +{"file": ".trellis/spec/hyb-oa/wecom-boundary.md", "reason": "Check ordinary browser WeCom entry is non-clickable while WebView CTA remains available."} diff --git a/.trellis/tasks/05-27-oa-auth-delivery-wecom-entry-fix/implement.jsonl b/.trellis/tasks/05-27-oa-auth-delivery-wecom-entry-fix/implement.jsonl new file mode 100644 index 00000000..60e19d8b --- /dev/null +++ b/.trellis/tasks/05-27-oa-auth-delivery-wecom-entry-fix/implement.jsonl @@ -0,0 +1,4 @@ +{"file": ".trellis/spec/hyb-oa/backend-fastify-prisma.md", "reason": "Auth route and email service changes must follow backend route/service validation rules."} +{"file": ".trellis/spec/hyb-oa/frontend-react-vite-design.md", "reason": "Login and forgot-password UI changes must follow auth-page layout, PWA and frontend design contracts."} +{"file": ".trellis/spec/hyb-oa/security-privacy.md", "reason": "Auth delivery work must avoid leaking credentials, verification codes, tokens or account-existence signals."} +{"file": ".trellis/spec/hyb-oa/wecom-boundary.md", "reason": "Enterprise WeChat login entry behavior must preserve OA/WeCom boundary and avoid unapproved backend OAuth changes."} diff --git a/.trellis/tasks/05-27-oa-auth-delivery-wecom-entry-fix/implement.md b/.trellis/tasks/05-27-oa-auth-delivery-wecom-entry-fix/implement.md new file mode 100644 index 00000000..0290bc0d --- /dev/null +++ b/.trellis/tasks/05-27-oa-auth-delivery-wecom-entry-fix/implement.md @@ -0,0 +1,15 @@ +# Implementation Notes + +## Approach + +- Backend exposes `/api/auth/capabilities` with non-secret email delivery readiness. +- Email-login code and forgot-password code requests fail before account lookup when SMTP delivery is not configured, preserving account enumeration protection. +- Verification email copy uses Chinese Beijing-time expiry wording instead of ISO UTC timestamps. +- Normal browsers show Enterprise WeChat as an informational entry only; Enterprise WeChat WebView keeps one-click login as the primary CTA. +- Frontend email-code login and forgot-password pages disable code sending when delivery is not ready. + +## Verification Focus + +- Backend unit coverage for delivery capability, delivery-unavailable behavior and Beijing-time email wording. +- Frontend E2E coverage for password visibility, PWA update prompt, email-code login, disabled email delivery state, forgot-password flow and Enterprise WeChat entry hierarchy. +- Deployment checks report public auth capability status without printing SMTP values. diff --git a/.trellis/tasks/05-27-oa-auth-delivery-wecom-entry-fix/prd.md b/.trellis/tasks/05-27-oa-auth-delivery-wecom-entry-fix/prd.md new file mode 100644 index 00000000..3a6d39a7 --- /dev/null +++ b/.trellis/tasks/05-27-oa-auth-delivery-wecom-entry-fix/prd.md @@ -0,0 +1,124 @@ +# 认证验证码投递与企业微信入口修复 + +## Goal + +修复部署后认证入口的两个真实问题:邮箱登录验证码和密码重置验证码无法收到;普通浏览器点击企业微信登录会跳转到只能在企业微信内使用的 OAuth 页面并报错。目标是让认证入口行为和真实能力一致,避免用户误判“验证码已发送”或点击错误入口。 + +## Scope + +- 本任务覆盖后端认证接口、邮件投递能力探测、认证验证码邮件文案、登录页、找回密码页、普通浏览器企业微信入口提示、部署检查和对应自动化测试。 +- 本任务不接入 PC 企业微信扫码登录,不修改企业微信后台配置,不在未获批准时继续修改远端 `.env` 或部署代码。 + +## What I Already Know + +- 用户反馈: + - 邮箱收不到登录验证码和密码重置验证码。 + - 点击企业微信登录报错,实际需要在企业微信里点击。 +- 登录验证码和密码重置验证码都通过 `sendNotificationEmailSafe` + SMTP 邮件服务发送。 +- 公网远端运行时配置显示: + - `emailEnabled=false` + - `smtpHost=false` + - `smtpUser=false` + - `smtpPass=false` + - `emailFrom=true` + - `smtpPort=587` + - `smtpSecure=false` +- 本地 `~/.hyb-oa/live-smoke.env` 也没有 SMTP 配置可直接同步。 +- 当前邮箱验证码接口会在账号存在且 active 时创建验证码和审计记录,然后异步调用邮件发送;当邮件服务关闭时,用户仍看到通用成功提示。 +- 当前普通浏览器下只要 `wecomEntry` ready,就显示“企业微信登录”按钮;点击后进入 `/api/auth/wecom/login` 并跳转企业微信 OAuth。 +- 当前企业微信入口的 OAuth 设计是企业微信工作台/WebView 入口,不是 PC 浏览器扫码登录。 + +## Diagnosis + +- 邮箱问题的直接原因是公网试运行环境未启用或配置 SMTP,两个验证码功能没有可用邮件通道。 +- 代码体验问题是认证页没有把“邮件服务未启用”和“普通浏览器不支持企业微信 OAuth”转化为清晰交互。 +- 安全约束:验证码请求仍必须避免账号枚举。不能因为邮箱是否存在而返回不同提示。 + +## Discussion Notes + +- 企业邮箱 SMTP 配置复杂度低,前提是企业邮箱已允许客户端 SMTP,且能为专用发件邮箱生成客户端专用密码或授权码。 +- 推荐使用独立发件邮箱,例如 `oa` 或 `no-reply` 类账号;不要使用个人员工邮箱或管理员真实登录密码。 +- 试运行环境当前缺 SMTP 配置,因此必须先补远端 `.env` 并重启 server,才能验证真实收信。 +- PC 企业微信扫码登录是独立能力,不等同于当前“企业微信内 OAuth 免登”。它适合 PC 常用用户,但需要单独配置企业微信授权登录回调域、前端二维码入口和后端扫码回调处理。 +- 当前修复建议先做 SMTP 投递和普通浏览器企业微信入口防误点;PC 企业微信扫码登录可列为后续 P1,不放进本次故障修复 MVP。 + +## Requirements + +- 邮件服务全局未启用或 SMTP 未配置时: + - 登录页邮箱验证码发送应提示“邮件服务暂未启用,请联系管理员或使用密码登录”,而不是“验证码将发送”。 + - 忘记密码页应给出同类提示。 + - 不暴露输入邮箱是否存在。 +- 公网/部署检查应能发现邮件验证码依赖的 SMTP 未启用状态,避免再次“功能上线但无法收信”。 +- 普通浏览器下: + - 不提供可点击的企业微信 OAuth 登录按钮。 + - 显示清晰提示:企业微信一键进入需从企业微信工作台或企业微信内打开 OA。 + - 不影响企业微信 WebView 下主 CTA。 +- 企业微信 WebView 下: + - 企微一键进入仍为主入口。 + - 账号密码/邮箱验证码兜底仍可按现有逻辑出现。 +- 不打印、提交或写入 SMTP 密码、真实邮箱、验证码、token、Secret。 + +## Acceptance Criteria + +- [ ] 公网或本地配置缺 SMTP 时,邮箱验证码和忘记密码不再误提示“验证码已发送”。 +- [ ] 配置完整时,验证码请求仍返回通用成功,不暴露账号是否存在。 +- [ ] 普通浏览器不再点击进入企业微信 OAuth 报错页。 +- [ ] 企业微信 WebView 下企业微信主入口不回退。 +- [ ] 后端/前端测试覆盖邮件服务未启用提示和普通浏览器企微入口提示。 +- [ ] 部署/公网检查能报告邮件投递配置是否 ready。 +- [ ] 需要真实收信验证时,仅在获得 SMTP 凭据和远端配置修改批准后执行。 + +## Proposed Implementation + +1. Backend: + - 增加一个安全的公开认证能力配置接口,或扩展现有公开 auth config,暴露 `emailDeliveryReady` 这类非敏感布尔值。 + - 在邮箱验证码和忘记密码请求中,若邮件服务全局未配置,返回统一错误(不依赖邮箱是否存在),让前端能提示服务未启用。 +2. Frontend: + - 登录页邮箱验证码模式根据 `emailDeliveryReady` 禁用发送按钮并显示说明。 + - 忘记密码页根据 `emailDeliveryReady` 禁用发送或显示服务未启用说明。 + - 普通浏览器下企业微信区域改为说明性入口,不触发 OAuth;企业微信 WebView 才显示可点击 CTA。 +3. QA: + - 增加/更新 auth 单测覆盖邮件未配置分支。 + - 更新登录页、忘记密码、企微入口 E2E。 + - 更新 live smoke/env 检查,输出邮件投递 ready 状态但不打印 SMTP 值。 +4. Operations: + - 若用户提供并批准 SMTP 配置,先备份远端 `.env`,再写入 SMTP 相关键并重启 server。 + - 配置后执行一次受控真实收信验证,不在日志和记录中保存验证码。 + +## Test Plan + +```bash +cd server && node --check src/routes/auth.js src/routes/wecomAuth.js src/config.js +cd server && npm test -- --run tests/unit/routes/auth.login.test.js +cd client && npm run typecheck +cd client && npm run build +E2E_BASE_URL=http://127.0.0.1:5173 npx playwright test tests/e2e/login-portal-ux.spec.ts tests/e2e/wecom-entry.spec.ts --workers=1 --reporter=list +node deploy/trialrun/validate-env-contract.js +node scripts/qa/check-live-smoke-env.mjs +python3 ./.trellis/scripts/task.py validate 05-27-oa-auth-delivery-wecom-entry-fix +node scripts/qa/check-trellis-governance.mjs +git diff --check +``` + +## Out of Scope + +- 不在本任务接入 PC 企业微信扫码登录。 +- 不在未获批准时修改远端 `.env`。 +- 不在聊天、Trellis、docs 或日志中记录 SMTP 密码、验证码或完整账号。 +- 不修改企业微信后台配置。 + +## Technical Notes + +- Email service: `server/src/services/email/index.js` +- Auth routes: `server/src/routes/auth.js` +- WeCom auth routes: `server/src/routes/wecomAuth.js` +- Login page: `client/src/pages/LoginPage.tsx` +- WeCom entry helpers: `client/src/lib/wecomEntry.ts` +- Runtime context: `client/src/lib/runtimeContext.ts` +- Live env check: `scripts/qa/check-live-smoke-env.mjs` + +## Progress Notes + +- 公网 SMTP 配置已完成一次受控验证:有效企业邮箱可以收到登录验证码和密码重置验证码;邮件被企业邮箱归入免提醒邮件。 +- 本地代码已补齐认证能力接口、北京时间有效期文案、邮箱验证码入口禁用态和普通浏览器企业微信入口提示。 +- 截止本记录,代码改动尚未部署到公网;公网 `/api/auth/capabilities` 仍需部署后验证。 diff --git a/.trellis/tasks/05-27-oa-auth-delivery-wecom-entry-fix/task.json b/.trellis/tasks/05-27-oa-auth-delivery-wecom-entry-fix/task.json new file mode 100644 index 00000000..7e754bba --- /dev/null +++ b/.trellis/tasks/05-27-oa-auth-delivery-wecom-entry-fix/task.json @@ -0,0 +1,34 @@ +{ + "id": "oa-auth-delivery-wecom-entry-fix", + "name": "oa-auth-delivery-wecom-entry-fix", + "title": "认证验证码投递与企业微信入口修复", + "description": "修复邮箱验证码真实投递能力、验证码有效期展示和普通浏览器企业微信入口误点击问题,并补齐登录页交互验证。", + "status": "in_progress", + "dev_type": null, + "scope": "server/client/auth", + "package": null, + "priority": "P2", + "creator": "shunli", + "assignee": "shunli", + "createdAt": "2026-05-27", + "completedAt": null, + "branch": null, + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [ + "server/src/routes/auth.js", + "server/src/services/email/index.js", + "client/src/pages/LoginPage.tsx", + "client/src/pages/ForgotPasswordPage.tsx", + "client/src/api/auth.ts", + "client/tests/e2e/login-portal-ux.spec.ts", + "client/tests/e2e/wecom-entry.spec.ts" + ], + "notes": "", + "meta": {} +} diff --git a/client/src/api/auth.ts b/client/src/api/auth.ts index 2213f303..a6636356 100644 --- a/client/src/api/auth.ts +++ b/client/src/api/auth.ts @@ -57,6 +57,19 @@ export async function verifyEmailLoginCode(payload: { email: string; code: strin }); } +export type AuthCapabilities = { + emailDelivery: { + ready: boolean; + statusReasons: string[]; + }; + emailLoginCode: { + enabled: boolean; + }; + passwordReset: { + enabled: boolean; + }; +}; + export type SsoProviderInfo = { id: "oidc"; label: string; enabled: boolean; loginUrl: string }; export type WecomEntryConfig = { @@ -76,6 +89,10 @@ export async function fetchSsoProviders() { return apiRequestData("/api/auth/sso/providers", { auth: false }); } +export async function fetchAuthCapabilities() { + return apiRequestData("/api/auth/capabilities", { auth: false }); +} + export async function fetchWecomConfig() { return apiRequestData("/api/auth/wecom/config", { auth: false }); } diff --git a/client/src/api/errorMessages.ts b/client/src/api/errorMessages.ts index 76392802..79205d7d 100644 --- a/client/src/api/errorMessages.ts +++ b/client/src/api/errorMessages.ts @@ -82,6 +82,10 @@ export function mapApiErrorMessage( return "目标数据不存在或已被删除。"; } + if (code === "SYS_001" && details?.email === "delivery_not_configured") { + return "邮件验证码暂不可用,请使用密码登录或联系管理员。"; + } + if (code === "SYS_001") { return "服务暂不可用,请稍后重试或联系管理员。"; } diff --git a/client/src/pages/ForgotPasswordPage.tsx b/client/src/pages/ForgotPasswordPage.tsx index 65ed110d..57a903a4 100644 --- a/client/src/pages/ForgotPasswordPage.tsx +++ b/client/src/pages/ForgotPasswordPage.tsx @@ -1,6 +1,8 @@ import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { MailCheck } from "lucide-react"; import { Link, useNavigate } from "react-router-dom"; -import { forgotPassword, resetPassword } from "../api/auth"; +import { fetchAuthCapabilities, forgotPassword, resetPassword } from "../api/auth"; import { mapApiErrorMessage } from "../api/errorMessages"; import { isApiError } from "../api/types"; import { AuthPanel } from "../components/auth/AuthPanel"; @@ -26,6 +28,15 @@ export function ForgotPasswordPage() { const [submittingReset, setSubmittingReset] = useState(false); const [message, setMessage] = useState(""); const [error, setError] = useState(""); + const authCapabilitiesQuery = useQuery({ + queryKey: ["auth", "capabilities"], + queryFn: () => fetchAuthCapabilities(), + staleTime: 60_000, + }); + const emailDeliveryReady = authCapabilitiesQuery.data?.emailDelivery.ready === true; + const emailDeliveryChecking = authCapabilitiesQuery.isLoading; + const emailDeliveryUnavailable = authCapabilitiesQuery.isSuccess && !emailDeliveryReady; + const emailDeliveryUnknown = authCapabilitiesQuery.isError; async function handleRequestCode(event: React.FormEvent) { event.preventDefault(); @@ -37,12 +48,20 @@ export function ForgotPasswordPage() { setError("请输入邮箱。"); return; } + if (!emailDeliveryReady) { + setError( + emailDeliveryChecking + ? "正在检查邮件服务,请稍后再试。" + : "邮件验证码暂不可用,请联系管理员处理。", + ); + return; + } setSubmittingCode(true); try { await forgotPassword({ email: normalizedEmail }); setCodeRequested(true); - setMessage("如账号可用,验证码将发送到该邮箱。请在有效期内完成重置。"); + setMessage("如账号可用,验证码将发送到企业邮箱;请在有效期内完成重置,并查看免提醒邮件。"); } catch (err) { if (isApiError(err)) { setError(mapApiErrorMessage(err.code, err.details, err.message)); @@ -118,8 +137,27 @@ export function ForgotPasswordPage() { /> - diff --git a/client/src/pages/LoginPage.tsx b/client/src/pages/LoginPage.tsx index 9083bf63..cf4d06d7 100644 --- a/client/src/pages/LoginPage.tsx +++ b/client/src/pages/LoginPage.tsx @@ -1,7 +1,8 @@ import { useEffect, useMemo, useState } from "react"; import { useQuery } from "@tanstack/react-query"; +import { Building2, MailCheck } from "lucide-react"; import { Link, useNavigate, useSearchParams } from "react-router-dom"; -import { fetchSsoProviders, fetchWecomConfig, requestEmailLoginCode } from "../api/auth"; +import { fetchAuthCapabilities, fetchSsoProviders, fetchWecomConfig, requestEmailLoginCode } from "../api/auth"; import { mapApiErrorMessage } from "../api/errorMessages"; import { isApiError } from "../api/types"; import { useAuth } from "../auth/AuthContext"; @@ -70,6 +71,16 @@ export function LoginPage() { }); const oidcProvider = (ssoProvidersQuery.data ?? []).find((item) => item.id === "oidc" && item.enabled); + const authCapabilitiesQuery = useQuery({ + queryKey: ["auth", "capabilities"], + queryFn: () => fetchAuthCapabilities(), + staleTime: 60_000, + }); + const emailDeliveryReady = authCapabilitiesQuery.data?.emailDelivery.ready === true; + const emailDeliveryChecking = authCapabilitiesQuery.isLoading; + const emailDeliveryUnavailable = authCapabilitiesQuery.isSuccess && !emailDeliveryReady; + const emailDeliveryUnknown = authCapabilitiesQuery.isError; + const wecomConfigQuery = useQuery({ queryKey: ["auth", "wecom-config"], queryFn: () => fetchWecomConfig(), @@ -85,6 +96,7 @@ export function LoginPage() { const showWecomPrimary = isWecomContext; const showPasswordPanel = !showWecomPrimary || Boolean(wecomNotice) || passwordFallbackOpen || (showWecomPrimary && wecomConfig && !wecomEntry); + const showBrowserWecomNotice = !showWecomPrimary && Boolean(wecomEntry); useEffect(() => { if (emailCodeCooldown <= 0) { @@ -140,13 +152,21 @@ export function LoginPage() { setEmailCodeError("请输入企业邮箱。"); return; } + if (!emailDeliveryReady) { + setEmailCodeError( + emailDeliveryChecking + ? "正在检查邮件服务,请稍后再试。" + : "邮件验证码暂不可用,请使用密码登录或联系管理员。", + ); + return; + } setEmailCodeSending(true); try { await requestEmailLoginCode({ email }); setEmailCodeRequested(true); setEmailCodeCooldown(60); - setEmailCodeMessage("如账号可用,验证码将发送到该邮箱。"); + setEmailCodeMessage("如账号可用,验证码将发送到企业邮箱;请同时查看免提醒邮件。"); } catch (err) { if (isApiError(err)) { setEmailCodeError(mapApiErrorMessage(err.code, err.details, err.message)); @@ -266,14 +286,41 @@ export function LoginPage() { + {!emailDeliveryReady ? ( +
+
+ ) : null} +
@@ -375,7 +422,7 @@ export function LoginPage() { : renderPasswordPanel(showWecomPrimary ? "login-password-secondary-panel" : "login-password-primary-panel") : null} - {!showWecomPrimary && (wecomEntry || oidcProvider) ? ( + {!showWecomPrimary && (showBrowserWecomNotice || oidcProvider) ? (
@@ -383,10 +430,17 @@ export function LoginPage() {
- {wecomEntry ? ( - + {showBrowserWecomNotice ? ( +
+
) : null} {oidcProvider ? ( diff --git a/client/tests/e2e/login-portal-ux.spec.ts b/client/tests/e2e/login-portal-ux.spec.ts index 2e03127d..7a4169a8 100644 --- a/client/tests/e2e/login-portal-ux.spec.ts +++ b/client/tests/e2e/login-portal-ux.spec.ts @@ -15,8 +15,30 @@ function envelope(data: unknown) { }; } -function mockPasswordLogin(page: import("@playwright/test").Page) { +function authCapabilitiesPayload(emailReady = true) { + return { + emailDelivery: { + ready: emailReady, + statusReasons: emailReady ? [] : ["email_disabled"], + }, + emailLoginCode: { + enabled: emailReady, + }, + passwordReset: { + enabled: emailReady, + }, + }; +} + +function mockPasswordLogin(page: import("@playwright/test").Page, emailReady = true) { return Promise.all([ + page.route("**/api/auth/capabilities", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(envelope(authCapabilitiesPayload(emailReady))), + }); + }), page.route("**/api/auth/sso/providers", async (route) => { await route.fulfill({ status: 200, @@ -66,8 +88,15 @@ function mockPasswordLogin(page: import("@playwright/test").Page) { ]); } -async function mockGuestAuthChrome(page: import("@playwright/test").Page) { +async function mockGuestAuthChrome(page: import("@playwright/test").Page, emailReady = true) { await Promise.all([ + page.route("**/api/auth/capabilities", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(envelope(authCapabilitiesPayload(emailReady))), + }); + }), page.route("**/api/auth/sso/providers", async (route) => { await route.fulfill({ status: 200, @@ -397,10 +426,33 @@ test("邮箱验证码登录可发送验证码并进入工作台", async ({ page expect(verifyPayloads).toEqual([{ email: "employee@example.test", code: "654321" }]); }); +test("邮件服务不可用时登录和找回密码禁用验证码发送", async ({ page }) => { + await mockGuestAuthChrome(page, false); + + await page.goto("/login"); + await page.getByTestId("login-mode-email-code").click(); + await expect(page.getByTestId("login-email-delivery-status")).toContainText("邮件验证码暂不可用"); + await page.getByTestId("login-email-code-email").fill("employee@example.test"); + await expect(page.getByTestId("login-email-code-request")).toBeDisabled(); + + await page.goto("/forgot-password"); + await expect(page.getByTestId("forgot-password-email-delivery-status")).toContainText("邮件验证码暂不可用"); + await page.getByTestId("forgot-password-email").fill("employee@example.test"); + await expect(page.getByTestId("forgot-password-request-code")).toBeDisabled(); +}); + test("找回密码流程使用邮箱验证码重置新密码", async ({ page }) => { const requestedEmails: string[] = []; const resetPayloads: unknown[] = []; + await page.route("**/api/auth/capabilities", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(envelope(authCapabilitiesPayload())), + }); + }); + await page.route("**/api/auth/forgot-password", async (route) => { const payload = route.request().postDataJSON() as { email?: string }; requestedEmails.push(String(payload.email ?? "")); @@ -473,10 +525,10 @@ test("Portal 员工视角优先显示今日动作顺序", async ({ page }) => { await expect(page.locator("summary").filter({ hasText: "我的申请与资产 / 继续工作" })).toBeVisible(); await expect(page.getByRole("tab", { name: "公告" })).toBeVisible(); - await expect(page.getByRole("heading", { name: "快捷操作栏" })).toBeVisible(); - await expect(page.getByRole("heading", { name: "团队全员状态" })).toBeVisible(); - await expect(page.getByRole("heading", { name: "今日待办聚合" })).toBeVisible(); - await expect(page.getByRole("heading", { name: "近期动态流" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "常用" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "团队状态" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "待处理" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "通知" })).toBeVisible(); }); test("Portal 管理视角重排正确,备用模块默认折叠", async ({ page }) => { @@ -501,7 +553,7 @@ test("Portal 管理视角重排正确,备用模块默认折叠", async ({ page "portal-card-weekly_help", ]); - await expect(page.locator("summary").filter({ hasText: "角色重点 / 常用入口" })).toBeVisible(); + await expect(page.locator("summary").filter({ hasText: "角色重点" })).toBeVisible(); await expect(page.getByText("试点观测摘要")).toBeVisible(); await expect(page.getByRole("link", { name: "采购申请" })).not.toBeVisible(); await page.locator("summary").filter({ hasText: "备用模块" }).click(); @@ -594,7 +646,7 @@ test("Portal 域管理角色也进入经理视图", async ({ page }) => { await login(page, EMPLOYEE); - await expect(page.locator("summary").filter({ hasText: "角色重点 / 常用入口" })).toBeVisible(); + await expect(page.locator("summary").filter({ hasText: "角色重点" })).toBeVisible(); await expect(page.locator('[aria-label="portal-card-pending_approvals"]')).toBeVisible(); await expect(page.locator('[aria-label="portal-card-team_presence"]')).toBeVisible(); }); diff --git a/client/tests/e2e/wecom-entry.spec.ts b/client/tests/e2e/wecom-entry.spec.ts index 56203cce..cee9ddab 100644 --- a/client/tests/e2e/wecom-entry.spec.ts +++ b/client/tests/e2e/wecom-entry.spec.ts @@ -15,31 +15,52 @@ function mockWecomConfigPayload() { }; } -test("E2E 企微入口:普通浏览器下企微按钮为次级入口", async ({ page }) => { +function envelope(data: unknown) { + return { + requestId: "mock-request", + data, + }; +} + +async function mockAuthBase(page: import("@playwright/test").Page) { + await page.route("**/api/auth/capabilities", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(envelope({ + emailDelivery: { ready: true, statusReasons: [] }, + emailLoginCode: { enabled: true }, + passwordReset: { enabled: true }, + })), + }); + }); + await page.route("**/api/auth/sso/providers", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(envelope([])), + }); + }); +} + +async function mockWecomConfig(page: import("@playwright/test").Page) { await page.route("**/api/auth/wecom/config", async (route) => { await route.fulfill({ status: 200, contentType: "application/json", - body: JSON.stringify({ - requestId: "mock-wecom-config", - data: mockWecomConfigPayload(), - }), - }); - }); - await page.route("**/api/auth/wecom/login?**", async (route) => { - await route.fulfill({ - status: 200, - contentType: "text/plain", - body: "mock wecom login entry", + body: JSON.stringify(envelope(mockWecomConfigPayload())), }); }); +} + +test("E2E 企微入口:普通浏览器下只提示企业微信工作台打开", async ({ page }) => { + await mockAuthBase(page); + await mockWecomConfig(page); await page.goto("/login"); await expect(page.getByTestId("login-password-primary-panel")).toBeVisible(); - await expect(page.getByTestId("wecom-entry-secondary")).toBeVisible(); - - await page.getByTestId("wecom-entry-secondary").click(); - await expect(page).toHaveURL(/\/api\/auth\/wecom\/login\?returnTo=/); + await expect(page.getByTestId("wecom-entry-browser-notice")).toContainText("请从企业微信工作台打开 OA"); + await expect(page.getByTestId("wecom-entry-secondary")).toHaveCount(0); }); test("E2E 企微入口:企业微信场景优先显示企微 CTA,并展示未绑定提示", async ({ page }) => { @@ -49,16 +70,8 @@ test("E2E 企微入口:企业微信场景优先显示企微 CTA,并展示未 value: "Mozilla/5.0 wxwork/4.1.20 MicroMessenger/7.0.1", }); }); - await page.route("**/api/auth/wecom/config", async (route) => { - await route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ - requestId: "mock-wecom-config", - data: mockWecomConfigPayload(), - }), - }); - }); + await mockAuthBase(page); + await mockWecomConfig(page); await page.route("**/api/auth/wecom/login?**", async (route) => { await route.fulfill({ status: 200, diff --git a/scripts/qa/check-live-smoke-env.mjs b/scripts/qa/check-live-smoke-env.mjs index 2fbd0b05..5b34e55b 100755 --- a/scripts/qa/check-live-smoke-env.mjs +++ b/scripts/qa/check-live-smoke-env.mjs @@ -121,6 +121,7 @@ async function main() { const ready = await fetchJson(`${baseUrl}/ready`); const wecomConfig = await fetchJson(`${baseUrl}/api/auth/wecom/config`); + const authCapabilities = await fetchJson(`${baseUrl}/api/auth/capabilities`); const result = { checkedAt: new Date().toISOString(), ...checks, @@ -137,6 +138,14 @@ async function main() { defaultReturnTo: wecomConfig.json?.data?.defaultReturnTo ?? null, loginUrl: wecomConfig.json?.data?.loginUrl ?? null, }, + authCapabilities: { + ok: authCapabilities.ok, + status: authCapabilities.status, + emailDeliveryReady: authCapabilities.json?.data?.emailDelivery?.ready ?? null, + emailDeliveryReasons: authCapabilities.json?.data?.emailDelivery?.statusReasons ?? null, + emailLoginCodeEnabled: authCapabilities.json?.data?.emailLoginCode?.enabled ?? null, + passwordResetEnabled: authCapabilities.json?.data?.passwordReset?.enabled ?? null, + }, }; if (jsonOnly) { @@ -145,6 +154,7 @@ async function main() { console.log(`[live-smoke-env] base=${result.baseUrl}`); console.log(`[live-smoke-env] publicReady=${result.publicReady.ok} status=${result.publicReady.status}`); console.log(`[live-smoke-env] wecom=${result.wecomConfig.statusText ?? "unknown"} entry=${result.wecomConfig.entryEnabled} oauth=${result.wecomConfig.oauthEnabled}`); + console.log(`[live-smoke-env] authCapabilities=${result.authCapabilities.ok} emailDelivery=${result.authCapabilities.emailDeliveryReady}`); console.log(`[live-smoke-env] liveSmokeReady=${result.liveSmokeReady}`); console.log(`[live-smoke-env] liveOcrReady=${result.liveOcrReady}`); if (result.missing.length) { @@ -152,7 +162,7 @@ async function main() { } } - if (strict && (!result.liveSmokeReady || !result.liveOcrReady || !result.publicReady.ok || !result.wecomConfig.ok)) { + if (strict && (!result.liveSmokeReady || !result.liveOcrReady || !result.publicReady.ok || !result.wecomConfig.ok || !result.authCapabilities.ok)) { process.exit(1); } } diff --git a/server/src/routes/auth.js b/server/src/routes/auth.js index c7c3b944..b3ab2a03 100644 --- a/server/src/routes/auth.js +++ b/server/src/routes/auth.js @@ -8,7 +8,7 @@ const { validatePassword } = require("../utils/passwordPolicy"); const { signPayload, verifyToken } = require("../utils/signedUrl"); const { sendData } = require("../utils/reply"); const { createNotifications } = require("../services/notifications"); -const { sendNotificationEmailSafe } = require("../services/email"); +const { resolveEmailDeliveryStatus, sendNotificationEmailSafe } = require("../services/email"); const { generators, getOidcClient } = require("../services/oidc"); const PENDING_WECOM_BIND_COOKIE_NAME = "wecom_login_bind"; @@ -245,6 +245,53 @@ function emailLoginCodeError() { }); } +function emailDeliveryUnavailableError() { + return new AppError({ + code: "SYS_001", + message: "邮件服务暂未启用", + statusCode: 503, + details: { email: "delivery_not_configured" }, + }); +} + +function assertEmailDeliveryReady(config) { + const status = resolveEmailDeliveryStatus(config); + if (!status.ready) { + throw emailDeliveryUnavailableError(); + } + return status; +} + +function getPart(parts, type) { + return parts.find((part) => part.type === type)?.value ?? ""; +} + +function formatChinaDateTime(date) { + const parts = new Intl.DateTimeFormat("zh-CN", { + timeZone: "Asia/Shanghai", + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hourCycle: "h23", + }).formatToParts(date); + + return `${getPart(parts, "year")}年${getPart(parts, "month")}月${getPart(parts, "day")}日 ${getPart(parts, "hour")}:${getPart(parts, "minute")}(北京时间)`; +} + +function buildAuthVerificationEmail({ code, expiresAt, purpose }) { + const subject = + purpose === "password_reset" + ? "【海音达OA】密码重置验证码" + : "【海音达OA】登录验证码"; + + return { + subject, + body: `验证码:${code}\n有效期至:${formatChinaDateTime(expiresAt)}\n如非本人操作,请忽略并联系管理员。`, + }; +} + async function createRefreshSession(tx, { employeeId, refreshTokenHash, deviceFingerprint, request, now, expiresAt }) { const activeSessions = await tx.authSession.findMany({ where: { @@ -303,6 +350,22 @@ async function authRoutes(fastify) { : undefined; const accessTokenExpiresIn = parseAccessTokenTtlSeconds(fastify.config.accessTokenTtl); + fastify.get("/capabilities", async (request, reply) => { + const emailDelivery = resolveEmailDeliveryStatus(fastify.config); + return sendData(reply, request, { + emailDelivery: { + ready: emailDelivery.ready, + statusReasons: emailDelivery.reasons, + }, + emailLoginCode: { + enabled: emailDelivery.ready, + }, + passwordReset: { + enabled: emailDelivery.ready, + }, + }); + }); + fastify.post("/login", { preHandler: authRateLimit }, async (request, reply) => { const parsed = loginBodySchema.safeParse(request.body); if (!parsed.success) { @@ -574,6 +637,8 @@ async function authRoutes(fastify) { }); } + assertEmailDeliveryReady(fastify.config); + const { email } = parsed.data; const employee = await fastify.prisma.employee.findFirst({ where: { email }, @@ -588,6 +653,7 @@ async function authRoutes(fastify) { const codeHash = hashEmailLoginCode(employee.id, code, fastify.config.jwtSecret); const now = new Date(); const expiresAt = new Date(now.getTime() + fastify.config.emailLoginCodeTtlSeconds * 1000); + const emailMessage = buildAuthVerificationEmail({ code, expiresAt, purpose: "login" }); await fastify.prisma.emailLoginCode.create({ data: { @@ -616,8 +682,8 @@ async function authRoutes(fastify) { requestId: request.id, type: "auth.email_login_code_requested", recipientEmployeeIds: [employee.id], - subject: "【安全】OA 登录验证码", - body: `你的 OA 登录验证码为:${code}\n有效期至:${expiresAt.toISOString()}\n如非本人操作,请忽略并联系管理员核查账号安全。`, + subject: emailMessage.subject, + body: emailMessage.body, }); return sendData(reply, request, {}); @@ -1355,7 +1421,7 @@ async function authRoutes(fastify) { }); const forgotPasswordBodySchema = z.object({ - email: z.string().email(), + email: z.string().trim().email().transform((value) => value.toLowerCase()), }); fastify.post("/forgot-password", { preHandler: authRateLimit }, async (request, reply) => { @@ -1369,6 +1435,8 @@ async function authRoutes(fastify) { }); } + assertEmailDeliveryReady(fastify.config); + const { email } = parsed.data; const employee = await fastify.prisma.employee.findFirst({ where: { email }, @@ -1383,6 +1451,7 @@ async function authRoutes(fastify) { const codeHash = sha256Hex(code); const now = new Date(); const expiresAt = new Date(now.getTime() + fastify.config.passwordResetCodeTtlSeconds * 1000); + const emailMessage = buildAuthVerificationEmail({ code, expiresAt, purpose: "password_reset" }); await fastify.prisma.passwordResetCode.create({ data: { @@ -1427,8 +1496,8 @@ async function authRoutes(fastify) { requestId: request.id, type: "auth.password_reset_requested", recipientEmployeeIds: [employee.id], - subject: "【安全】密码重置验证码", - body: `你的密码重置验证码为:${code}\n有效期至:${expiresAt.toISOString()}\n如非本人操作,请忽略并联系管理员核查账号安全。`, + subject: emailMessage.subject, + body: emailMessage.body, }); if (fastify.config.devShowPasswordResetCode) { @@ -1442,7 +1511,7 @@ async function authRoutes(fastify) { }); const resetPasswordBodySchema = z.object({ - email: z.string().email(), + email: z.string().trim().email().transform((value) => value.toLowerCase()), code: z.string().length(6), newPassword: z.string().min(12), }); @@ -1577,3 +1646,7 @@ async function authRoutes(fastify) { } module.exports = authRoutes; +module.exports._test = { + buildAuthVerificationEmail, + formatChinaDateTime, +}; diff --git a/server/src/scripts/smoke-external-deps.js b/server/src/scripts/smoke-external-deps.js index ce95b3aa..954bb970 100644 --- a/server/src/scripts/smoke-external-deps.js +++ b/server/src/scripts/smoke-external-deps.js @@ -1,6 +1,7 @@ require('dotenv/config'); const { getConfig } = require('../config'); +const { resolveEmailDeliveryStatus } = require('../services/email'); function assertCondition(condition, message) { if (!condition) { @@ -42,17 +43,20 @@ function evaluateWecom(config) { } function evaluateEmail(config) { - if (!config.emailEnabled) { + const status = resolveEmailDeliveryStatus(config); + if (status.reasons.includes('email_disabled')) { return { state: 'disabled', detail: '企业邮箱 SMTP 未启用', checks: [] }; } - const missing = []; - if (!String(config.emailFrom ?? '').trim()) missing.push('EMAIL_FROM'); - if (!String(config.smtpHost ?? '').trim()) missing.push('SMTP_HOST'); - if (!Number.isFinite(Number(config.smtpPort)) || Number(config.smtpPort) <= 0) missing.push('SMTP_PORT'); - if (String(config.smtpUser ?? '').trim() && !String(config.smtpPass ?? '').trim()) missing.push('SMTP_PASS'); + const reasonLabels = { + email_from_missing: 'EMAIL_FROM', + smtp_host_missing: 'SMTP_HOST', + smtp_port_invalid: 'SMTP_PORT', + smtp_auth_incomplete: 'SMTP_USER/SMTP_PASS', + }; + const missing = status.reasons.map((reason) => reasonLabels[reason] ?? reason); - assertCondition(missing.length === 0, `SMTP 配置不完整:${formatList(missing)}`); + assertCondition(status.ready, `SMTP 配置不完整:${formatList(missing)}`); return { state: 'ready', diff --git a/server/src/services/email/index.js b/server/src/services/email/index.js index e9141121..dc21fe4f 100644 --- a/server/src/services/email/index.js +++ b/server/src/services/email/index.js @@ -51,6 +51,37 @@ function getTransporter(config) { return cachedTransporter; } +function resolveEmailDeliveryStatus(config) { + const reasons = []; + const authUser = String(config.smtpUser ?? '').trim(); + const authPass = String(config.smtpPass ?? '').trim(); + const smtpPort = Number(config.smtpPort); + + if (!config.emailEnabled) { + reasons.push('email_disabled'); + } + if (!String(config.emailFrom ?? '').trim()) { + reasons.push('email_from_missing'); + } + if (!String(config.smtpHost ?? '').trim()) { + reasons.push('smtp_host_missing'); + } + if (!Number.isFinite(smtpPort) || smtpPort <= 0) { + reasons.push('smtp_port_invalid'); + } + if (authUser && !authPass) { + reasons.push('smtp_auth_incomplete'); + } + if (!authUser && authPass) { + reasons.push('smtp_auth_incomplete'); + } + + return { + ready: reasons.length === 0, + reasons, + }; +} + function uniq(values) { return Array.from(new Set((values ?? []).filter(Boolean))); } @@ -168,6 +199,7 @@ async function sendNotificationEmail({ } module.exports = { + resolveEmailDeliveryStatus, sendNotificationEmailSafe, sendNotificationEmail, }; diff --git a/server/tests/unit/routes/auth.login.test.js b/server/tests/unit/routes/auth.login.test.js index 1d8a9e82..4246668f 100644 --- a/server/tests/unit/routes/auth.login.test.js +++ b/server/tests/unit/routes/auth.login.test.js @@ -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: "no-reply@hyinda.test", + 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: "employee@hyinda.test" }, + }); + + 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: " employee@hyinda.test " }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json().data).toEqual({}); + expect(passwordResetCodeCreate).toHaveBeenCalledWith({ + data: expect.objectContaining({ + employee_id: EMPLOYEE_ID, + code_hash: 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: "employee@hyinda.test" }, + }); + + 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(); + }); }); diff --git a/server/tests/unit/services/email.test.js b/server/tests/unit/services/email.test.js new file mode 100644 index 00000000..2d1b847d --- /dev/null +++ b/server/tests/unit/services/email.test.js @@ -0,0 +1,37 @@ +const { resolveEmailDeliveryStatus } = require("../../../src/services/email"); + +describe("email delivery status", () => { + it("reports ready only when SMTP delivery has the required public settings", () => { + expect( + resolveEmailDeliveryStatus({ + emailEnabled: true, + emailFrom: "no-reply@hyinda.test", + smtpHost: "smtp.hyinda.test", + smtpPort: 587, + smtpSecure: false, + smtpUser: "", + smtpPass: "", + }), + ).toEqual({ + ready: true, + reasons: [], + }); + }); + + it("reports non-secret missing SMTP reasons", () => { + expect( + resolveEmailDeliveryStatus({ + emailEnabled: true, + emailFrom: "", + smtpHost: "", + smtpPort: 0, + smtpSecure: false, + smtpUser: "sender", + smtpPass: "", + }), + ).toEqual({ + ready: false, + reasons: ["email_from_missing", "smtp_host_missing", "smtp_port_invalid", "smtp_auth_incomplete"], + }); + }); +});