完善企微开通与部署端口契约(07-26-internal-ops-recovery-baseline)

这个提交包含在:
2026-07-26 23:44:44 +08:00
父节点 9a624700d0
当前提交 130518f471
共修改 21 个文件,包含 1350 行新增和 55 行删除
+218
查看文件
@@ -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: "" } });
@@ -131,8 +131,11 @@ describe("wecom HR master sync helpers", () => {
return jsonResponse({ errcode: 0, access_token: "agent-token", expires_in: 7200 });
}
}
if (parsed.pathname === "/cgi-bin/department/list" && parsed.searchParams.get("access_token") === "contact-token") {
return jsonResponse({ errcode: 48009, errmsg: "department list not visible to contact app" });
if (parsed.pathname === "/cgi-bin/department/list" && parsed.searchParams.get("access_token") === "agent-token") {
return jsonResponse({
errcode: 0,
department: [{ id: 1, name: "研发部", parentid: 0, order: 1 }],
});
}
if (parsed.pathname === "/cgi-bin/user/list_id" && parsed.searchParams.get("access_token") === "contact-token") {
expect(init.method).toBe("POST");
@@ -146,8 +149,17 @@ describe("wecom HR master sync helpers", () => {
next_cursor: "",
});
}
if (parsed.pathname === "/cgi-bin/user/get" && parsed.searchParams.get("access_token") === "contact-token") {
return jsonResponse({ errcode: 48009, errmsg: "api forbidden for contact assistant" });
if (parsed.pathname === "/cgi-bin/user/get" && parsed.searchParams.get("access_token") === "agent-token") {
return jsonResponse({
errcode: 0,
userid: "oa_smoke002",
name: "测试员工",
mobile: "19912345678",
email: "[email protected]",
department: [1],
position: "测试工程师",
enable: 1,
});
}
if (parsed.pathname === "/cgi-bin/hr/get_staff_info" && parsed.searchParams.get("access_token") === "agent-token") {
expect(init.method).toBe("POST");
@@ -185,10 +197,12 @@ describe("wecom HR master sync helpers", () => {
expect(result.profiles[0].externalUserId).toBe("oa_smoke002");
expect(result.profiles[0].name).toBe("测试员工");
expect(result.profiles[0].missingRequiredFields).toEqual([]);
expect(calls.some((call) => call.path === "/cgi-bin/department/list" && call.token === "contact-token")).toBe(true);
expect(calls.some((call) => call.path === "/cgi-bin/department/list" && call.token === "agent-token")).toBe(true);
expect(calls.some((call) => call.path === "/cgi-bin/user/list_id" && call.token === "contact-token")).toBe(true);
expect(calls.some((call) => call.path === "/cgi-bin/user/get" && call.token === "contact-token")).toBe(true);
expect(calls.some((call) => call.path === "/cgi-bin/user/get" && call.token === "agent-token")).toBe(true);
expect(calls.some((call) => call.path === "/cgi-bin/hr/get_staff_info" && call.token === "agent-token")).toBe(true);
expect(calls.some((call) => call.path === "/cgi-bin/department/list" && call.token === "contact-token")).toBe(false);
expect(calls.some((call) => call.path === "/cgi-bin/user/get" && call.token === "contact-token")).toBe(false);
expect(calls.some((call) => call.secret === "deprecated-hr-secret-should-not-be-used")).toBe(false);
});
});
@@ -0,0 +1,345 @@
const {
WecomOAuthOnboardingError,
fetchWecomOAuthOnboardingProfile,
provisionWecomOAuthEmployee,
} = require("../../src/services/integrations/wecomOAuthOnboarding");
function jsonResponse(payload, status = 200) {
return {
ok: status >= 200 && status < 300,
status,
json: async () => payload,
};
}
function buildRequest() {
return {
id: "request-onboard-1",
ip: "127.0.0.1",
headers: { "user-agent": "vitest" },
};
}
describe("wecom OAuth onboarding service", () => {
it("combines member-authorized private fields with active contact and department data", async () => {
const calls = [];
const fetchImpl = vi.fn(async (url, init = {}) => {
const parsed = new URL(String(url));
calls.push({ path: parsed.pathname, body: init.body ? JSON.parse(init.body) : null });
if (parsed.pathname === "/cgi-bin/auth/getuserdetail") {
return jsonResponse({
errcode: 0,
userid: "member-1",
mobile: "19900000001",
email: "[email protected]",
});
}
if (parsed.pathname === "/cgi-bin/user/get") {
return jsonResponse({
errcode: 0,
userid: "member-1",
name: "测试成员",
status: 1,
department: [2],
position: "工程师",
});
}
if (parsed.pathname === "/cgi-bin/department/list") {
return jsonResponse({ errcode: 0, department: [{ id: 2, name: "研发部" }] });
}
return jsonResponse({ errcode: 40001 }, 400);
});
const profile = await fetchWecomOAuthOnboardingProfile({
accessToken: "agent-token",
externalUserId: "member-1",
userTicket: "private-ticket",
fetchImpl,
});
expect(profile).toEqual({
externalUserId: "member-1",
name: "测试成员",
phone: "19900000001",
email: "[email protected]",
departmentExternalId: "2",
departmentName: "研发部",
title: "工程师",
});
expect(calls.find((call) => call.path === "/cgi-bin/auth/getuserdetail")?.body).toEqual({
user_ticket: "private-ticket",
});
});
it("falls back to a valid business email and rejects malformed authorized fields", async () => {
const buildFetch = (privateDetail) => vi.fn(async (url) => {
const path = new URL(String(url)).pathname;
if (path === "/cgi-bin/auth/getuserdetail") return jsonResponse(privateDetail);
if (path === "/cgi-bin/user/get") {
return jsonResponse({
errcode: 0,
userid: "member-fields",
name: "测试成员",
status: 1,
department: [1],
});
}
return jsonResponse({ errcode: 0, department: [{ id: 1, name: "总部" }] });
});
const profile = await fetchWecomOAuthOnboardingProfile({
accessToken: "agent-token",
externalUserId: "member-fields",
userTicket: "ticket",
fetchImpl: buildFetch({
errcode: 0,
userid: "member-fields",
mobile: "19900000006",
email: " ",
biz_mail: "[email protected]",
}),
});
expect(profile.email).toBe("[email protected]");
await expect(fetchWecomOAuthOnboardingProfile({
accessToken: "agent-token",
externalUserId: "member-fields",
userTicket: "ticket",
fetchImpl: buildFetch({
errcode: 0,
userid: "member-fields",
mobile: "1",
email: "not-an-email",
}),
})).rejects.toMatchObject({ code: "missing_required_field" });
});
it("does not expose request credentials when the provider request fails", async () => {
const privateTicket = "private-ticket-must-not-leak";
let caught;
try {
await fetchWecomOAuthOnboardingProfile({
accessToken: "agent-token-must-not-leak",
externalUserId: "member-network",
userTicket: privateTicket,
fetchImpl: vi.fn().mockRejectedValue(new Error(`network failed ${privateTicket}`)),
});
} catch (error) {
caught = error;
}
expect(caught).toMatchObject({ code: "wecom_api_failed" });
expect(JSON.stringify(caught)).not.toContain(privateTicket);
expect(JSON.stringify(caught)).not.toContain("agent-token-must-not-leak");
});
it("rejects inactive members and identity mismatches", async () => {
const mismatchedFetch = vi.fn(async (url) => {
const path = new URL(String(url)).pathname;
if (path === "/cgi-bin/auth/getuserdetail") {
return jsonResponse({ errcode: 0, userid: "another-member", mobile: "19900000002", email: "[email protected]" });
}
if (path === "/cgi-bin/user/get") {
return jsonResponse({ errcode: 0, userid: "member-2", name: "成员", status: 1, department: [1] });
}
return jsonResponse({ errcode: 0, department: [{ id: 1, name: "总部" }] });
});
await expect(fetchWecomOAuthOnboardingProfile({
accessToken: "agent-token",
externalUserId: "member-2",
userTicket: "ticket",
fetchImpl: mismatchedFetch,
})).rejects.toMatchObject({ code: "identity_mismatch" });
const inactiveFetch = vi.fn(async (url) => {
const path = new URL(String(url)).pathname;
if (path === "/cgi-bin/auth/getuserdetail") {
return jsonResponse({ errcode: 0, userid: "member-2", mobile: "19900000002", email: "[email protected]" });
}
if (path === "/cgi-bin/user/get") {
return jsonResponse({ errcode: 0, userid: "member-2", name: "成员", status: 2, department: [1] });
}
return jsonResponse({ errcode: 0, department: [{ id: 1, name: "总部" }] });
});
await expect(fetchWecomOAuthOnboardingProfile({
accessToken: "agent-token",
externalUserId: "member-2",
userTicket: "ticket",
fetchImpl: inactiveFetch,
})).rejects.toMatchObject({ code: "member_inactive" });
});
it("self-provisions only the employee role and stores a dehydrated audit record", async () => {
const tx = {
wecomIdentityBinding: {
findUnique: vi.fn().mockResolvedValue(null),
upsert: vi.fn().mockResolvedValue({ id: "binding-1" }),
},
employee: {
findMany: vi.fn().mockResolvedValue([]),
create: vi.fn().mockResolvedValue({ id: "employee-1", token_version: 0 }),
update: vi.fn(),
},
integrationBinding: {
findFirst: vi.fn().mockResolvedValue(null),
create: vi.fn().mockResolvedValue({ id: "department-binding-1" }),
},
department: {
findMany: vi.fn().mockResolvedValue([{ id: "department-1" }]),
findUnique: vi.fn(),
create: vi.fn(),
},
employeeRole: {
create: vi.fn().mockResolvedValue({ employee_id: "employee-1", role_code: "employee" }),
},
auditLog: {
create: vi.fn().mockResolvedValue({ id: "audit-1" }),
},
};
const prisma = { $transaction: vi.fn(async (callback) => callback(tx)) };
const result = await provisionWecomOAuthEmployee({
prisma,
request: buildRequest(),
profile: {
externalUserId: "member-1",
name: "测试成员",
phone: "19900000001",
email: "[email protected]",
departmentExternalId: "2",
departmentName: "研发部",
title: "工程师",
},
});
expect(result).toEqual({ id: "employee-1", token_version: 0, created: true });
expect(tx.employeeRole.create).toHaveBeenCalledWith({
data: expect.objectContaining({ role_code: "employee" }),
});
expect(tx.auditLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({
actor_id: "employee-1",
action: "AUTH_WECOM_SELF_PROVISIONED",
extra: {
created: true,
source: "wecom_oauth",
fieldPresence: { phone: true, email: true },
},
}),
});
const persisted = JSON.stringify({
role: tx.employeeRole.create.mock.calls,
audit: tx.auditLog.create.mock.calls,
});
expect(persisted).not.toContain("private-ticket");
expect(persisted).not.toContain("19900000001");
expect(persisted).not.toContain("[email protected]");
});
it("rejects contact data that resolves to multiple OA employees", async () => {
const tx = {
wecomIdentityBinding: { findUnique: vi.fn().mockResolvedValue(null) },
employee: {
findMany: vi.fn().mockResolvedValue([
{ id: "employee-1", status: "active" },
{ id: "employee-2", status: "active" },
]),
},
};
const prisma = { $transaction: vi.fn(async (callback) => callback(tx)) };
await expect(provisionWecomOAuthEmployee({
prisma,
request: buildRequest(),
profile: {
externalUserId: "member-conflict",
name: "冲突成员",
phone: "19900000003",
email: "[email protected]",
departmentExternalId: "1",
departmentName: "总部",
title: "员工",
},
})).rejects.toBeInstanceOf(WecomOAuthOnboardingError);
});
it("does not rebind an OA employee that already belongs to another WeCom identity", async () => {
const tx = {
wecomIdentityBinding: { findUnique: vi.fn().mockResolvedValue(null) },
employee: {
findMany: vi.fn().mockResolvedValue([{
id: "employee-1",
status: "active",
wecom_identity_binding: { external_user_id: "another-member" },
}]),
},
};
const prisma = { $transaction: vi.fn(async (callback) => callback(tx)) };
await expect(provisionWecomOAuthEmployee({
prisma,
request: buildRequest(),
profile: {
externalUserId: "member-new",
name: "测试成员",
phone: "19900000004",
email: "[email protected]",
departmentExternalId: "1",
departmentName: "总部",
title: "员工",
},
})).rejects.toMatchObject({ code: "employee_conflict" });
});
it("completes an existing bound employee profile without changing roles", async () => {
const existing = {
id: "employee-1",
status: "active",
wecom_identity_binding: { external_user_id: "member-1" },
};
const tx = {
wecomIdentityBinding: {
findUnique: vi.fn().mockResolvedValue({ employee: existing }),
upsert: vi.fn().mockResolvedValue({ id: "binding-1" }),
},
employee: {
findMany: vi.fn().mockResolvedValue([existing]),
update: vi.fn().mockResolvedValue({ id: "employee-1", token_version: 3 }),
create: vi.fn(),
},
integrationBinding: {
findFirst: vi.fn().mockResolvedValue({ local_id: "department-1" }),
},
department: {
findUnique: vi.fn().mockResolvedValue({ id: "department-1", archived_at: null }),
},
employeeRole: { create: vi.fn() },
auditLog: { create: vi.fn().mockResolvedValue({ id: "audit-1" }) },
};
const prisma = { $transaction: vi.fn(async (callback) => callback(tx)) };
const result = await provisionWecomOAuthEmployee({
prisma,
request: buildRequest(),
profile: {
externalUserId: "member-1",
name: "测试成员",
phone: "19900000001",
email: "[email protected]",
departmentExternalId: "2",
departmentName: "研发部",
title: "工程师",
},
});
expect(result).toEqual({ id: "employee-1", token_version: 3, created: false });
expect(tx.employee.update).toHaveBeenCalled();
expect(tx.employee.create).not.toHaveBeenCalled();
expect(tx.employeeRole.create).not.toHaveBeenCalled();
expect(tx.auditLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({ action: "AUTH_WECOM_PROFILE_COMPLETED" }),
});
});
});