汇总已落盘的借款本金与还款、兼容历史编号的周期化编号、付款责任与回单核验、报销进度和排序、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项公网义务仍开放。生产编号未启用、差旅仍冻结;不推送、不部署,不宣称发布资格。
1218 行
44 KiB
JavaScript
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(); }
|
|
});
|