完善企微开通与部署端口契约(07-26-internal-ops-recovery-baseline)
这个提交包含在:
@@ -183,6 +183,7 @@ function getConfig() {
|
||||
wecomHrMasterSyncEnabled: process.env.WECOM_HR_MASTER_SYNC_ENABLED === "true",
|
||||
wecomHrAssistantSecret: process.env.WECOM_HR_ASSISTANT_SECRET ?? "",
|
||||
wecomOauthEnabled: process.env.WECOM_OAUTH_ENABLED === "true",
|
||||
wecomOauthOnboardingEnabled: process.env.WECOM_OAUTH_ONBOARDING_ENABLED === "true",
|
||||
wecomRedirectUri: process.env.WECOM_REDIRECT_URI ?? "",
|
||||
wecomAllowedReturnHosts: process.env.WECOM_ALLOWED_RETURN_HOSTS ?? "",
|
||||
wecomApprovalCallbackUrl: process.env.WECOM_APPROVAL_CALLBACK_URL ?? "",
|
||||
|
||||
+92
-11
@@ -6,6 +6,11 @@ const { buildAuditLogData } = require("../utils/audit");
|
||||
const { randomRefreshToken, sha256Hex } = require("../utils/crypto");
|
||||
const { signPayload, verifyToken } = require("../utils/signedUrl");
|
||||
const { sendData } = require("../utils/reply");
|
||||
const {
|
||||
WecomOAuthOnboardingError,
|
||||
fetchWecomOAuthOnboardingProfile,
|
||||
provisionWecomOAuthEmployee,
|
||||
} = require("../services/integrations/wecomOAuthOnboarding");
|
||||
|
||||
const BIND_COOKIE_NAME = "wecom_bind";
|
||||
const PENDING_LOGIN_BIND_COOKIE_NAME = "wecom_login_bind";
|
||||
@@ -15,7 +20,7 @@ const DEFAULT_RETURN_TO = "/mobile";
|
||||
const DISABLED_STATUS_REASONS = new Set(["wecom_disabled", "entry_disabled", "oauth_disabled"]);
|
||||
const QUERY_SCHEMA = z.object({
|
||||
returnTo: z.string().optional(),
|
||||
mode: z.enum(["login", "bind"]).optional(),
|
||||
mode: z.enum(["login", "bind", "onboard"]).optional(),
|
||||
deviceFingerprint: z.string().trim().min(1).max(128).optional(),
|
||||
});
|
||||
const BIND_BODY_SCHEMA = z.object({ returnTo: z.string().optional() }).strict().optional();
|
||||
@@ -159,6 +164,7 @@ function resolveWecomEntryConfig(config) {
|
||||
&& config.wecomRedirectUri,
|
||||
);
|
||||
const entryEnabled = Boolean(bindEnabled && config.wecomEntryEnabled);
|
||||
const onboardingEnabled = Boolean(bindEnabled && config.wecomOauthOnboardingEnabled);
|
||||
const hasDisabledReason = statusReasons.some((reason) => DISABLED_STATUS_REASONS.has(reason));
|
||||
|
||||
return {
|
||||
@@ -169,6 +175,8 @@ function resolveWecomEntryConfig(config) {
|
||||
agentId: String(config.wecomAgentId ?? ""),
|
||||
loginUrl: entryEnabled ? buildWecomLoginUrl(DEFAULT_RETURN_TO, "login") : null,
|
||||
bindEnabled,
|
||||
onboardingEnabled,
|
||||
onboardingUrl: onboardingEnabled ? buildWecomLoginUrl(DEFAULT_RETURN_TO, "onboard") : null,
|
||||
defaultReturnTo: DEFAULT_RETURN_TO,
|
||||
status: entryEnabled ? "ready" : hasDisabledReason ? "disabled" : "misconfigured",
|
||||
statusReasons,
|
||||
@@ -199,10 +207,17 @@ function assertWecomEntryReady(config) {
|
||||
return entryConfig;
|
||||
}
|
||||
|
||||
function assertWecomOnboardingReady(config) {
|
||||
assertWecomOauthReady(config);
|
||||
if (!config.wecomOauthOnboardingEnabled) {
|
||||
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
|
||||
}
|
||||
}
|
||||
|
||||
function buildWecomLoginUrl(returnTo = DEFAULT_RETURN_TO, mode = "login") {
|
||||
const params = new URLSearchParams({ returnTo });
|
||||
if (mode === "bind") {
|
||||
params.set("mode", "bind");
|
||||
if (mode === "bind" || mode === "onboard") {
|
||||
params.set("mode", mode);
|
||||
}
|
||||
return `/api/auth/wecom/login?${params.toString()}`;
|
||||
}
|
||||
@@ -219,14 +234,24 @@ async function getWecomAccessToken(config) {
|
||||
const url = new URL("https://qyapi.weixin.qq.com/cgi-bin/gettoken");
|
||||
url.searchParams.set("corpid", config.wecomCorpId);
|
||||
url.searchParams.set("corpsecret", config.wecomAgentSecret);
|
||||
const response = await fetch(url);
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(url);
|
||||
} catch {
|
||||
throw new AppError({
|
||||
code: "AUTH_003",
|
||||
message: "企业微信认证失败",
|
||||
statusCode: 401,
|
||||
details: { wecom: "token_exchange_failed" },
|
||||
});
|
||||
}
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok || !payload || Number(payload.errcode ?? 0) !== 0 || !payload.access_token) {
|
||||
throw new AppError({
|
||||
code: "AUTH_003",
|
||||
message: "企业微信认证失败",
|
||||
statusCode: 401,
|
||||
details: { wecom: "token_exchange_failed", payload },
|
||||
details: { wecom: "token_exchange_failed" },
|
||||
});
|
||||
}
|
||||
return String(payload.access_token);
|
||||
@@ -237,14 +262,24 @@ async function exchangeWecomCode(config, code) {
|
||||
const url = new URL("https://qyapi.weixin.qq.com/cgi-bin/auth/getuserinfo");
|
||||
url.searchParams.set("access_token", accessToken);
|
||||
url.searchParams.set("code", code);
|
||||
const response = await fetch(url);
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(url);
|
||||
} catch {
|
||||
throw new AppError({
|
||||
code: "AUTH_003",
|
||||
message: "企业微信认证失败",
|
||||
statusCode: 401,
|
||||
details: { wecom: "userinfo_exchange_failed" },
|
||||
});
|
||||
}
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok || !payload || Number(payload.errcode ?? 0) !== 0) {
|
||||
throw new AppError({
|
||||
code: "AUTH_003",
|
||||
message: "企业微信认证失败",
|
||||
statusCode: 401,
|
||||
details: { wecom: "userinfo_exchange_failed", payload },
|
||||
details: { wecom: "userinfo_exchange_failed" },
|
||||
});
|
||||
}
|
||||
const payloadKeys = payload && typeof payload === "object" ? Object.keys(payload).sort() : [];
|
||||
@@ -261,6 +296,8 @@ async function exchangeWecomCode(config, code) {
|
||||
externalUserId: pickCandidate(payload.UserId, payload.userid),
|
||||
externalOpenUserId: pickCandidate(payload.OpenId, payload.openid),
|
||||
deviceId: pickCandidate(payload.DeviceId, payload.deviceid),
|
||||
userTicket: pickCandidate(payload.user_ticket),
|
||||
accessToken,
|
||||
diagnostics: {
|
||||
errcode: Number(payload.errcode ?? 0),
|
||||
errmsg: typeof payload.errmsg === "string" ? payload.errmsg : null,
|
||||
@@ -280,9 +317,9 @@ async function exchangeWecomCode(config, code) {
|
||||
};
|
||||
}
|
||||
|
||||
function buildWecomAuthorizeUrl(config, state) {
|
||||
function buildWecomAuthorizeUrl(config, state, mode = "login") {
|
||||
const redirectUri = encodeURIComponent(config.wecomRedirectUri);
|
||||
const scope = "snsapi_base";
|
||||
const scope = mode === "onboard" ? "snsapi_privateinfo" : "snsapi_base";
|
||||
return `https://open.weixin.qq.com/connect/oauth2/authorize?appid=${encodeURIComponent(config.wecomCorpId)}&redirect_uri=${redirectUri}&response_type=code&scope=${scope}&agentid=${encodeURIComponent(config.wecomAgentId)}&state=${encodeURIComponent(state)}#wechat_redirect`;
|
||||
}
|
||||
|
||||
@@ -364,6 +401,8 @@ async function wecomAuthRoutes(fastify) {
|
||||
reply.clearCookie(BIND_COOKIE_NAME, bindCookieOptions(fastify.config));
|
||||
throw new AppError({ code: "AUTH_004", message: "Access Token 已过期/无效", statusCode: 401 });
|
||||
}
|
||||
} else if (mode === "onboard") {
|
||||
assertWecomOnboardingReady(fastify.config);
|
||||
} else {
|
||||
assertWecomEntryReady(fastify.config);
|
||||
}
|
||||
@@ -376,7 +415,7 @@ async function wecomAuthRoutes(fastify) {
|
||||
mode,
|
||||
deviceFingerprint,
|
||||
}, fastify.config.jwtSecret);
|
||||
return reply.redirect(buildWecomAuthorizeUrl(fastify.config, state));
|
||||
return reply.redirect(buildWecomAuthorizeUrl(fastify.config, state, mode));
|
||||
});
|
||||
|
||||
fastify.post("/wecom/bind", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
@@ -427,7 +466,7 @@ async function wecomAuthRoutes(fastify) {
|
||||
if (typeof ctx.exp !== "number" || ctx.exp <= Date.now()) {
|
||||
throw new AppError({ code: "AUTH_004", message: "Access Token 已过期/无效", statusCode: 401 });
|
||||
}
|
||||
const mode = ctx.mode === "bind" ? "bind" : "login";
|
||||
const mode = ctx.mode === "bind" ? "bind" : ctx.mode === "onboard" ? "onboard" : "login";
|
||||
const returnTo = sanitizeReturnTo(ctx.returnTo, fastify.config, request);
|
||||
const profile = await exchangeWecomCode(fastify.config, code);
|
||||
if (!profile.externalUserId) {
|
||||
@@ -459,6 +498,48 @@ async function wecomAuthRoutes(fastify) {
|
||||
|
||||
const origin = resolvePrimaryAppOrigin(fastify.config, request);
|
||||
|
||||
if (mode === "onboard") {
|
||||
assertWecomOnboardingReady(fastify.config);
|
||||
if (!profile.userTicket) {
|
||||
return reply.redirect(`${origin}/login?redirect=${encodeURIComponent(returnTo)}&wecom=consent_required`);
|
||||
}
|
||||
try {
|
||||
const onboardingProfile = await fetchWecomOAuthOnboardingProfile({
|
||||
accessToken: profile.accessToken,
|
||||
externalUserId: profile.externalUserId,
|
||||
userTicket: profile.userTicket,
|
||||
});
|
||||
const employee = await provisionWecomOAuthEmployee({
|
||||
prisma: fastify.prisma,
|
||||
request,
|
||||
profile: onboardingProfile,
|
||||
externalOpenUserId: profile.externalOpenUserId,
|
||||
});
|
||||
const deviceFingerprint = resolveOAuthDeviceFingerprint(ctx.deviceFingerprint);
|
||||
const session = await createAuthenticatedSession({
|
||||
prisma: fastify.prisma,
|
||||
request,
|
||||
config: fastify.config,
|
||||
employeeId: employee.id,
|
||||
tokenVersion: employee.token_version,
|
||||
deviceFingerprint,
|
||||
auditAction: "AUTH_WECOM_ONBOARDING_LOGIN_SUCCESS",
|
||||
extra: { created: employee.created },
|
||||
});
|
||||
reply.setCookie(fastify.config.refreshCookieName, session.refreshToken, refreshCookieOptions(fastify.config));
|
||||
reply.setCookie("has_session", "1", sessionHintCookieOptions(fastify.config));
|
||||
reply.setCookie(DEVICE_FINGERPRINT_COOKIE_NAME, deviceFingerprint, deviceFingerprintCookieOptions(fastify.config));
|
||||
return reply.redirect(`${origin}${returnTo}`);
|
||||
} catch (error) {
|
||||
if (error instanceof WecomOAuthOnboardingError) {
|
||||
return reply.redirect(
|
||||
`${origin}/login?redirect=${encodeURIComponent(returnTo)}&wecom=${encodeURIComponent(error.code)}`,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (mode === "bind") {
|
||||
const bindCookie = request.cookies?.[BIND_COOKIE_NAME];
|
||||
const bindVerified = verifyToken(bindCookie, fastify.config.jwtSecret);
|
||||
|
||||
@@ -327,7 +327,7 @@ async function fetchDepartmentMap({ accessToken, fetchImpl, timeoutMs }) {
|
||||
return normalizeDepartmentMap(result.payload);
|
||||
}
|
||||
|
||||
async function listContactMembers({ accessToken, limit, fetchImpl, timeoutMs }) {
|
||||
async function listContactMembers({ accessToken, detailAccessToken = accessToken, limit, fetchImpl, timeoutMs }) {
|
||||
const membersByUserid = new Map();
|
||||
let cursor = "";
|
||||
|
||||
@@ -363,7 +363,7 @@ async function listContactMembers({ accessToken, limit, fetchImpl, timeoutMs })
|
||||
const members = Array.from(membersByUserid.values()).slice(0, limit);
|
||||
const contacts = await Promise.all(members.map(async (member) => {
|
||||
const detail = await fetchOptionalContactMember({
|
||||
accessToken,
|
||||
accessToken: detailAccessToken,
|
||||
userid: member.userid,
|
||||
fetchImpl,
|
||||
timeoutMs,
|
||||
@@ -477,21 +477,22 @@ function normalizeWecomHrProfile({ contact = {}, staff = {}, departmentMap = new
|
||||
return profile;
|
||||
}
|
||||
|
||||
async function fetchContactProfilesInput({ config, token, userids, limit, fetchImpl, timeoutMs }) {
|
||||
async function fetchContactProfilesInput({ config, token, detailToken = token, userids, limit, fetchImpl, timeoutMs }) {
|
||||
const departmentMap = await fetchOptionalDepartmentMap({
|
||||
accessToken: token.accessToken,
|
||||
accessToken: detailToken.accessToken,
|
||||
fetchImpl,
|
||||
timeoutMs,
|
||||
});
|
||||
const contacts = userids.length
|
||||
? await Promise.all(userids.map((userid) => fetchOptionalContactMember({
|
||||
accessToken: token.accessToken,
|
||||
accessToken: detailToken.accessToken,
|
||||
userid,
|
||||
fetchImpl,
|
||||
timeoutMs,
|
||||
})))
|
||||
: await listContactMembers({
|
||||
accessToken: token.accessToken,
|
||||
detailAccessToken: detailToken.accessToken,
|
||||
limit,
|
||||
fetchImpl,
|
||||
timeoutMs,
|
||||
@@ -526,6 +527,7 @@ async function fetchWecomHrProfiles({ config, userids, limit, fetchImpl = fetch
|
||||
const contactInput = await fetchContactProfilesInput({
|
||||
config,
|
||||
token: contactToken,
|
||||
detailToken: hrToken,
|
||||
userids,
|
||||
limit: readLimit,
|
||||
fetchImpl,
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
const crypto = require("node:crypto");
|
||||
const { z } = require("zod");
|
||||
|
||||
const { buildAuditLogData } = require("../../utils/audit");
|
||||
const { hashPassword } = require("../../utils/crypto");
|
||||
|
||||
const WECOM_API_BASE = "https://qyapi.weixin.qq.com";
|
||||
const PRIVATE_EMAIL_SCHEMA = z.string().trim().email().max(200).transform((value) => value.toLowerCase());
|
||||
const PRIVATE_PHONE_SCHEMA = z.string().trim().min(6).max(30);
|
||||
|
||||
class WecomOAuthOnboardingError extends Error {
|
||||
constructor(code, message) {
|
||||
super(message);
|
||||
this.name = "WecomOAuthOnboardingError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
function requiredString(value, maxLength) {
|
||||
if (typeof value !== "string") return "";
|
||||
return value.trim().slice(0, maxLength);
|
||||
}
|
||||
|
||||
function normalizeEmail(value) {
|
||||
const parsed = PRIVATE_EMAIL_SCHEMA.safeParse(value);
|
||||
return parsed.success ? parsed.data : "";
|
||||
}
|
||||
|
||||
function normalizePhone(value) {
|
||||
const parsed = PRIVATE_PHONE_SCHEMA.safeParse(value);
|
||||
return parsed.success ? parsed.data : "";
|
||||
}
|
||||
|
||||
async function fetchWecomJson(pathName, accessToken, options = {}) {
|
||||
const url = new URL(`${WECOM_API_BASE}${pathName}`);
|
||||
url.searchParams.set("access_token", accessToken);
|
||||
for (const [key, value] of Object.entries(options.query ?? {})) {
|
||||
if (value != null && value !== "") url.searchParams.set(key, String(value));
|
||||
}
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = await (options.fetchImpl ?? fetch)(url, {
|
||||
method: options.body ? "POST" : "GET",
|
||||
headers: options.body ? { "Content-Type": "application/json" } : undefined,
|
||||
body: options.body ? JSON.stringify(options.body) : undefined,
|
||||
});
|
||||
} catch {
|
||||
throw new WecomOAuthOnboardingError("wecom_api_failed", "WeCom onboarding API failed");
|
||||
}
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok || !payload || Number(payload.errcode ?? 0) !== 0) {
|
||||
throw new WecomOAuthOnboardingError("wecom_api_failed", "WeCom onboarding API failed");
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
function normalizeDepartmentMap(payload) {
|
||||
const map = new Map();
|
||||
for (const item of Array.isArray(payload?.department) ? payload.department : []) {
|
||||
const externalId = requiredString(String(item?.id ?? item?.department_id ?? ""), 64);
|
||||
const name = requiredString(item?.name, 100);
|
||||
if (externalId && name) map.set(externalId, name);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
async function fetchWecomOAuthOnboardingProfile({
|
||||
accessToken,
|
||||
externalUserId,
|
||||
userTicket,
|
||||
fetchImpl = fetch,
|
||||
}) {
|
||||
const userid = requiredString(externalUserId, 128);
|
||||
const ticket = requiredString(userTicket, 512);
|
||||
if (!userid || !ticket) {
|
||||
throw new WecomOAuthOnboardingError("consent_required", "WeCom private information consent is required");
|
||||
}
|
||||
|
||||
const [privateDetail, contact, departmentPayload] = await Promise.all([
|
||||
fetchWecomJson("/cgi-bin/auth/getuserdetail", accessToken, {
|
||||
body: { user_ticket: ticket },
|
||||
fetchImpl,
|
||||
}),
|
||||
fetchWecomJson("/cgi-bin/user/get", accessToken, {
|
||||
query: { userid },
|
||||
fetchImpl,
|
||||
}),
|
||||
fetchWecomJson("/cgi-bin/department/list", accessToken, { fetchImpl }),
|
||||
]);
|
||||
|
||||
const detailUserid = requiredString(privateDetail.userid ?? privateDetail.UserId, 128);
|
||||
if (!detailUserid || detailUserid !== userid) {
|
||||
throw new WecomOAuthOnboardingError("identity_mismatch", "WeCom onboarding identity mismatch");
|
||||
}
|
||||
|
||||
const status = Number(contact.status ?? contact.enable ?? 0);
|
||||
if (status !== 1) {
|
||||
throw new WecomOAuthOnboardingError("member_inactive", "WeCom member is not active");
|
||||
}
|
||||
|
||||
const departmentExternalId = requiredString(
|
||||
String(contact.main_department ?? contact.department?.[0] ?? ""),
|
||||
64,
|
||||
);
|
||||
const departmentName = normalizeDepartmentMap(departmentPayload).get(departmentExternalId) ?? "";
|
||||
const profile = {
|
||||
externalUserId: userid,
|
||||
name: requiredString(contact.name, 100),
|
||||
phone: normalizePhone(privateDetail.mobile),
|
||||
email: normalizeEmail(privateDetail.email) || normalizeEmail(privateDetail.biz_mail),
|
||||
departmentExternalId,
|
||||
departmentName,
|
||||
title: requiredString(contact.position, 100) || "员工",
|
||||
};
|
||||
|
||||
const missing = [];
|
||||
for (const field of ["name", "phone", "email", "departmentExternalId", "departmentName"]) {
|
||||
if (!profile[field]) missing.push(field);
|
||||
}
|
||||
if (missing.length) {
|
||||
throw new WecomOAuthOnboardingError("missing_required_field", "WeCom onboarding fields are incomplete");
|
||||
}
|
||||
return profile;
|
||||
}
|
||||
|
||||
async function resolveDepartment(tx, profile, actorId) {
|
||||
const binding = await tx.integrationBinding.findFirst({
|
||||
where: {
|
||||
provider: "wecom",
|
||||
entity_type: "department",
|
||||
external_id: profile.departmentExternalId,
|
||||
},
|
||||
});
|
||||
if (binding) {
|
||||
const department = await tx.department.findUnique({
|
||||
where: { id: binding.local_id },
|
||||
select: { id: true, archived_at: true },
|
||||
});
|
||||
if (!department || department.archived_at) {
|
||||
throw new WecomOAuthOnboardingError("department_invalid", "WeCom department binding is unavailable");
|
||||
}
|
||||
return department.id;
|
||||
}
|
||||
|
||||
const matches = await tx.department.findMany({
|
||||
where: { name: profile.departmentName, archived_at: null },
|
||||
select: { id: true },
|
||||
take: 2,
|
||||
});
|
||||
if (matches.length > 1) {
|
||||
throw new WecomOAuthOnboardingError("department_ambiguous", "WeCom department matched multiple OA departments");
|
||||
}
|
||||
|
||||
const departmentId = matches[0]?.id ?? (await tx.department.create({
|
||||
data: {
|
||||
name: profile.departmentName,
|
||||
created_by: actorId,
|
||||
updated_by: actorId,
|
||||
},
|
||||
select: { id: true },
|
||||
})).id;
|
||||
|
||||
await tx.integrationBinding.create({
|
||||
data: {
|
||||
provider: "wecom",
|
||||
entity_type: "department",
|
||||
local_id: departmentId,
|
||||
external_id: profile.departmentExternalId,
|
||||
external_code: profile.departmentName,
|
||||
last_synced_at: new Date(),
|
||||
created_by: actorId,
|
||||
updated_by: actorId,
|
||||
},
|
||||
});
|
||||
return departmentId;
|
||||
}
|
||||
|
||||
async function resolveEmployee(tx, profile) {
|
||||
const binding = await tx.wecomIdentityBinding.findUnique({
|
||||
where: { external_user_id: profile.externalUserId },
|
||||
select: {
|
||||
employee: {
|
||||
select: { id: true, status: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
const contactMatches = await tx.employee.findMany({
|
||||
where: { OR: [{ phone: profile.phone }, { email: profile.email }] },
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
wecom_identity_binding: { select: { external_user_id: true } },
|
||||
},
|
||||
take: 3,
|
||||
});
|
||||
|
||||
const matches = new Map();
|
||||
if (binding?.employee) matches.set(binding.employee.id, binding.employee);
|
||||
for (const employee of contactMatches) matches.set(employee.id, employee);
|
||||
if (matches.size > 1) {
|
||||
throw new WecomOAuthOnboardingError("employee_conflict", "WeCom onboarding matched multiple OA employees");
|
||||
}
|
||||
const employee = [...matches.values()][0] ?? null;
|
||||
if (employee && employee.status !== "active") {
|
||||
throw new WecomOAuthOnboardingError("employee_inactive", "Matched OA employee is inactive");
|
||||
}
|
||||
const existingExternalUserId = requiredString(employee?.wecom_identity_binding?.external_user_id, 128);
|
||||
if (existingExternalUserId && existingExternalUserId.toLowerCase() !== profile.externalUserId.toLowerCase()) {
|
||||
throw new WecomOAuthOnboardingError("employee_conflict", "Matched OA employee is bound to another WeCom member");
|
||||
}
|
||||
return employee;
|
||||
}
|
||||
|
||||
async function provisionWecomOAuthEmployee({
|
||||
prisma,
|
||||
request,
|
||||
profile,
|
||||
externalOpenUserId = null,
|
||||
}) {
|
||||
try {
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
const existing = await resolveEmployee(tx, profile);
|
||||
const actorId = existing?.id ?? null;
|
||||
const departmentId = await resolveDepartment(tx, profile, actorId);
|
||||
const now = new Date();
|
||||
const employee = existing
|
||||
? await tx.employee.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
name: profile.name,
|
||||
phone: profile.phone,
|
||||
email: profile.email,
|
||||
department_id: departmentId,
|
||||
title: profile.title,
|
||||
updated_at: now,
|
||||
updated_by: existing.id,
|
||||
},
|
||||
select: { id: true, token_version: true },
|
||||
})
|
||||
: await tx.employee.create({
|
||||
data: {
|
||||
name: profile.name,
|
||||
phone: profile.phone,
|
||||
email: profile.email,
|
||||
department_id: departmentId,
|
||||
title: profile.title,
|
||||
hire_date: now,
|
||||
status: "active",
|
||||
employment_status: "active",
|
||||
bank_account_encrypted: "",
|
||||
bank_name: "",
|
||||
password_hash: await hashPassword(`WecomOnboardA1${crypto.randomBytes(18).toString("hex")}`),
|
||||
must_change_password: true,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
select: { id: true, token_version: true },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
await tx.employeeRole.create({
|
||||
data: {
|
||||
employee_id: employee.id,
|
||||
role_code: "employee",
|
||||
created_at: now,
|
||||
created_by: employee.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await tx.wecomIdentityBinding.upsert({
|
||||
where: { employee_id: employee.id },
|
||||
update: {
|
||||
external_user_id: profile.externalUserId,
|
||||
external_open_user_id: externalOpenUserId,
|
||||
updated_by: employee.id,
|
||||
},
|
||||
create: {
|
||||
employee_id: employee.id,
|
||||
external_user_id: profile.externalUserId,
|
||||
external_open_user_id: externalOpenUserId,
|
||||
created_by: employee.id,
|
||||
updated_by: employee.id,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: employee.id,
|
||||
action: existing ? "AUTH_WECOM_PROFILE_COMPLETED" : "AUTH_WECOM_SELF_PROVISIONED",
|
||||
targetType: "employee",
|
||||
targetId: employee.id,
|
||||
extra: {
|
||||
created: !existing,
|
||||
source: "wecom_oauth",
|
||||
fieldPresence: { phone: true, email: true },
|
||||
},
|
||||
}),
|
||||
});
|
||||
return { ...employee, created: !existing };
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof WecomOAuthOnboardingError) throw error;
|
||||
if (error?.code === "P2002") {
|
||||
throw new WecomOAuthOnboardingError("employee_conflict", "WeCom onboarding unique field conflict");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
WecomOAuthOnboardingError,
|
||||
fetchWecomOAuthOnboardingProfile,
|
||||
provisionWecomOAuthEmployee,
|
||||
};
|
||||
@@ -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" }),
|
||||
});
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户