文件
hyb-oa/server/tests/unit/routes/wecomAuth.test.js
T

686 行
22 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 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,
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;
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",
}));
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("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");
expect(String(response.headers["set-cookie"] ?? "")).toContain("wecom_login_bind=");
await app.close();
});
it("accepts lowercase userid and redirects unbound users back to login", 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=unbound");
expect(String(response.headers["set-cookie"] ?? "")).toContain("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("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("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();
});
});