HCLT 收口企微通讯录公网闭环测试
这个提交包含在:
@@ -0,0 +1,440 @@
|
||||
#!/usr/bin/env node
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { randomInt } from "node:crypto";
|
||||
|
||||
const DEFAULT_BASE_URL = "https://oa.hyinda.com";
|
||||
const DEFAULT_REPORT_ROOT = "artifacts/qa-flow";
|
||||
const REQUIRED_APPROVAL = "HRWD_CONTACT_LIVE_APPROVED";
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = {
|
||||
baseUrl: process.env.LIVE_SMOKE_BASE_URL || DEFAULT_BASE_URL,
|
||||
issueId: process.env.QA_ISSUE_ID || "HCLT",
|
||||
reportDir: process.env.HRWD_CONTACT_LIVE_REPORT_DIR || "",
|
||||
};
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (arg.startsWith("--base-url=")) {
|
||||
options.baseUrl = arg.slice("--base-url=".length);
|
||||
} else if (arg.startsWith("--report-dir=")) {
|
||||
options.reportDir = arg.slice("--report-dir=".length);
|
||||
}
|
||||
}
|
||||
|
||||
options.baseUrl = options.baseUrl.trim().replace(/\/$/, "");
|
||||
options.issueId = options.issueId.trim() || "HCLT";
|
||||
if (!options.reportDir) {
|
||||
const stamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
|
||||
options.reportDir = join(DEFAULT_REPORT_ROOT, `hrwd-contact-live-test-${stamp}`);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function getCredential(role) {
|
||||
const prefixByRole = {
|
||||
admin: "LIVE_SMOKE_ADMIN",
|
||||
employee: "LIVE_SMOKE_EMPLOYEE",
|
||||
};
|
||||
const prefix = prefixByRole[role];
|
||||
const identifier = process.env[`${prefix}_IDENTIFIER`]?.trim();
|
||||
const password = process.env[`${prefix}_PASSWORD`]?.trim();
|
||||
if (!identifier || !password) {
|
||||
return null;
|
||||
}
|
||||
return { identifier, password };
|
||||
}
|
||||
|
||||
async function fetchJson(url, init = {}) {
|
||||
const response = await fetch(url, {
|
||||
...init,
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
const text = await response.text();
|
||||
let json = null;
|
||||
try {
|
||||
json = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
json = null;
|
||||
}
|
||||
return { ok: response.ok, status: response.status, json };
|
||||
}
|
||||
|
||||
function summarizeError(json) {
|
||||
const error = json?.error ?? {};
|
||||
return {
|
||||
code: typeof error.code === "string" ? error.code : null,
|
||||
featureFlag: typeof error.details?.featureFlag === "string" ? error.details.featureFlag : null,
|
||||
reason: typeof error.details?.reason === "string" ? error.details.reason : null,
|
||||
};
|
||||
}
|
||||
|
||||
async function login(baseUrl, role, credential) {
|
||||
const result = await fetchJson(`${baseUrl}/api/auth/login`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-device-fingerprint": `hrwd-contact-live-${role}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
identifier: credential.identifier,
|
||||
password: credential.password,
|
||||
}),
|
||||
});
|
||||
const accessToken = result.json?.data?.accessToken;
|
||||
if (!result.ok || typeof accessToken !== "string" || !accessToken) {
|
||||
throw new Error(`${role} login failed: HTTP ${result.status} ${summarizeError(result.json).code ?? ""}`.trim());
|
||||
}
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
async function callApi(baseUrl, token, method, pathName, body) {
|
||||
return fetchJson(`${baseUrl}${pathName}`, {
|
||||
method,
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
accept: "application/json",
|
||||
...(body ? { "content-type": "application/json" } : {}),
|
||||
},
|
||||
...(body ? { body: JSON.stringify(body) } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
function maskedId(value) {
|
||||
const raw = String(value ?? "");
|
||||
if (!raw) return null;
|
||||
return raw.length <= 8 ? `${raw.slice(0, 2)}***` : `${raw.slice(0, 8)}***`;
|
||||
}
|
||||
|
||||
function buildMarker() {
|
||||
const now = new Date();
|
||||
const pad = (value) => String(value).padStart(2, "0");
|
||||
return [
|
||||
now.getFullYear(),
|
||||
pad(now.getMonth() + 1),
|
||||
pad(now.getDate()),
|
||||
pad(now.getHours()),
|
||||
pad(now.getMinutes()),
|
||||
pad(now.getSeconds()),
|
||||
].join("");
|
||||
}
|
||||
|
||||
function randomTestPhone() {
|
||||
return `199${String(randomInt(0, 100_000_000)).padStart(8, "0")}`;
|
||||
}
|
||||
|
||||
function buildTestPayload(marker, attempt) {
|
||||
const suffix = attempt === 0 ? marker : `${marker}R${attempt}`;
|
||||
return {
|
||||
employeeNo: `HRWDTEST-${suffix}`,
|
||||
name: `HRWDTEST-${suffix}`,
|
||||
phone: randomTestPhone(),
|
||||
email: `hrwdtest-${suffix.toLowerCase()}@hyinda.com`,
|
||||
title: "HRWDTEST-Onboarding",
|
||||
roles: ["employee"],
|
||||
syncWecomDirectory: false,
|
||||
};
|
||||
}
|
||||
|
||||
function syncRunSummary(run) {
|
||||
if (!run) {
|
||||
return null;
|
||||
}
|
||||
const responseSummary = run.responseSummary && typeof run.responseSummary === "object" ? run.responseSummary : {};
|
||||
return {
|
||||
operation: run.operation ?? null,
|
||||
syncStatus: run.status ?? null,
|
||||
code: run.errorCode ?? null,
|
||||
errcode: responseSummary.errcode ?? null,
|
||||
externalUserIdPrefix: maskedId(run.externalUserId),
|
||||
};
|
||||
}
|
||||
|
||||
function assertWecomSyncSuccess(label, syncResult, expectedOperation) {
|
||||
const run = syncResult?.run ?? null;
|
||||
const errcode = run?.responseSummary?.errcode;
|
||||
if (
|
||||
!syncResult
|
||||
|| syncResult.status !== "success"
|
||||
|| syncResult.code !== "wecom_contact_synced"
|
||||
|| run?.operation !== expectedOperation
|
||||
|| Number(errcode ?? 0) !== 0
|
||||
) {
|
||||
const summary = syncRunSummary(run);
|
||||
throw new Error(`${label} failed: ${JSON.stringify({
|
||||
status: syncResult?.status ?? null,
|
||||
code: syncResult?.code ?? null,
|
||||
run: summary,
|
||||
})}`);
|
||||
}
|
||||
return syncRunSummary(run);
|
||||
}
|
||||
|
||||
function passStep(name, details = {}) {
|
||||
return { name, ...details, status: "PASS" };
|
||||
}
|
||||
|
||||
function failStep(name, details = {}) {
|
||||
return { name, ...details, status: "FAIL" };
|
||||
}
|
||||
|
||||
async function createEmployeeWithRetry(baseUrl, adminToken, marker) {
|
||||
let lastError = null;
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
const payload = buildTestPayload(marker, attempt);
|
||||
const result = await callApi(baseUrl, adminToken, "POST", "/api/users", payload);
|
||||
if (result.ok) {
|
||||
const data = result.json?.data ?? {};
|
||||
return {
|
||||
employee: data,
|
||||
oneTimePassword: data.oneTimePassword,
|
||||
marker: payload.name,
|
||||
};
|
||||
}
|
||||
lastError = {
|
||||
httpStatus: result.status,
|
||||
error: summarizeError(result.json),
|
||||
};
|
||||
if (result.status !== 409) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
throw new Error(`employee create failed: ${JSON.stringify(lastError)}`);
|
||||
}
|
||||
|
||||
function getPendingOffboardingTasks(profileLike) {
|
||||
const tasks = profileLike?.lifecycle?.checklistTasks ?? profileLike?.checklistTasks ?? [];
|
||||
return tasks.filter((task) => task.scene === "offboarding" && task.status === "pending");
|
||||
}
|
||||
|
||||
async function getDirectoryState(baseUrl, adminToken, employeeId) {
|
||||
const result = await callApi(baseUrl, adminToken, "GET", `/api/employees/${employeeId}/wecom-directory-sync`);
|
||||
if (!result.ok) {
|
||||
throw new Error(`directory state failed: HTTP ${result.status} ${summarizeError(result.json).code ?? ""}`.trim());
|
||||
}
|
||||
return result.json?.data ?? {};
|
||||
}
|
||||
|
||||
async function assertAuditAction(baseUrl, adminToken, action, targetId, dateFrom) {
|
||||
const params = new URLSearchParams({
|
||||
action,
|
||||
targetType: "employee",
|
||||
page: "1",
|
||||
pageSize: "100",
|
||||
dateFrom,
|
||||
});
|
||||
const result = await callApi(baseUrl, adminToken, "GET", `/api/audit-logs?${params.toString()}`);
|
||||
if (!result.ok) {
|
||||
throw new Error(`audit ${action} failed: HTTP ${result.status} ${summarizeError(result.json).code ?? ""}`.trim());
|
||||
}
|
||||
const items = result.json?.data?.items ?? [];
|
||||
const matched = items.some((item) => item.targetId === targetId && item.action === action);
|
||||
if (!matched) {
|
||||
throw new Error(`audit ${action} not found for target`);
|
||||
}
|
||||
return { action, matched: true };
|
||||
}
|
||||
|
||||
async function assertEmployeeCannotAccessSync(baseUrl, employeeToken, targetEmployeeId) {
|
||||
const view = await callApi(baseUrl, employeeToken, "GET", `/api/employees/${targetEmployeeId}/wecom-directory-sync`);
|
||||
if (view.status !== 403) {
|
||||
throw new Error(`employee sync view should be forbidden, got HTTP ${view.status}`);
|
||||
}
|
||||
const retry = await callApi(baseUrl, employeeToken, "POST", `/api/employees/${targetEmployeeId}/wecom-directory-sync/retry`, {
|
||||
operation: "update",
|
||||
});
|
||||
if (retry.status !== 403) {
|
||||
throw new Error(`employee sync retry should be forbidden, got HTTP ${retry.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
if (process.env[REQUIRED_APPROVAL] !== "1") {
|
||||
throw new Error(`${REQUIRED_APPROVAL}=1 is required before public HRWDTEST writes`);
|
||||
}
|
||||
|
||||
const adminCredential = getCredential("admin");
|
||||
const employeeCredential = getCredential("employee");
|
||||
if (!adminCredential || !employeeCredential) {
|
||||
throw new Error("missing LIVE_SMOKE_ADMIN_* or LIVE_SMOKE_EMPLOYEE_* credentials");
|
||||
}
|
||||
|
||||
mkdirSync(options.reportDir, { recursive: true });
|
||||
const startedAt = new Date().toISOString();
|
||||
const markerStamp = buildMarker();
|
||||
const steps = [];
|
||||
let createdEmployeeId = null;
|
||||
let createdMarker = `HRWDTEST-${markerStamp}`;
|
||||
let finalStatus = "FAIL";
|
||||
|
||||
try {
|
||||
const adminToken = await login(options.baseUrl, "admin", adminCredential);
|
||||
steps.push(passStep("admin-login"));
|
||||
const employeeToken = await login(options.baseUrl, "employee", employeeCredential);
|
||||
steps.push(passStep("employee-login"));
|
||||
|
||||
const created = await createEmployeeWithRetry(options.baseUrl, adminToken, markerStamp);
|
||||
const employee = created.employee;
|
||||
createdEmployeeId = employee.id;
|
||||
createdMarker = created.marker;
|
||||
const oneTimePassword = created.oneTimePassword;
|
||||
if (!createdEmployeeId || !oneTimePassword) {
|
||||
throw new Error("employee create response missing id or one-time password");
|
||||
}
|
||||
steps.push(passStep("employee-created", {
|
||||
employeeIdPrefix: maskedId(createdEmployeeId),
|
||||
marker: createdMarker,
|
||||
wecomSyncSkippedAtCreate: employee.wecomDirectorySync == null,
|
||||
}));
|
||||
|
||||
const onboarding = await callApi(options.baseUrl, adminToken, "POST", `/api/employees/${createdEmployeeId}/onboarding/start`, {
|
||||
effectiveDate: new Date().toISOString().slice(0, 10),
|
||||
notes: `${options.issueId} ${createdMarker}`,
|
||||
});
|
||||
if (!onboarding.ok) {
|
||||
throw new Error(`onboarding failed: HTTP ${onboarding.status} ${summarizeError(onboarding.json).code ?? ""}`.trim());
|
||||
}
|
||||
const createSync = assertWecomSyncSuccess("wecom create", onboarding.json?.data?.wecomDirectorySync, "create");
|
||||
steps.push(passStep("wecom-create", createSync));
|
||||
|
||||
await assertEmployeeCannotAccessSync(options.baseUrl, employeeToken, createdEmployeeId);
|
||||
steps.push(passStep("employee-sync-permission-denied"));
|
||||
|
||||
const updatedTitle = `HRWDTEST-Update-${markerStamp}`;
|
||||
const update = await callApi(options.baseUrl, adminToken, "PATCH", `/api/employees/${createdEmployeeId}/profile`, {
|
||||
title: updatedTitle,
|
||||
});
|
||||
if (!update.ok) {
|
||||
throw new Error(`profile update failed: HTTP ${update.status} ${summarizeError(update.json).code ?? ""}`.trim());
|
||||
}
|
||||
const updateSync = assertWecomSyncSuccess("wecom update", update.json?.data?.wecomDirectorySync, "update");
|
||||
steps.push(passStep("wecom-update", updateSync));
|
||||
|
||||
const offboarding = await callApi(options.baseUrl, adminToken, "POST", `/api/employees/${createdEmployeeId}/offboarding/start`, {
|
||||
effectiveDate: new Date().toISOString().slice(0, 10),
|
||||
lastWorkingDate: new Date().toISOString().slice(0, 10),
|
||||
notes: `${options.issueId} ${createdMarker}`,
|
||||
});
|
||||
if (!offboarding.ok) {
|
||||
throw new Error(`offboarding start failed: HTTP ${offboarding.status} ${summarizeError(offboarding.json).code ?? ""}`.trim());
|
||||
}
|
||||
steps.push(passStep("offboarding-started"));
|
||||
|
||||
let profile = offboarding.json?.data ?? {};
|
||||
let tasks = getPendingOffboardingTasks(profile);
|
||||
if (!tasks.length) {
|
||||
const latestProfile = await callApi(options.baseUrl, adminToken, "GET", `/api/employees/${createdEmployeeId}/profile`);
|
||||
if (!latestProfile.ok) {
|
||||
throw new Error(`profile fetch failed: HTTP ${latestProfile.status} ${summarizeError(latestProfile.json).code ?? ""}`.trim());
|
||||
}
|
||||
profile = latestProfile.json?.data ?? {};
|
||||
tasks = getPendingOffboardingTasks(profile);
|
||||
}
|
||||
if (!tasks.length) {
|
||||
throw new Error("offboarding checklist has no pending tasks");
|
||||
}
|
||||
|
||||
let disableSync = null;
|
||||
for (const task of tasks) {
|
||||
const completed = await callApi(options.baseUrl, adminToken, "POST", `/api/employees/${createdEmployeeId}/checklist-tasks/${task.id}/complete`, {});
|
||||
if (!completed.ok) {
|
||||
throw new Error(`checklist complete failed: HTTP ${completed.status} ${summarizeError(completed.json).code ?? ""}`.trim());
|
||||
}
|
||||
if (completed.json?.data?.wecomDirectorySync) {
|
||||
disableSync = completed.json.data.wecomDirectorySync;
|
||||
}
|
||||
}
|
||||
const disableSummary = assertWecomSyncSuccess("wecom disable", disableSync, "disable");
|
||||
steps.push(passStep("wecom-disable", {
|
||||
...disableSummary,
|
||||
completedTaskCount: tasks.length,
|
||||
}));
|
||||
|
||||
const finalProfile = await callApi(options.baseUrl, adminToken, "GET", `/api/employees/${createdEmployeeId}/profile`);
|
||||
if (!finalProfile.ok) {
|
||||
throw new Error(`final profile failed: HTTP ${finalProfile.status} ${summarizeError(finalProfile.json).code ?? ""}`.trim());
|
||||
}
|
||||
const finalEmployee = finalProfile.json?.data?.employee ?? {};
|
||||
if (finalEmployee.status !== "archived" || finalEmployee.employmentStatus !== "inactive") {
|
||||
throw new Error(`employee final state mismatch: ${JSON.stringify({
|
||||
status: finalEmployee.status ?? null,
|
||||
employmentStatus: finalEmployee.employmentStatus ?? null,
|
||||
})}`);
|
||||
}
|
||||
steps.push(passStep("oa-employee-disabled", {
|
||||
employeeStatus: finalEmployee.status,
|
||||
employmentStatus: finalEmployee.employmentStatus,
|
||||
}));
|
||||
|
||||
const deniedLogin = await fetchJson(`${options.baseUrl}/api/auth/login`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-device-fingerprint": "hrwd-contact-live-disabled-employee",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
identifier: employee.email,
|
||||
password: oneTimePassword,
|
||||
}),
|
||||
});
|
||||
if (deniedLogin.ok || deniedLogin.json?.data?.accessToken) {
|
||||
throw new Error("disabled employee login unexpectedly succeeded");
|
||||
}
|
||||
steps.push(passStep("disabled-employee-login-denied", {
|
||||
httpStatus: deniedLogin.status,
|
||||
code: summarizeError(deniedLogin.json).code,
|
||||
}));
|
||||
|
||||
const directoryState = await getDirectoryState(options.baseUrl, adminToken, createdEmployeeId);
|
||||
const latestSync = directoryState.wecomDirectory?.latestSync ?? null;
|
||||
steps.push(passStep("admin-sync-state-readable", {
|
||||
itemCount: Array.isArray(directoryState.items) ? directoryState.items.length : null,
|
||||
latest: syncRunSummary(latestSync),
|
||||
}));
|
||||
|
||||
const auditResults = [];
|
||||
for (const action of ["WECOM_DIRECTORY_CREATE", "WECOM_DIRECTORY_UPDATE", "WECOM_DIRECTORY_DISABLE"]) {
|
||||
auditResults.push(await assertAuditAction(options.baseUrl, adminToken, action, createdEmployeeId, startedAt));
|
||||
}
|
||||
steps.push(passStep("audit-actions", {
|
||||
actions: auditResults.map((item) => item.action),
|
||||
}));
|
||||
|
||||
finalStatus = "PASS";
|
||||
} catch (error) {
|
||||
steps.push(failStep("hrwd-contact-live-test", {
|
||||
message: error instanceof Error ? error.message.slice(0, 500) : String(error).slice(0, 500),
|
||||
}));
|
||||
} finally {
|
||||
const summary = {
|
||||
checkedAt: new Date().toISOString(),
|
||||
issueId: options.issueId,
|
||||
baseUrl: options.baseUrl,
|
||||
status: finalStatus,
|
||||
marker: createdMarker,
|
||||
employeeIdPrefix: maskedId(createdEmployeeId),
|
||||
finalPolicy: "keep_offboarded_and_wecom_disabled_do_not_delete",
|
||||
steps,
|
||||
};
|
||||
|
||||
writeFileSync(join(options.reportDir, "summary.json"), `${JSON.stringify(summary, null, 2)}\n`, "utf8");
|
||||
console.log(`[hrwd-contact-live-test] status=${summary.status} base=${summary.baseUrl} marker=${summary.marker}`);
|
||||
for (const step of steps) {
|
||||
const detail = step.httpStatus ? ` http=${step.httpStatus}` : "";
|
||||
const code = step.code ? ` code=${step.code}` : "";
|
||||
console.log(`[hrwd-contact-live-test] ${step.name} status=${step.status}${detail}${code}`);
|
||||
}
|
||||
console.log(`[hrwd-contact-live-test] report=${options.reportDir}`);
|
||||
}
|
||||
|
||||
if (finalStatus !== "PASS") {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(`[hrwd-contact-live-test][ERROR] ${error instanceof Error ? error.message : String(error)}`);
|
||||
process.exit(1);
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户