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 WecomEntryConfig = {
|
||||
enabled: boolean;
|
||||
entryEnabled: boolean;
|
||||
oauthEnabled: boolean;
|
||||
corpId: string;
|
||||
agentId: string;
|
||||
oauthEnabled: boolean;
|
||||
loginUrl: string | null;
|
||||
bindEnabled: boolean;
|
||||
defaultReturnTo: "/portal";
|
||||
status: "ready" | "disabled" | "misconfigured";
|
||||
statusReasons: string[];
|
||||
};
|
||||
|
||||
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 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({
|
||||
returnTo: z.string().optional(),
|
||||
mode: z.enum(["login", "bind"]).optional(),
|
||||
@@ -58,7 +60,6 @@ function parseAllowedReturnHosts(config, request) {
|
||||
try {
|
||||
hosts.add(new URL(item).host);
|
||||
} catch {
|
||||
// ignore invalid origin
|
||||
}
|
||||
}
|
||||
for (const item of String(config.wecomAllowedReturnHosts || "").split(",").map((v) => v.trim()).filter(Boolean)) {
|
||||
@@ -68,7 +69,6 @@ function parseAllowedReturnHosts(config, request) {
|
||||
try {
|
||||
hosts.add(new URL(resolvePrimaryAppOrigin(config, request)).host);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
return hosts;
|
||||
@@ -76,39 +76,102 @@ function parseAllowedReturnHosts(config, request) {
|
||||
|
||||
function sanitizeReturnTo(rawValue, config, request) {
|
||||
if (typeof rawValue !== "string") {
|
||||
return "/portal";
|
||||
return DEFAULT_RETURN_TO;
|
||||
}
|
||||
const trimmed = rawValue.trim();
|
||||
if (!trimmed) {
|
||||
return "/portal";
|
||||
return DEFAULT_RETURN_TO;
|
||||
}
|
||||
if (trimmed.startsWith("/")) {
|
||||
return trimmed.startsWith("//") ? "/portal" : trimmed;
|
||||
return trimmed.startsWith("//") ? DEFAULT_RETURN_TO : trimmed;
|
||||
}
|
||||
try {
|
||||
const url = new URL(trimmed);
|
||||
const allowedHosts = parseAllowedReturnHosts(config, request);
|
||||
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 {
|
||||
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) {
|
||||
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
|
||||
}
|
||||
if (!config.wecomCorpId || !config.wecomAgentId || !config.wecomRedirectUri) {
|
||||
if (!entryConfig.bindEnabled) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
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) {
|
||||
@@ -218,30 +281,25 @@ async function createAuthenticatedSession({ prisma, request, config, employeeId,
|
||||
|
||||
async function wecomAuthRoutes(fastify) {
|
||||
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, {
|
||||
enabled,
|
||||
corpId: enabled ? fastify.config.wecomCorpId : "",
|
||||
agentId: enabled ? fastify.config.wecomAgentId : "",
|
||||
oauthEnabled: enabled,
|
||||
loginUrl: enabled ? "/api/auth/wecom/login?returnTo=%2Fportal" : null,
|
||||
});
|
||||
return sendData(reply, request, resolveWecomEntryConfig(fastify.config));
|
||||
});
|
||||
|
||||
fastify.get("/wecom/login", async (request, reply) => {
|
||||
assertWecomEnabled(fastify.config);
|
||||
const parsed = QUERY_SCHEMA.safeParse(request.query ?? {});
|
||||
if (!parsed.success) {
|
||||
throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: parsed.error.flatten() });
|
||||
}
|
||||
const mode = parsed.data.mode ?? "login";
|
||||
if (mode === "bind") {
|
||||
assertWecomOauthReady(fastify.config);
|
||||
const bindCookie = request.cookies?.[BIND_COOKIE_NAME];
|
||||
const bindState = verifyToken(bindCookie, fastify.config.jwtSecret);
|
||||
if (!bindState.ok) {
|
||||
reply.clearCookie(BIND_COOKIE_NAME, bindCookieOptions(fastify.config));
|
||||
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 state = signPayload({
|
||||
@@ -254,7 +312,7 @@ async function wecomAuthRoutes(fastify) {
|
||||
});
|
||||
|
||||
fastify.post("/wecom/bind", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
assertWecomEnabled(fastify.config);
|
||||
assertWecomOauthReady(fastify.config);
|
||||
const parsed = BIND_BODY_SCHEMA.safeParse(request.body ?? {});
|
||||
if (!parsed.success) {
|
||||
throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: parsed.error.flatten() });
|
||||
@@ -267,7 +325,7 @@ async function wecomAuthRoutes(fastify) {
|
||||
}, fastify.config.jwtSecret);
|
||||
reply.setCookie(BIND_COOKIE_NAME, token, bindCookieOptions(fastify.config));
|
||||
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) => {
|
||||
assertWecomEnabled(fastify.config);
|
||||
assertWecomOauthReady(fastify.config);
|
||||
const stateToken = String(request.query?.state ?? "").trim();
|
||||
const code = String(request.query?.code ?? "").trim();
|
||||
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();
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户