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

244 行
7.7 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() } = {}) {
const app = Fastify();
await app.register(fastifyCookie);
app.decorate("config", {
appOrigin: "https://oa.hyinda.com",
jwtSecret: JWT_SECRET,
refreshCookieName: "refresh_token",
refreshTtlSeconds: 7 * 24 * 60 * 60,
cookieSecure: true,
wecomEnabled: true,
wecomEntryEnabled: true,
wecomOauthEnabled: true,
wecomCorpId: "wx-test-corp",
wecomAgentId: "1000001",
wecomAgentSecret: "wecom-secret",
wecomRedirectUri: "https://oa.hyinda.com/api/auth/wecom/callback",
wecomAllowedReturnHosts: "oa.hyinda.com",
...config,
});
app.decorate("prisma", prisma);
app.decorate("issueAccessToken", vi.fn().mockReturnValue("access-token"));
app.decorate("authenticate", async (request) => {
if (!request.headers.authorization) {
throw new AppError({ code: "AUTH_001", message: "未登录", statusCode: 401 });
}
request.employee = { id: EMPLOYEE_ID };
});
app.setErrorHandler((error, request, reply) => {
if (error instanceof AppError) {
return reply.status(error.statusCode).send({
requestId: request.id,
error: { code: error.code, message: error.message, details: error.details ?? {} },
});
}
return reply.status(500).send({
requestId: request.id,
error: { code: "SYS_001", message: "服务器内部错误", details: {} },
});
});
await app.register(wecomAuthRoutes, { prefix: "/api/auth" });
return app;
}
function mockWecomFetch({ userId = "wecom-user-1", openId = "wecom-open-1" } = {}) {
global.fetch = vi.fn()
.mockResolvedValueOnce({
ok: true,
json: async () => ({ errcode: 0, access_token: "access-token" }),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({ errcode: 0, UserId: userId, OpenId: openId, DeviceId: "device-1" }),
});
}
afterEach(() => {
global.fetch = originalFetch;
vi.restoreAllMocks();
});
describe("wecom auth routes R27", () => {
it("returns entry-disabled config while keeping bind available", async () => {
const app = await buildApp({ config: { wecomEntryEnabled: false } });
const response = await app.inject({ method: "GET", url: "/api/auth/wecom/config" });
expect(response.statusCode).toBe(200);
expect(response.json().data).toEqual(expect.objectContaining({
enabled: false,
entryEnabled: false,
bindEnabled: true,
oauthEnabled: true,
status: "disabled",
defaultReturnTo: "/portal",
loginUrl: null,
statusReasons: expect.arrayContaining(["entry_disabled"]),
}));
await app.close();
});
it("returns misconfigured status when corp id is missing", async () => {
const app = await buildApp({ config: { wecomCorpId: "" } });
const response = await app.inject({ method: "GET", url: "/api/auth/wecom/config" });
expect(response.statusCode).toBe(200);
expect(response.json().data).toEqual(expect.objectContaining({
enabled: false,
bindEnabled: false,
status: "misconfigured",
statusReasons: expect.arrayContaining(["corp_id_missing"]),
corpId: "",
agentId: "1000001",
}));
await app.close();
});
it("sanitizes login returnTo to allowed hosts or local paths", async () => {
const app = await buildApp();
const response = await app.inject({
method: "GET",
url: "/api/auth/wecom/login?returnTo=https%3A%2F%2Fevil.example.com%2Fsteal",
});
expect(response.statusCode).toBe(302);
const location = response.headers.location;
expect(location).toContain("open.weixin.qq.com/connect/oauth2/authorize");
const state = new URL(location).searchParams.get("state");
const verified = verifyToken(state, JWT_SECRET);
expect(verified.ok).toBe(true);
expect(verified.payload.returnTo).toBe("/portal");
expect(verified.payload.mode).toBe("login");
await app.close();
});
it("redirects unbound users back to login with wecom hint", async () => {
mockWecomFetch({ userId: "wecom-unbound" });
const prisma = buildPrisma({
wecomIdentityBinding: {
findUnique: vi.fn().mockResolvedValue(null),
},
});
const app = await buildApp({ prisma });
const state = signPayload({
exp: Date.now() + 60_000,
nonce: "nonce-login",
returnTo: "/portal",
mode: "login",
}, JWT_SECRET);
const response = await app.inject({
method: "GET",
url: `/api/auth/wecom/callback?state=${encodeURIComponent(state)}&code=test-code`,
});
expect(response.statusCode).toBe(302);
expect(response.headers.location).toBe("https://oa.hyinda.com/login?redirect=%2Fportal&wecom=unbound");
await app.close();
});
it("creates bind audit log on successful wecom bind callback", async () => {
mockWecomFetch({ userId: "wecom-bind-1" });
const prisma = buildPrisma();
const app = await buildApp({ prisma });
const state = signPayload({
exp: Date.now() + 60_000,
nonce: "nonce-bind",
returnTo: "/settings/profile",
mode: "bind",
}, JWT_SECRET);
const bindCookie = signPayload({
exp: Date.now() + 60_000,
employeeId: EMPLOYEE_ID,
returnTo: "/settings/profile",
}, JWT_SECRET);
const response = await app.inject({
method: "GET",
url: `/api/auth/wecom/callback?state=${encodeURIComponent(state)}&code=test-code`,
headers: {
cookie: `wecom_bind=${bindCookie}`,
},
});
expect(response.statusCode).toBe(302);
expect(response.headers.location).toBe("https://oa.hyinda.com/settings/profile?wecomBind=success");
expect(prisma.wecomIdentityBinding.upsert).toHaveBeenCalled();
expect(prisma.auditLog.create).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({
actor_id: EMPLOYEE_ID,
action: "AUTH_WECOM_BIND",
target_type: "employee",
target_id: EMPLOYEE_ID,
}),
}));
await app.close();
});
it("writes audit log when unbinding wecom identity", async () => {
const prisma = buildPrisma();
const app = await buildApp({ prisma });
const response = await app.inject({
method: "POST",
url: "/api/auth/wecom/unbind",
headers: {
authorization: "Bearer test-token",
},
payload: {},
});
expect(response.statusCode).toBe(200);
expect(prisma.wecomIdentityBinding.deleteMany).toHaveBeenCalledWith({ where: { employee_id: EMPLOYEE_ID } });
expect(prisma.auditLog.create).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({
actor_id: EMPLOYEE_ID,
action: "AUTH_WECOM_UNBIND",
}),
}));
await app.close();
});
});