feat: R27-1 收口企业微信入口配置状态
这个提交包含在:
@@ -0,0 +1,243 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户