完善企微开通与部署端口契约(07-26-internal-ops-recovery-baseline)
这个提交包含在:
@@ -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" }),
|
||||
});
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户