完善企微开通与部署端口契约(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: "" } });
|
||||
|
||||
|
||||
@@ -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" }),
|
||||
});
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户