文件
wangsli 1f8d5b658a 完善借款、编号、付款与预算并发及验收(09-06-release-gate-integrity)
汇总已落盘的借款本金与还款、兼容历史编号的周期化编号、付款责任与回单核验、报销进度和排序、PWA/采购事务、错误原因与日期校验及ERP退役。
统一共享预算池锁序和锁后判断,恢复差旅原预算选择,补原因码固定夹具及反向完整性检查。
纳入配套迁移、测试、受管QA、分层验收合同、规约及任务记录;原始证据留在ignored artifacts。

关联任务:09-17-company-loan-principal-repayment、09-17-erp-exclusive-retirement、09-18-reimbursement-periodic-numbering、09-08-reimbursement-payment-closeout、09-18-local-concurrency-product-fixes、09-19-budget-pool-concurrency、09-05-error-explainability-payment-readiness。
预算补审由Claude定向验证2文件928项通过,原4446/28及r6结果仍仅对应各自历史输入;本轮未重跑测试。
完整新候选门禁、R1真实入口接入与12项公网义务仍开放。生产编号未启用、差旅仍冻结;不推送、不部署,不宣称发布资格。
2026-09-19 13:11:04 +08:00

1218 行
44 KiB
JavaScript

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 { clearWecomAppAccessTokenCache } = require("../../../src/services/integrations/wecomAccessToken");
const { clearWecomJsApiTicketCache } = require("../../../src/services/integrations/wecomJsSdk");
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(), fastifyOptions = {} } = {}) {
const app = Fastify(fastifyOptions);
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,
wecomPcWebLoginEnabled: false,
wecomOauthEnabled: true,
wecomOauthOnboardingEnabled: false,
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" }),
});
}
function mockWecomFetchWithPayload(payload) {
global.fetch = vi.fn()
.mockResolvedValueOnce({
ok: true,
json: async () => ({ errcode: 0, access_token: "access-token" }),
})
.mockResolvedValueOnce({
ok: true,
json: async () => payload,
});
}
afterEach(() => {
global.fetch = originalFetch;
clearWecomAppAccessTokenCache();
clearWecomJsApiTicketCache();
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: "/mobile",
loginUrl: null,
statusReasons: expect.arrayContaining(["entry_disabled"]),
}));
await app.close();
});
it("returns mobile-first login url when entry is ready", async () => {
const app = await buildApp();
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: true,
entryEnabled: true,
status: "ready",
defaultReturnTo: "/mobile",
loginUrl: "/api/auth/wecom/login?returnTo=%2Fmobile",
pcWebLoginEnabled: false,
pcLoginUrl: null,
}));
await app.close();
});
it("returns an independently gated PC Web login entry", async () => {
const app = await buildApp({ config: { wecomPcWebLoginEnabled: true } });
const response = await app.inject({ method: "GET", url: "/api/auth/wecom/config" });
expect(response.statusCode).toBe(200);
expect(response.json().data).toEqual(expect.objectContaining({
entryEnabled: true,
pcWebLoginEnabled: true,
pcLoginUrl: "/api/auth/wecom/login?returnTo=%2Fportal&entrySource=pc_qr",
}));
await app.close();
});
it("builds the hosted PC Web login URL and browser correlation cookie", async () => {
const app = await buildApp({ config: { wecomPcWebLoginEnabled: true } });
const response = await app.inject({
method: "GET",
url: "/api/auth/wecom/login?entrySource=pc_qr&returnTo=%2Freimbursements&deviceFingerprint=pc-device-1",
});
expect(response.statusCode).toBe(302);
const location = new URL(response.headers.location);
expect(location.origin).toBe("https://login.work.weixin.qq.com");
expect(location.pathname).toBe("/wwlogin/sso/login");
expect(location.searchParams.get("login_type")).toBe("CorpApp");
expect(location.searchParams.get("appid")).toBe("wx-test-corp");
expect(location.searchParams.get("agentid")).toBe("1000001");
expect(location.searchParams.get("redirect_uri")).toBe("https://oa.hyinda.com/api/auth/wecom/callback");
const state = verifyToken(location.searchParams.get("state"), JWT_SECRET);
expect(state).toEqual(expect.objectContaining({
ok: true,
payload: expect.objectContaining({
returnTo: "/reimbursements",
mode: "login",
entrySource: "pc_qr",
deviceFingerprint: "pc-device-1",
}),
}));
const setCookie = String(response.headers["set-cookie"] ?? "");
expect(setCookie).toContain("wecom_pc_login_state=");
expect(setCookie).toContain("Path=/api/auth/wecom");
expect(setCookie).toContain("HttpOnly");
expect(setCookie).toContain("Secure");
expect(setCookie).toContain("SameSite=Lax");
await app.close();
});
it("rejects PC entry for bind/onboard and while its independent switch is off", async () => {
const disabledApp = await buildApp();
const disabled = await disabledApp.inject({
method: "GET",
url: "/api/auth/wecom/login?entrySource=pc_qr&returnTo=%2Fportal",
});
expect(disabled.statusCode).toBe(404);
await disabledApp.close();
const app = await buildApp({ config: { wecomPcWebLoginEnabled: true } });
const unsupported = await app.inject({
method: "GET",
url: "/api/auth/wecom/login?entrySource=pc_qr&mode=onboard&returnTo=%2Fportal",
});
expect(unsupported.statusCode).toBe(400);
expect(unsupported.json().error.details).toEqual({ entrySource: "unsupported_for_mode", reasonCode: "wecom.pc_mode_unsupported" });
await app.close();
});
it("keeps onboarding disabled by default", async () => {
const app = await buildApp();
const configResponse = await app.inject({ method: "GET", url: "/api/auth/wecom/config" });
const loginResponse = await app.inject({
method: "GET",
url: "/api/auth/wecom/login?mode=onboard&returnTo=%2Fmobile",
});
expect(configResponse.statusCode).toBe(200);
expect(configResponse.json().data).toEqual(expect.objectContaining({
onboardingEnabled: false,
onboardingUrl: null,
}));
expect(loginResponse.statusCode).toBe(404);
await app.close();
});
it("uses private-info scope only for the explicit onboarding entry", async () => {
const app = await buildApp({ config: { wecomOauthOnboardingEnabled: true } });
const configResponse = await app.inject({ method: "GET", url: "/api/auth/wecom/config" });
const onboardingResponse = await app.inject({
method: "GET",
url: "/api/auth/wecom/login?mode=onboard&returnTo=%2Fmobile",
});
const loginResponse = await app.inject({
method: "GET",
url: "/api/auth/wecom/login?returnTo=%2Fmobile",
});
expect(configResponse.json().data).toEqual(expect.objectContaining({
onboardingEnabled: true,
onboardingUrl: "/api/auth/wecom/login?returnTo=%2Fmobile&mode=onboard",
}));
expect(new URL(onboardingResponse.headers.location).searchParams.get("scope")).toBe("snsapi_privateinfo");
expect(new URL(loginResponse.headers.location).searchParams.get("scope")).toBe("snsapi_base");
await app.close();
});
it("does not provision when private-info consent returns no user ticket", async () => {
mockWecomFetch({ userId: "wecom-onboard-no-ticket" });
const app = await buildApp({ config: { wecomOauthOnboardingEnabled: true } });
const state = signPayload({
exp: Date.now() + 60_000,
nonce: "nonce-onboard-no-ticket",
returnTo: "/mobile",
mode: "onboard",
}, 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=%2Fmobile&wecom=consent_required",
);
await app.close();
});
it("does not return a raw provider payload when code exchange fails", async () => {
const privateValue = "private-provider-value-must-not-leak";
global.fetch = vi.fn()
.mockResolvedValueOnce({
ok: true,
json: async () => ({ errcode: 0, access_token: "access-token" }),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({ errcode: 40029, errmsg: "invalid code", privateValue }),
});
const app = await buildApp({ config: { wecomOauthOnboardingEnabled: true } });
const state = signPayload({
exp: Date.now() + 60_000,
nonce: "nonce-onboard-provider-failure",
returnTo: "/mobile",
mode: "onboard",
}, JWT_SECRET);
const response = await app.inject({
method: "GET",
url: `/api/auth/wecom/callback?state=${encodeURIComponent(state)}&code=test-code`,
});
expect(response.statusCode).toBe(401);
expect(response.json().error.details).toEqual({ wecom: "userinfo_exchange_failed" });
expect(response.body).not.toContain(privateValue);
await app.close();
});
it("does not expose credentials from provider network errors", async () => {
const privateValue = "network-error-secret-must-not-leak";
global.fetch = vi.fn().mockRejectedValue(new Error(`request failed ${privateValue}`));
const app = await buildApp({
config: {
wecomOauthOnboardingEnabled: true,
wecomAgentSecret: privateValue,
},
});
const state = signPayload({
exp: Date.now() + 60_000,
nonce: "nonce-onboard-network-failure",
returnTo: "/mobile",
mode: "onboard",
}, JWT_SECRET);
const response = await app.inject({
method: "GET",
url: `/api/auth/wecom/callback?state=${encodeURIComponent(state)}&code=test-code`,
});
expect(response.statusCode).toBe(401);
expect(response.json().error.details).toEqual({ wecom: "token_exchange_failed" });
expect(response.body).not.toContain(privateValue);
await app.close();
});
it("self-provisions an employee-only account after explicit private-info consent", async () => {
global.fetch = vi.fn()
.mockResolvedValueOnce({
ok: true,
json: async () => ({ errcode: 0, access_token: "access-token" }),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({
errcode: 0,
userid: "wecom-onboard-user",
user_ticket: "private-ticket",
}),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({
errcode: 0,
userid: "wecom-onboard-user",
mobile: "19900000001",
email: "[email protected]",
}),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({
errcode: 0,
userid: "wecom-onboard-user",
name: "测试成员",
status: 1,
department: [2],
position: "工程师",
}),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({ errcode: 0, department: [{ id: 2, name: "研发部" }] }),
});
const prisma = buildPrisma({
wecomIdentityBinding: {
findUnique: vi.fn().mockResolvedValue(null),
upsert: vi.fn().mockResolvedValue({ id: "binding-1" }),
},
employee: {
findMany: vi.fn().mockResolvedValue([]),
create: vi.fn().mockResolvedValue({ id: EMPLOYEE_ID, token_version: 0 }),
},
integrationBinding: {
findFirst: vi.fn().mockResolvedValue(null),
create: vi.fn().mockResolvedValue({ id: "department-binding-1" }),
},
department: {
findMany: vi.fn().mockResolvedValue([{ id: "department-1" }]),
},
employeeRole: {
create: vi.fn().mockResolvedValue({ employee_id: EMPLOYEE_ID, role_code: "employee" }),
},
authSession: {
findMany: vi.fn().mockResolvedValue([]),
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
create: vi.fn().mockResolvedValue({ id: "session-1" }),
},
});
prisma.$transaction = vi.fn(async (callback) => callback(prisma));
const app = await buildApp({ config: { wecomOauthOnboardingEnabled: true }, prisma });
const state = signPayload({
exp: Date.now() + 60_000,
nonce: "nonce-onboard-success",
returnTo: "/mobile",
mode: "onboard",
deviceFingerprint: "onboard-device",
}, 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/mobile");
expect(prisma.employeeRole.create).toHaveBeenCalledWith({
data: expect.objectContaining({ role_code: "employee" }),
});
expect(prisma.wecomIdentityBinding.upsert).toHaveBeenCalled();
expect(prisma.authSession.create).toHaveBeenCalledWith({
data: expect.objectContaining({ employee_id: EMPLOYEE_ID, device_fingerprint: "onboard-device" }),
});
expect(JSON.stringify(prisma.auditLog.create.mock.calls)).not.toContain("private-ticket");
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("/mobile");
expect(verified.payload.mode).toBe("login");
expect(String(verified.payload.deviceFingerprint ?? "")).toMatch(/^wecom:/);
await app.close();
});
it("keeps frontend device fingerprint in wecom login state", async () => {
const app = await buildApp();
const response = await app.inject({
method: "GET",
url: "/api/auth/wecom/login?returnTo=%2Fportal&deviceFingerprint=browser-device-1",
});
expect(response.statusCode).toBe(302);
const state = new URL(response.headers.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");
expect(verified.payload.deviceFingerprint).toBe("browser-device-1");
await app.close();
});
it("fails closed without a password-binding cookie when the WeCom profile is unavailable", 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=account_mapping_required",
);
expect(String(response.headers["set-cookie"] ?? "")).not.toMatch(/wecom_login_bind=[^;]/);
await app.close();
});
it("accepts lowercase userid and fails closed when no directory profile is available", async () => {
mockWecomFetchWithPayload({
errcode: 0,
errmsg: "ok",
userid: "wecom-unbound-lowercase",
});
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-lowercase-userid",
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=account_mapping_required",
);
expect(String(response.headers["set-cookie"] ?? "")).not.toMatch(/wecom_login_bind=[^;]/);
expect(prisma.wecomIdentityBinding.findUnique).toHaveBeenCalledWith({
where: { external_user_id: "wecom-unbound-lowercase" },
select: { employee_id: true, employee: { select: { id: true, status: true, token_version: true } } },
});
await app.close();
});
it("auto-binds a unique active OA employee by enterprise email and logs in without a password", async () => {
global.fetch = vi.fn()
.mockResolvedValueOnce({
ok: true,
json: async () => ({ errcode: 0, access_token: "access-token" }),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({ errcode: 0, UserId: "wecom-auto-user", OpenId: "open-auto-user" }),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({
errcode: 0,
userid: "wecom-auto-user",
status: 1,
biz_mail: "[email protected]",
}),
});
const authSession = {
findMany: vi.fn().mockResolvedValue([]),
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
create: vi.fn().mockResolvedValue({ id: "session-1" }),
};
const prisma = buildPrisma({
authSession,
employee: {
findMany: vi.fn().mockResolvedValue([{
id: EMPLOYEE_ID,
employee_no: "E0001",
email: "[email protected]",
status: "active",
archived_at: null,
token_version: 0,
}]),
},
wecomIdentityBinding: {
findUnique: vi.fn()
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(null),
create: vi.fn().mockResolvedValue({ id: "binding-auto" }),
},
});
prisma.$transaction = vi.fn(async (callback) => callback(prisma));
const app = await buildApp({ prisma });
const state = signPayload({
exp: Date.now() + 60_000,
nonce: "nonce-auto-bind",
returnTo: "/mobile",
mode: "login",
deviceFingerprint: "auto-bind-device",
}, 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/mobile");
expect(prisma.wecomIdentityBinding.create).toHaveBeenCalledWith({
data: expect.objectContaining({
employee_id: EMPLOYEE_ID,
external_user_id: "wecom-auto-user",
}),
});
expect(prisma.auditLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({
action: "AUTH_WECOM_AUTO_BOUND",
extra: { matchBy: "email", emailMatched: true },
}),
});
expect(prisma.auditLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({
action: "AUTH_WECOM_LOGIN_SUCCESS",
extra: { bindingSource: "auto", entrySource: "wecom_workbench" },
}),
});
expect(authSession.create).toHaveBeenCalledWith({
data: expect.objectContaining({ employee_id: EMPLOYEE_ID, device_fingerprint: "auto-bind-device" }),
});
expect(String(response.headers["set-cookie"] ?? "")).not.toMatch(/wecom_login_bind=[^;]/);
await app.close();
});
it("sets session hint cookie after successful wecom login callback", async () => {
mockWecomFetch({ userId: "wecom-bound-user" });
const authSession = {
findMany: vi.fn().mockResolvedValue([]),
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
create: vi.fn().mockResolvedValue({ id: "session-1" }),
};
const prisma = buildPrisma({
authSession,
wecomIdentityBinding: {
findUnique: vi.fn().mockResolvedValue({
employee_id: EMPLOYEE_ID,
employee: {
id: EMPLOYEE_ID,
status: "active",
token_version: 0,
},
}),
},
});
prisma.$transaction = vi.fn(async (callback) => callback(prisma));
const app = await buildApp({ prisma });
const state = signPayload({
exp: Date.now() + 60_000,
nonce: "nonce-login-success",
returnTo: "/portal",
mode: "login",
deviceFingerprint: "browser-device-1",
}, 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/portal");
const setCookieHeader = String(response.headers["set-cookie"] ?? "");
expect(setCookieHeader).toContain("refresh_token=");
expect(setCookieHeader).toContain("has_session=1");
expect(setCookieHeader).toContain("hyb_oa_device_fingerprint=browser-device-1");
expect(authSession.create).toHaveBeenCalledWith({
data: expect.objectContaining({
employee_id: EMPLOYEE_ID,
device_fingerprint: "browser-device-1",
}),
});
expect(prisma.auditLog.create).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({
actor_id: EMPLOYEE_ID,
action: "AUTH_WECOM_LOGIN_SUCCESS",
target_type: "employee",
target_id: EMPLOYEE_ID,
}),
}));
await app.close();
});
it("fails closed before code exchange when the PC browser correlation cookie is missing", async () => {
global.fetch = vi.fn();
const app = await buildApp({ config: { wecomPcWebLoginEnabled: true } });
const state = signPayload({
exp: Date.now() + 60_000,
nonce: "pc-nonce-missing-cookie",
returnTo: "/portal",
mode: "login",
entrySource: "pc_qr",
deviceFingerprint: "pc-device-missing-cookie",
}, JWT_SECRET);
const response = await app.inject({
method: "GET",
url: `/api/auth/wecom/callback?state=${encodeURIComponent(state)}&code=test-code`,
});
expect(response.statusCode).toBe(401);
expect(response.json().error.code).toBe("AUTH_004");
expect(global.fetch).not.toHaveBeenCalled();
expect(String(response.headers["set-cookie"] ?? "")).toContain("wecom_pc_login_state=;");
await app.close();
});
it("fails closed before code exchange when the PC browser nonce does not match", async () => {
global.fetch = vi.fn();
const app = await buildApp({ config: { wecomPcWebLoginEnabled: true } });
const state = signPayload({
exp: Date.now() + 60_000,
nonce: "pc-nonce-state",
returnTo: "/portal",
mode: "login",
entrySource: "pc_qr",
}, JWT_SECRET);
const cookie = signPayload({
exp: Date.now() + 60_000,
nonce: "pc-nonce-cookie",
entrySource: "pc_qr",
}, JWT_SECRET);
const response = await app.inject({
method: "GET",
url: `/api/auth/wecom/callback?state=${encodeURIComponent(state)}&code=test-code`,
headers: { cookie: `wecom_pc_login_state=${cookie}` },
});
expect(response.statusCode).toBe(401);
expect(global.fetch).not.toHaveBeenCalled();
await app.close();
});
it("clears PC correlation state for expired state, missing code, or a disabled PC switch", async () => {
global.fetch = vi.fn();
const nonce = "pc-nonce-terminal-failures";
const cookie = signPayload({
exp: Date.now() + 60_000,
nonce,
entrySource: "pc_qr",
}, JWT_SECRET);
const cookieHeader = { cookie: `wecom_pc_login_state=${cookie}` };
const enabledApp = await buildApp({ config: { wecomPcWebLoginEnabled: true } });
const expiredState = signPayload({
exp: Date.now() - 1,
nonce,
returnTo: "/portal",
mode: "login",
entrySource: "pc_qr",
}, JWT_SECRET);
const expired = await enabledApp.inject({
method: "GET",
url: `/api/auth/wecom/callback?state=${encodeURIComponent(expiredState)}&code=test-code`,
headers: cookieHeader,
});
expect(expired.statusCode).toBe(401);
expect(String(expired.headers["set-cookie"] ?? "")).toContain("wecom_pc_login_state=;");
const validState = signPayload({
exp: Date.now() + 60_000,
nonce,
returnTo: "/portal",
mode: "login",
entrySource: "pc_qr",
}, JWT_SECRET);
const missingCode = await enabledApp.inject({
method: "GET",
url: `/api/auth/wecom/callback?state=${encodeURIComponent(validState)}`,
headers: cookieHeader,
});
expect(missingCode.statusCode).toBe(401);
expect(String(missingCode.headers["set-cookie"] ?? "")).toContain("wecom_pc_login_state=;");
await enabledApp.close();
const disabledApp = await buildApp();
const disabled = await disabledApp.inject({
method: "GET",
url: `/api/auth/wecom/callback?state=${encodeURIComponent(validState)}&code=test-code`,
headers: cookieHeader,
});
expect(disabled.statusCode).toBe(404);
expect(String(disabled.headers["set-cookie"] ?? "")).toContain("wecom_pc_login_state=;");
await disabledApp.close();
expect(global.fetch).not.toHaveBeenCalled();
});
it("reuses the mapped login flow for PC Web login and records a sanitized entry source", async () => {
global.fetch = vi.fn()
.mockResolvedValueOnce({
ok: true,
json: async () => ({ errcode: 0, access_token: "access-token" }),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({ errcode: 0, UserId: "wecom-pc-user", OpenId: "wecom-open-pc" }),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({ errcode: 0, access_token: "access-token-replay" }),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({ errcode: 40029, errmsg: "invalid code" }),
});
const authSession = {
findMany: vi.fn().mockResolvedValue([]),
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
create: vi.fn().mockResolvedValue({ id: "session-pc" }),
};
const prisma = buildPrisma({
authSession,
wecomIdentityBinding: {
findUnique: vi.fn().mockResolvedValue({
employee_id: EMPLOYEE_ID,
employee: {
id: EMPLOYEE_ID,
status: "active",
token_version: 0,
},
}),
},
});
prisma.$transaction = vi.fn(async (callback) => callback(prisma));
const app = await buildApp({ config: { wecomPcWebLoginEnabled: true }, prisma });
const nonce = "pc-nonce-success";
const state = signPayload({
exp: Date.now() + 60_000,
nonce,
returnTo: "/reimbursements",
mode: "login",
entrySource: "pc_qr",
deviceFingerprint: "pc-device-success",
}, JWT_SECRET);
const cookie = signPayload({
exp: Date.now() + 60_000,
nonce,
entrySource: "pc_qr",
}, JWT_SECRET);
const response = await app.inject({
method: "GET",
url: `/api/auth/wecom/callback?state=${encodeURIComponent(state)}&code=test-code`,
headers: { cookie: `wecom_pc_login_state=${cookie}` },
});
expect(response.statusCode).toBe(302);
expect(response.headers.location).toBe("https://oa.hyinda.com/reimbursements");
expect(String(response.headers["set-cookie"] ?? "")).toContain("wecom_pc_login_state=;");
expect(authSession.create).toHaveBeenCalledWith({
data: expect.objectContaining({
employee_id: EMPLOYEE_ID,
device_fingerprint: "pc-device-success",
}),
});
expect(prisma.auditLog.create).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({
action: "AUTH_WECOM_LOGIN_SUCCESS",
extra: { bindingSource: "existing", entrySource: "pc_qr" },
}),
}));
expect(JSON.stringify(prisma.auditLog.create.mock.calls)).not.toContain("test-code");
expect(JSON.stringify(prisma.auditLog.create.mock.calls)).not.toContain(state);
expect(JSON.stringify(prisma.auditLog.create.mock.calls)).not.toContain("wecom-pc-user");
const replay = await app.inject({
method: "GET",
url: `/api/auth/wecom/callback?state=${encodeURIComponent(state)}&code=test-code`,
headers: { cookie: `wecom_pc_login_state=${cookie}` },
});
expect(replay.statusCode).toBe(401);
expect(replay.json().error).toEqual(expect.objectContaining({
code: "AUTH_003",
details: { wecom: "userinfo_exchange_failed" },
}));
expect(authSession.create).toHaveBeenCalledTimes(1);
await app.close();
});
it("rejects a signed but unknown callback entry source before code exchange", async () => {
global.fetch = vi.fn();
const app = await buildApp({ config: { wecomPcWebLoginEnabled: true } });
const state = signPayload({
exp: Date.now() + 60_000,
nonce: "pc-nonce-unknown-source",
returnTo: "/portal",
mode: "login",
entrySource: "unknown_source",
}, JWT_SECRET);
const response = await app.inject({
method: "GET",
url: `/api/auth/wecom/callback?state=${encodeURIComponent(state)}&code=test-code`,
});
expect(response.statusCode).toBe(401);
expect(global.fetch).not.toHaveBeenCalled();
await app.close();
});
it("returns structured diagnostics when wecom callback has only open id", async () => {
const logs = [];
mockWecomFetchWithPayload({
errcode: 0,
errmsg: "ok",
openid: "lowercase-openid-value",
user_ticket: "ticket-value",
corpid: "corp-id-value",
});
const app = await buildApp({
fastifyOptions: {
logger: {
level: "warn",
stream: {
write(message) {
logs.push(JSON.parse(message));
},
},
},
},
});
const state = signPayload({
exp: Date.now() + 60_000,
nonce: "nonce-openid-only",
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(401);
expect(response.json().error).toEqual(expect.objectContaining({
code: "AUTH_003",
message: "企业微信认证失败",
details: expect.objectContaining({
wecom: "missing_user_id",
hasOpenId: true,
mode: "login",
}),
}));
expect(logs.some((entry) => (
entry.msg === "wecom callback missing internal user id"
&& entry.mode === "login"
&& entry.hasUserId === false
&& entry.hasOpenId === true
&& entry.hasDeviceId === false
&& entry.wecomErrCode === 0
&& entry.wecomErrMsg === "ok"
&& Array.isArray(entry.payloadKeys)
&& entry.payloadKeys.join(",") === "corpid,errcode,errmsg,openid,user_ticket"
&& entry.fieldPresence?.UserId === false
&& entry.fieldPresence?.userid === false
&& entry.fieldPresence?.OpenId === false
&& entry.fieldPresence?.openid === true
&& entry.fieldPresence?.external_userid === false
&& entry.fieldPresence?.user_ticket === true
&& entry.fieldPresence?.corpid === true
))).toBe(true);
expect(logs.some((entry) => (
entry.msg === "wecom callback missing internal user id"
&& typeof entry.requestId === "string"
))).toBe(true);
expect(response.json().error).toEqual(expect.objectContaining({
code: "AUTH_003",
message: "企业微信认证失败",
details: expect.objectContaining({
wecom: "missing_user_id",
hasOpenId: true,
mode: "login",
}),
}));
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();
});
it("signs a same-origin page URL without exposing WeCom credentials", async () => {
global.fetch = vi.fn()
.mockResolvedValueOnce({
ok: true,
json: async () => ({ errcode: 0, access_token: "synthetic-access-token", expires_in: 7200 }),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({ errcode: 0, ticket: "synthetic-jsapi-ticket", expires_in: 7200 }),
});
const app = await buildApp();
const response = await app.inject({
method: "POST",
url: "/api/auth/wecom/js-sdk-signature",
headers: { authorization: "Bearer test-token", "content-type": "application/json" },
payload: { url: "https://oa.hyinda.com/mobile/reimbursements/test?source=wecom" },
});
expect(response.statusCode).toBe(200);
expect(response.json().data).toEqual({
corpId: "wx-test-corp",
timestamp: expect.any(Number),
nonceStr: expect.any(String),
signature: expect.stringMatching(/^[a-f0-9]{40}$/),
});
const serialized = JSON.stringify(response.json()).toLowerCase();
expect(serialized).not.toContain("synthetic-access-token");
expect(serialized).not.toContain("synthetic-jsapi-ticket");
expect(serialized).not.toContain("wecom-secret");
expect(global.fetch).toHaveBeenCalledTimes(2);
await app.close();
});
it("rejects cross-origin and credential-bearing JS-SDK signing URLs before provider access", async () => {
global.fetch = vi.fn();
const app = await buildApp();
const crossOrigin = await app.inject({
method: "POST",
url: "/api/auth/wecom/js-sdk-signature",
headers: { authorization: "Bearer test-token", "content-type": "application/json" },
payload: { url: "https://evil.example/mobile" },
});
const credentialUrl = await app.inject({
method: "POST",
url: "/api/auth/wecom/js-sdk-signature",
headers: { authorization: "Bearer test-token", "content-type": "application/json" },
payload: { url: "https://user:[email protected]/mobile" },
});
const fragmentUrl = await app.inject({
method: "POST",
url: "/api/auth/wecom/js-sdk-signature",
headers: { authorization: "Bearer test-token", "content-type": "application/json" },
payload: { url: "https://oa.hyinda.com/mobile#detail" },
});
expect(crossOrigin.statusCode).toBe(403);
expect(credentialUrl.statusCode).toBe(400);
expect(fragmentUrl.statusCode).toBe(400);
expect(global.fetch).not.toHaveBeenCalled();
await app.close();
});
it("returns a stable redacted error when the JSAPI ticket provider fails", async () => {
global.fetch = vi.fn()
.mockResolvedValueOnce({
ok: true,
json: async () => ({ errcode: 0, access_token: "synthetic-access-token", expires_in: 7200 }),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({ errcode: 40001, errmsg: "provider-secret-detail" }),
});
const app = await buildApp();
const response = await app.inject({
method: "POST",
url: "/api/auth/wecom/js-sdk-signature",
headers: { authorization: "Bearer test-token", "content-type": "application/json" },
payload: { url: "https://oa.hyinda.com/mobile" },
});
expect(response.statusCode).toBe(503);
expect(response.json().error.details).toEqual({ wecom: "jsapi_ticket_unavailable" });
expect(JSON.stringify(response.json())).not.toContain("provider-secret-detail");
expect(JSON.stringify(response.json())).not.toContain("synthetic-access-token");
await app.close();
});
it("fails closed without provider access when JS-SDK credentials are incomplete", async () => {
global.fetch = vi.fn();
const app = await buildApp({ config: { wecomAgentSecret: "" } });
const response = await app.inject({
method: "POST",
url: "/api/auth/wecom/js-sdk-signature",
headers: { authorization: "Bearer test-token", "content-type": "application/json" },
payload: { url: "https://oa.hyinda.com/mobile" },
});
expect(response.statusCode).toBe(503);
expect(response.json().error.details).toEqual({ wecom: "jsapi_ticket_unavailable" });
expect(global.fetch).not.toHaveBeenCalled();
await app.close();
});
});
describe("wecom validation reasons without provider access", () => {
it.each([
["POST", "/wecom/js-sdk-signature", {}, "wecom.signature_url_invalid"],
["POST", "/wecom/js-sdk-signature", { url: "not-a-url" }, "wecom.signature_url_invalid"],
["POST", "/wecom/js-sdk-signature", { url: "file:///synthetic" }, "wecom.signature_url_invalid"],
["POST", "/wecom/js-sdk-signature", { url: "https://oa.hyinda.com/path#fragment" }, "wecom.signature_url_invalid"],
["POST", "/wecom/js-sdk-signature", { url: "https://synthetic:[email protected]/path" }, "wecom.signature_url_invalid"],
["GET", "/wecom/login?mode=bad", undefined, "wecom.login_query_invalid"],
["GET", "/wecom/login?mode=bind&entrySource=pc_qr", undefined, "wecom.pc_mode_unsupported"],
["GET", "/wecom/login?mode=onboard&entrySource=pc_qr", undefined, "wecom.pc_mode_unsupported"],
["POST", "/wecom/bind", { returnTo: 1 }, "wecom.bind_input_invalid"],
])("explains %s %s before fetch/cookies/writes", async (method, path, payload, reason) => {
global.fetch = vi.fn().mockRejectedValue(new Error("unexpected_network")); const prisma = buildPrisma(); const app = await buildApp({ prisma });
try { const r = await app.inject({ method, url: `/api/auth${path}`, payload, headers: { authorization: "Bearer synthetic" } }); expect(r.statusCode).toBe(400); expect(r.json().error).toMatchObject({ code: "VALID_001", details: { reasonCode: reason } }); expect(r.headers["set-cookie"]).toBeUndefined(); expect(global.fetch).not.toHaveBeenCalled(); expect(prisma.wecomIdentityBinding.upsert).not.toHaveBeenCalled(); expect(prisma.auditLog.create).not.toHaveBeenCalled(); } finally { await app.close(); }
});
it("explains missing entry configuration without reflecting it or fetching", async () => { global.fetch = vi.fn(); const app = await buildApp({ config: { wecomCorpId: "" } }); try { const r = await app.inject({ url: "/api/auth/wecom/login" }); expect(r.statusCode).toBe(409); expect(r.json().error.details).toMatchObject({ wecom: "missing_required_config", reasonCode: "wecom.entry_config_incomplete" }); expect(r.headers["set-cookie"]).toBeUndefined(); expect(global.fetch).not.toHaveBeenCalled(); } finally { await app.close(); } });
});
it("explains missing synthetic auth credential only at callback token exchange", async () => {
global.fetch = vi.fn().mockRejectedValue(new Error("unexpected_network"));
const prisma = buildPrisma(); const app = await buildApp({ prisma, config: { wecomAgentSecret: "" } });
try {
expect((await app.inject({ url: "/api/auth/wecom/login" })).statusCode).toBe(302);
const state = signPayload({ exp: Date.now() + 60_000, nonce: "synthetic", returnTo: "/portal", mode: "login" }, JWT_SECRET);
const r = await app.inject({ url: `/api/auth/wecom/callback?state=${encodeURIComponent(state)}&code=synthetic` });
expect(r.statusCode).toBe(409); expect(r.json().error.details).toEqual({ wecom: "missing_agent_secret", reasonCode: "wecom.auth_config_incomplete" });
expect(global.fetch).not.toHaveBeenCalled(); expect(prisma.wecomIdentityBinding.findUnique).not.toHaveBeenCalled(); expect(prisma.wecomIdentityBinding.upsert).not.toHaveBeenCalled(); expect(prisma.auditLog.create).not.toHaveBeenCalled();
} finally { await app.close(); }
});