完善企微开通与部署端口契约(07-26-internal-ops-recovery-baseline)
这个提交包含在:
@@ -43,6 +43,7 @@ async function buildApp({ config = {}, prisma = buildPrisma(), fastifyOptions =
|
||||
wecomEnabled: true,
|
||||
wecomEntryEnabled: true,
|
||||
wecomOauthEnabled: true,
|
||||
wecomOauthOnboardingEnabled: false,
|
||||
wecomCorpId: "wx-test-corp",
|
||||
wecomAgentId: "1000001",
|
||||
wecomAgentSecret: "wecom-secret",
|
||||
@@ -142,6 +143,223 @@ describe("wecom auth routes R27", () => {
|
||||
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: "" } });
|
||||
|
||||
|
||||
在新工单中引用
屏蔽一个用户