feat: R27-1 收口企业微信入口配置状态
这个提交包含在:
+6
-2
@@ -17,15 +17,19 @@ export async function changePassword(payload: { oldPassword: string; newPassword
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export type SsoProviderInfo = { id: "oidc"; label: string; enabled: boolean; loginUrl: string };
|
export type SsoProviderInfo = { id: "oidc"; label: string; enabled: boolean; loginUrl: string };
|
||||||
|
|
||||||
export type WecomEntryConfig = {
|
export type WecomEntryConfig = {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
entryEnabled: boolean;
|
||||||
|
oauthEnabled: boolean;
|
||||||
corpId: string;
|
corpId: string;
|
||||||
agentId: string;
|
agentId: string;
|
||||||
oauthEnabled: boolean;
|
|
||||||
loginUrl: string | null;
|
loginUrl: string | null;
|
||||||
|
bindEnabled: boolean;
|
||||||
|
defaultReturnTo: "/portal";
|
||||||
|
status: "ready" | "disabled" | "misconfigured";
|
||||||
|
statusReasons: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function fetchSsoProviders() {
|
export async function fetchSsoProviders() {
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes
|
||||||
|
R27-0,执行合同建档与范围冻结,为企业微信正式接入与首批交互优化建立执行合同并冻结本轮范围,Issue CSV 校验通过 且范围锁定为企微入口 登录绑定 Portal 导航与测试文档,python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-03-22_19-06-31-r27-wecom-entry-and-ux-polish.csv,manual,DONE,DONE,DONE,issues/2026-03-22_19-06-31-r27-wecom-entry-and-ux-polish.csv,[],none
|
||||||
|
R27-1,企业微信接入配置与入口链路收口,扩展 wecom config 返回状态与原因 将 WECOM_ENTRY_ENABLED 纳入正式入口判断并保持现有 login bind callback 主链路不变,GET /api/auth/wecom/config 返回 decision complete 状态结构 且未绑定回退与白名单 returnTo 逻辑零回退,cd server && npm test,manual,DONE,DONE,DONE,server/src/routes/wecomAuth.js|client/src/api/auth.ts|server/tests/unit/routes/wecomAuth.test.js,"[""R27-0""]",不新增新路由 仅扩展 config 返回
|
||||||
|
R27-2,登录页与个人绑定体验优化,重构 LoginPage 与 ProfilePage 让企微入口 绑定状态 回退提示更产品化,普通浏览器与企微场景 CTA 分层明确 wecom query 提示可读 个人设置页可清晰展示接入与绑定状态,cd client && npm run typecheck && npm run build,manual,DONE,DONE,DONE,client/src/pages/LoginPage.tsx|client/src/pages/ProfilePage.tsx|client/src/api/auth.ts,"[""R27-1""]",不新增页面 继续复用现有登录页与个人设置页
|
||||||
|
R27-3,Portal 首屏信息架构重排,重排 Portal 首屏卡片与摘要层级 让员工与管理员优先看到今日动作与关键提醒,员工与管理员首屏主卡片顺序符合规划 观测摘要对普通员工弱化 公告降为第二阅读层,cd client && npm run typecheck && npm run build,manual,DONE,DONE,DONE,client/src/pages/PortalHomePage.tsx,"[""R27-1""]",仅做信息架构与文案层级优化 不改后端 portal payload 结构
|
||||||
|
R27-4,导航 Topbar 与备用模块弱化,优化 NavLinks Sidebar Topbar 的分区展示与企微入口提示 弱化备用模块存在感,主导航稳定为 9 项 管理入口单独分组 备用模块默认折叠 顶栏在企微 WebView 下显示入口提示,cd client && npm run typecheck && npm run build,manual,DONE,DONE,DONE,client/src/components/navigation/NavLinks.tsx|client/src/components/navigation/Sidebar.tsx|client/src/components/navigation/Topbar.tsx,"[""R27-3""]",不改后端导航接口 以前端分组与展示优化为主
|
||||||
|
R27-5,自动化测试与文档补强,补齐 wecom 后端单测 登录与 Portal 交互 E2E 并同步管理员与员工文档,新增或扩展测试覆盖 wecom config 登录提示 Portal 顺序与导航弱化 文档写清配置与使用方式,cd server && npm test && cd ../client && npm run test:e2e,manual,DOING,TODO,TODO,server/tests/unit/routes/wecomAuth.test.js|client/tests/e2e/wecom-entry.spec.ts|client/tests/e2e/login-portal-ux.spec.ts|docs/admin-handbook.md|docs/employee-quickstart.md,"[""R27-2"",""R27-3"",""R27-4""]",新增 wecom-entry/login-portal-ux 用例已通过;client 全量 test:e2e 被既有 assets-flow 与 confidential-boundary 失败阻塞
|
||||||
|
R27-6,试点验收与上线清单收口,完成本地验证 部署抽检 文档清单同步与 git 提交 形成可进入小范围企微试点的版本,关键单测 类型检查 构建 E2E 通过 且接入清单可供管理员逐项配置实施,cd server && npm test && cd ../client && npm run typecheck && npm run build && npm run test:e2e,manual,DOING,TODO,TODO,deploy/trialrun/checklist.md|deploy/trialrun/runbook.md|issues/2026-03-22_19-06-31-r27-wecom-entry-and-ux-polish.csv,"[""R27-5""]",本地 server 全量单测 250/250 通过;client typecheck/build 通过;全量 E2E 仍受既有失败阻塞
|
||||||
|
+81
-23
@@ -9,6 +9,8 @@ const { sendData } = require("../utils/reply");
|
|||||||
|
|
||||||
const BIND_COOKIE_NAME = "wecom_bind";
|
const BIND_COOKIE_NAME = "wecom_bind";
|
||||||
const DATE_WINDOW_MS = 10 * 60 * 1000;
|
const DATE_WINDOW_MS = 10 * 60 * 1000;
|
||||||
|
const DEFAULT_RETURN_TO = "/portal";
|
||||||
|
const DISABLED_STATUS_REASONS = new Set(["wecom_disabled", "entry_disabled", "oauth_disabled"]);
|
||||||
const QUERY_SCHEMA = z.object({
|
const QUERY_SCHEMA = z.object({
|
||||||
returnTo: z.string().optional(),
|
returnTo: z.string().optional(),
|
||||||
mode: z.enum(["login", "bind"]).optional(),
|
mode: z.enum(["login", "bind"]).optional(),
|
||||||
@@ -58,7 +60,6 @@ function parseAllowedReturnHosts(config, request) {
|
|||||||
try {
|
try {
|
||||||
hosts.add(new URL(item).host);
|
hosts.add(new URL(item).host);
|
||||||
} catch {
|
} catch {
|
||||||
// ignore invalid origin
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const item of String(config.wecomAllowedReturnHosts || "").split(",").map((v) => v.trim()).filter(Boolean)) {
|
for (const item of String(config.wecomAllowedReturnHosts || "").split(",").map((v) => v.trim()).filter(Boolean)) {
|
||||||
@@ -68,7 +69,6 @@ function parseAllowedReturnHosts(config, request) {
|
|||||||
try {
|
try {
|
||||||
hosts.add(new URL(resolvePrimaryAppOrigin(config, request)).host);
|
hosts.add(new URL(resolvePrimaryAppOrigin(config, request)).host);
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return hosts;
|
return hosts;
|
||||||
@@ -76,39 +76,102 @@ function parseAllowedReturnHosts(config, request) {
|
|||||||
|
|
||||||
function sanitizeReturnTo(rawValue, config, request) {
|
function sanitizeReturnTo(rawValue, config, request) {
|
||||||
if (typeof rawValue !== "string") {
|
if (typeof rawValue !== "string") {
|
||||||
return "/portal";
|
return DEFAULT_RETURN_TO;
|
||||||
}
|
}
|
||||||
const trimmed = rawValue.trim();
|
const trimmed = rawValue.trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
return "/portal";
|
return DEFAULT_RETURN_TO;
|
||||||
}
|
}
|
||||||
if (trimmed.startsWith("/")) {
|
if (trimmed.startsWith("/")) {
|
||||||
return trimmed.startsWith("//") ? "/portal" : trimmed;
|
return trimmed.startsWith("//") ? DEFAULT_RETURN_TO : trimmed;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const url = new URL(trimmed);
|
const url = new URL(trimmed);
|
||||||
const allowedHosts = parseAllowedReturnHosts(config, request);
|
const allowedHosts = parseAllowedReturnHosts(config, request);
|
||||||
if (!allowedHosts.has(url.host)) {
|
if (!allowedHosts.has(url.host)) {
|
||||||
return "/portal";
|
return DEFAULT_RETURN_TO;
|
||||||
}
|
}
|
||||||
return `${url.pathname || "/portal"}${url.search || ""}${url.hash || ""}`;
|
return `${url.pathname || DEFAULT_RETURN_TO}${url.search || ""}${url.hash || ""}`;
|
||||||
} catch {
|
} catch {
|
||||||
return "/portal";
|
return DEFAULT_RETURN_TO;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function assertWecomEnabled(config) {
|
function resolveWecomEntryConfig(config) {
|
||||||
|
const statusReasons = [];
|
||||||
|
if (!config.wecomEnabled) {
|
||||||
|
statusReasons.push("wecom_disabled");
|
||||||
|
}
|
||||||
|
if (!config.wecomEntryEnabled) {
|
||||||
|
statusReasons.push("entry_disabled");
|
||||||
|
}
|
||||||
|
if (!config.wecomOauthEnabled) {
|
||||||
|
statusReasons.push("oauth_disabled");
|
||||||
|
}
|
||||||
|
if (!config.wecomCorpId) {
|
||||||
|
statusReasons.push("corp_id_missing");
|
||||||
|
}
|
||||||
|
if (!config.wecomAgentId) {
|
||||||
|
statusReasons.push("agent_id_missing");
|
||||||
|
}
|
||||||
|
if (!config.wecomRedirectUri) {
|
||||||
|
statusReasons.push("redirect_uri_missing");
|
||||||
|
}
|
||||||
|
|
||||||
|
const bindEnabled = Boolean(
|
||||||
|
config.wecomEnabled
|
||||||
|
&& config.wecomOauthEnabled
|
||||||
|
&& config.wecomCorpId
|
||||||
|
&& config.wecomAgentId
|
||||||
|
&& config.wecomRedirectUri,
|
||||||
|
);
|
||||||
|
const entryEnabled = Boolean(bindEnabled && config.wecomEntryEnabled);
|
||||||
|
const hasDisabledReason = statusReasons.some((reason) => DISABLED_STATUS_REASONS.has(reason));
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled: entryEnabled,
|
||||||
|
entryEnabled,
|
||||||
|
oauthEnabled: Boolean(config.wecomOauthEnabled),
|
||||||
|
corpId: String(config.wecomCorpId ?? ""),
|
||||||
|
agentId: String(config.wecomAgentId ?? ""),
|
||||||
|
loginUrl: entryEnabled ? buildWecomLoginUrl(DEFAULT_RETURN_TO, "login") : null,
|
||||||
|
bindEnabled,
|
||||||
|
defaultReturnTo: DEFAULT_RETURN_TO,
|
||||||
|
status: entryEnabled ? "ready" : hasDisabledReason ? "disabled" : "misconfigured",
|
||||||
|
statusReasons,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertWecomOauthReady(config) {
|
||||||
|
const entryConfig = resolveWecomEntryConfig(config);
|
||||||
if (!config.wecomEnabled || !config.wecomOauthEnabled) {
|
if (!config.wecomEnabled || !config.wecomOauthEnabled) {
|
||||||
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
|
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
|
||||||
}
|
}
|
||||||
if (!config.wecomCorpId || !config.wecomAgentId || !config.wecomRedirectUri) {
|
if (!entryConfig.bindEnabled) {
|
||||||
throw new AppError({
|
throw new AppError({
|
||||||
code: "VALID_001",
|
code: "VALID_001",
|
||||||
message: "参数校验失败",
|
message: "参数校验失败",
|
||||||
statusCode: 409,
|
statusCode: 409,
|
||||||
details: { wecom: "missing_required_config" },
|
details: { wecom: "missing_required_config", statusReasons: entryConfig.statusReasons },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
return entryConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertWecomEntryReady(config) {
|
||||||
|
const entryConfig = assertWecomOauthReady(config);
|
||||||
|
if (!config.wecomEntryEnabled) {
|
||||||
|
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
|
||||||
|
}
|
||||||
|
return entryConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWecomLoginUrl(returnTo = DEFAULT_RETURN_TO, mode = "login") {
|
||||||
|
const params = new URLSearchParams({ returnTo });
|
||||||
|
if (mode === "bind") {
|
||||||
|
params.set("mode", "bind");
|
||||||
|
}
|
||||||
|
return `/api/auth/wecom/login?${params.toString()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getWecomAccessToken(config) {
|
async function getWecomAccessToken(config) {
|
||||||
@@ -218,30 +281,25 @@ async function createAuthenticatedSession({ prisma, request, config, employeeId,
|
|||||||
|
|
||||||
async function wecomAuthRoutes(fastify) {
|
async function wecomAuthRoutes(fastify) {
|
||||||
fastify.get("/wecom/config", async (request, reply) => {
|
fastify.get("/wecom/config", async (request, reply) => {
|
||||||
const enabled = Boolean(fastify.config.wecomEnabled && fastify.config.wecomOauthEnabled && fastify.config.wecomCorpId && fastify.config.wecomAgentId && fastify.config.wecomRedirectUri);
|
return sendData(reply, request, resolveWecomEntryConfig(fastify.config));
|
||||||
return sendData(reply, request, {
|
|
||||||
enabled,
|
|
||||||
corpId: enabled ? fastify.config.wecomCorpId : "",
|
|
||||||
agentId: enabled ? fastify.config.wecomAgentId : "",
|
|
||||||
oauthEnabled: enabled,
|
|
||||||
loginUrl: enabled ? "/api/auth/wecom/login?returnTo=%2Fportal" : null,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
fastify.get("/wecom/login", async (request, reply) => {
|
fastify.get("/wecom/login", async (request, reply) => {
|
||||||
assertWecomEnabled(fastify.config);
|
|
||||||
const parsed = QUERY_SCHEMA.safeParse(request.query ?? {});
|
const parsed = QUERY_SCHEMA.safeParse(request.query ?? {});
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: parsed.error.flatten() });
|
throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: parsed.error.flatten() });
|
||||||
}
|
}
|
||||||
const mode = parsed.data.mode ?? "login";
|
const mode = parsed.data.mode ?? "login";
|
||||||
if (mode === "bind") {
|
if (mode === "bind") {
|
||||||
|
assertWecomOauthReady(fastify.config);
|
||||||
const bindCookie = request.cookies?.[BIND_COOKIE_NAME];
|
const bindCookie = request.cookies?.[BIND_COOKIE_NAME];
|
||||||
const bindState = verifyToken(bindCookie, fastify.config.jwtSecret);
|
const bindState = verifyToken(bindCookie, fastify.config.jwtSecret);
|
||||||
if (!bindState.ok) {
|
if (!bindState.ok) {
|
||||||
reply.clearCookie(BIND_COOKIE_NAME, bindCookieOptions(fastify.config));
|
reply.clearCookie(BIND_COOKIE_NAME, bindCookieOptions(fastify.config));
|
||||||
throw new AppError({ code: "AUTH_004", message: "Access Token 已过期/无效", statusCode: 401 });
|
throw new AppError({ code: "AUTH_004", message: "Access Token 已过期/无效", statusCode: 401 });
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
assertWecomEntryReady(fastify.config);
|
||||||
}
|
}
|
||||||
const returnTo = sanitizeReturnTo(parsed.data.returnTo, fastify.config, request);
|
const returnTo = sanitizeReturnTo(parsed.data.returnTo, fastify.config, request);
|
||||||
const state = signPayload({
|
const state = signPayload({
|
||||||
@@ -254,7 +312,7 @@ async function wecomAuthRoutes(fastify) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
fastify.post("/wecom/bind", { preHandler: fastify.authenticate }, async (request, reply) => {
|
fastify.post("/wecom/bind", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||||
assertWecomEnabled(fastify.config);
|
assertWecomOauthReady(fastify.config);
|
||||||
const parsed = BIND_BODY_SCHEMA.safeParse(request.body ?? {});
|
const parsed = BIND_BODY_SCHEMA.safeParse(request.body ?? {});
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: parsed.error.flatten() });
|
throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: parsed.error.flatten() });
|
||||||
@@ -267,7 +325,7 @@ async function wecomAuthRoutes(fastify) {
|
|||||||
}, fastify.config.jwtSecret);
|
}, fastify.config.jwtSecret);
|
||||||
reply.setCookie(BIND_COOKIE_NAME, token, bindCookieOptions(fastify.config));
|
reply.setCookie(BIND_COOKIE_NAME, token, bindCookieOptions(fastify.config));
|
||||||
return sendData(reply, request, {
|
return sendData(reply, request, {
|
||||||
loginUrl: `/api/auth/wecom/login?mode=bind&returnTo=${encodeURIComponent(returnTo)}`,
|
loginUrl: buildWecomLoginUrl(returnTo, "bind"),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -290,7 +348,7 @@ async function wecomAuthRoutes(fastify) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
fastify.get("/wecom/callback", async (request, reply) => {
|
fastify.get("/wecom/callback", async (request, reply) => {
|
||||||
assertWecomEnabled(fastify.config);
|
assertWecomOauthReady(fastify.config);
|
||||||
const stateToken = String(request.query?.state ?? "").trim();
|
const stateToken = String(request.query?.state ?? "").trim();
|
||||||
const code = String(request.query?.code ?? "").trim();
|
const code = String(request.query?.code ?? "").trim();
|
||||||
const verified = verifyToken(stateToken, fastify.config.jwtSecret);
|
const verified = verifyToken(stateToken, fastify.config.jwtSecret);
|
||||||
|
|||||||
@@ -0,0 +1,243 @@
|
|||||||
|
const Fastify = require("fastify");
|
||||||
|
const fastifyCookie = require("@fastify/cookie");
|
||||||
|
|
||||||
|
const { AppError } = require("../../../src/utils/AppError");
|
||||||
|
const wecomAuthRoutes = require("../../../src/routes/wecomAuth");
|
||||||
|
const { signPayload, verifyToken } = require("../../../src/utils/signedUrl");
|
||||||
|
|
||||||
|
const NOW = new Date("2026-03-22T11:00:00.000Z");
|
||||||
|
const EMPLOYEE_ID = "11111111-1111-4111-8111-111111111111";
|
||||||
|
const JWT_SECRET = "test-wecom-secret";
|
||||||
|
const originalFetch = global.fetch;
|
||||||
|
|
||||||
|
function buildPrisma(overrides = {}) {
|
||||||
|
return {
|
||||||
|
wecomIdentityBinding: {
|
||||||
|
deleteMany: vi.fn().mockResolvedValue({ count: 1 }),
|
||||||
|
findFirst: vi.fn().mockResolvedValue(null),
|
||||||
|
findUnique: vi.fn().mockResolvedValue(null),
|
||||||
|
upsert: vi.fn().mockResolvedValue({ id: "binding-1" }),
|
||||||
|
...(overrides.wecomIdentityBinding ?? {}),
|
||||||
|
},
|
||||||
|
employee: {
|
||||||
|
findUnique: vi.fn().mockResolvedValue({ id: EMPLOYEE_ID, status: "active" }),
|
||||||
|
...(overrides.employee ?? {}),
|
||||||
|
},
|
||||||
|
auditLog: {
|
||||||
|
create: vi.fn().mockResolvedValue({ id: "audit-1" }),
|
||||||
|
...(overrides.auditLog ?? {}),
|
||||||
|
},
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildApp({ config = {}, prisma = buildPrisma() } = {}) {
|
||||||
|
const app = Fastify();
|
||||||
|
await app.register(fastifyCookie);
|
||||||
|
app.decorate("config", {
|
||||||
|
appOrigin: "https://oa.hyinda.com",
|
||||||
|
jwtSecret: JWT_SECRET,
|
||||||
|
refreshCookieName: "refresh_token",
|
||||||
|
refreshTtlSeconds: 7 * 24 * 60 * 60,
|
||||||
|
cookieSecure: true,
|
||||||
|
wecomEnabled: true,
|
||||||
|
wecomEntryEnabled: true,
|
||||||
|
wecomOauthEnabled: true,
|
||||||
|
wecomCorpId: "wx-test-corp",
|
||||||
|
wecomAgentId: "1000001",
|
||||||
|
wecomAgentSecret: "wecom-secret",
|
||||||
|
wecomRedirectUri: "https://oa.hyinda.com/api/auth/wecom/callback",
|
||||||
|
wecomAllowedReturnHosts: "oa.hyinda.com",
|
||||||
|
...config,
|
||||||
|
});
|
||||||
|
app.decorate("prisma", prisma);
|
||||||
|
app.decorate("issueAccessToken", vi.fn().mockReturnValue("access-token"));
|
||||||
|
app.decorate("authenticate", async (request) => {
|
||||||
|
if (!request.headers.authorization) {
|
||||||
|
throw new AppError({ code: "AUTH_001", message: "未登录", statusCode: 401 });
|
||||||
|
}
|
||||||
|
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: "SYS_001", message: "服务器内部错误", details: {} },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.register(wecomAuthRoutes, { prefix: "/api/auth" });
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockWecomFetch({ userId = "wecom-user-1", openId = "wecom-open-1" } = {}) {
|
||||||
|
global.fetch = vi.fn()
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ errcode: 0, access_token: "access-token" }),
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ errcode: 0, UserId: userId, OpenId: openId, DeviceId: "device-1" }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
global.fetch = originalFetch;
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("wecom auth routes R27", () => {
|
||||||
|
it("returns entry-disabled config while keeping bind available", async () => {
|
||||||
|
const app = await buildApp({ config: { wecomEntryEnabled: false } });
|
||||||
|
|
||||||
|
const response = await app.inject({ method: "GET", url: "/api/auth/wecom/config" });
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json().data).toEqual(expect.objectContaining({
|
||||||
|
enabled: false,
|
||||||
|
entryEnabled: false,
|
||||||
|
bindEnabled: true,
|
||||||
|
oauthEnabled: true,
|
||||||
|
status: "disabled",
|
||||||
|
defaultReturnTo: "/portal",
|
||||||
|
loginUrl: null,
|
||||||
|
statusReasons: expect.arrayContaining(["entry_disabled"]),
|
||||||
|
}));
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns misconfigured status when corp id is missing", async () => {
|
||||||
|
const app = await buildApp({ config: { wecomCorpId: "" } });
|
||||||
|
|
||||||
|
const response = await app.inject({ method: "GET", url: "/api/auth/wecom/config" });
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json().data).toEqual(expect.objectContaining({
|
||||||
|
enabled: false,
|
||||||
|
bindEnabled: false,
|
||||||
|
status: "misconfigured",
|
||||||
|
statusReasons: expect.arrayContaining(["corp_id_missing"]),
|
||||||
|
corpId: "",
|
||||||
|
agentId: "1000001",
|
||||||
|
}));
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sanitizes login returnTo to allowed hosts or local paths", async () => {
|
||||||
|
const app = await buildApp();
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/api/auth/wecom/login?returnTo=https%3A%2F%2Fevil.example.com%2Fsteal",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(302);
|
||||||
|
const location = response.headers.location;
|
||||||
|
expect(location).toContain("open.weixin.qq.com/connect/oauth2/authorize");
|
||||||
|
const state = new URL(location).searchParams.get("state");
|
||||||
|
const verified = verifyToken(state, JWT_SECRET);
|
||||||
|
expect(verified.ok).toBe(true);
|
||||||
|
expect(verified.payload.returnTo).toBe("/portal");
|
||||||
|
expect(verified.payload.mode).toBe("login");
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redirects unbound users back to login with wecom hint", async () => {
|
||||||
|
mockWecomFetch({ userId: "wecom-unbound" });
|
||||||
|
const prisma = buildPrisma({
|
||||||
|
wecomIdentityBinding: {
|
||||||
|
findUnique: vi.fn().mockResolvedValue(null),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const app = await buildApp({ prisma });
|
||||||
|
const state = signPayload({
|
||||||
|
exp: Date.now() + 60_000,
|
||||||
|
nonce: "nonce-login",
|
||||||
|
returnTo: "/portal",
|
||||||
|
mode: "login",
|
||||||
|
}, JWT_SECRET);
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: `/api/auth/wecom/callback?state=${encodeURIComponent(state)}&code=test-code`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(302);
|
||||||
|
expect(response.headers.location).toBe("https://oa.hyinda.com/login?redirect=%2Fportal&wecom=unbound");
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates bind audit log on successful wecom bind callback", async () => {
|
||||||
|
mockWecomFetch({ userId: "wecom-bind-1" });
|
||||||
|
const prisma = buildPrisma();
|
||||||
|
const app = await buildApp({ prisma });
|
||||||
|
const state = signPayload({
|
||||||
|
exp: Date.now() + 60_000,
|
||||||
|
nonce: "nonce-bind",
|
||||||
|
returnTo: "/settings/profile",
|
||||||
|
mode: "bind",
|
||||||
|
}, JWT_SECRET);
|
||||||
|
const bindCookie = signPayload({
|
||||||
|
exp: Date.now() + 60_000,
|
||||||
|
employeeId: EMPLOYEE_ID,
|
||||||
|
returnTo: "/settings/profile",
|
||||||
|
}, JWT_SECRET);
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: `/api/auth/wecom/callback?state=${encodeURIComponent(state)}&code=test-code`,
|
||||||
|
headers: {
|
||||||
|
cookie: `wecom_bind=${bindCookie}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(302);
|
||||||
|
expect(response.headers.location).toBe("https://oa.hyinda.com/settings/profile?wecomBind=success");
|
||||||
|
expect(prisma.wecomIdentityBinding.upsert).toHaveBeenCalled();
|
||||||
|
expect(prisma.auditLog.create).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
actor_id: EMPLOYEE_ID,
|
||||||
|
action: "AUTH_WECOM_BIND",
|
||||||
|
target_type: "employee",
|
||||||
|
target_id: EMPLOYEE_ID,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes audit log when unbinding wecom identity", async () => {
|
||||||
|
const prisma = buildPrisma();
|
||||||
|
const app = await buildApp({ prisma });
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/auth/wecom/unbind",
|
||||||
|
headers: {
|
||||||
|
authorization: "Bearer test-token",
|
||||||
|
},
|
||||||
|
payload: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(prisma.wecomIdentityBinding.deleteMany).toHaveBeenCalledWith({ where: { employee_id: EMPLOYEE_ID } });
|
||||||
|
expect(prisma.auditLog.create).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
actor_id: EMPLOYEE_ID,
|
||||||
|
action: "AUTH_WECOM_UNBIND",
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
在新工单中引用
屏蔽一个用户