346 行
12 KiB
JavaScript
346 行
12 KiB
JavaScript
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" }),
|
|
});
|
|
});
|
|
});
|