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: "member1@example.test", }); } 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: "member1@example.test", 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: "Business.User@Example.test", }), }); expect(profile.email).toBe("business.user@example.test"); 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: "a@example.test" }); } 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: "a@example.test" }); } 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: "member1@example.test", 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("member1@example.test"); }); 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: "conflict@example.test", 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: "member4@example.test", 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: "member1@example.test", 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" }), }); }); });