336 行
10 KiB
JavaScript
336 行
10 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,
|
|
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: "/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("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: false,
|
|
mode: "login",
|
|
}),
|
|
}));
|
|
expect(logs.some((entry) => (
|
|
entry.msg === "wecom callback missing internal user id"
|
|
&& entry.mode === "login"
|
|
&& entry.hasUserId === false
|
|
&& entry.hasOpenId === false
|
|
&& 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: false,
|
|
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();
|
|
});
|
|
});
|