415 行
14 KiB
Bash
可执行文件
415 行
14 KiB
Bash
可执行文件
#!/usr/bin/env bash
|
|
set -u -o pipefail
|
|
|
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
|
SERVER_DIR="$ROOT_DIR/server"
|
|
PUBLIC_BASE_URL="${LIVE_SMOKE_BASE_URL:-https://oa.hyinda.com}"
|
|
REPORT_DIR="${WTCLOSE2_REPORT_DIR:-$ROOT_DIR/artifacts/qa-flow/wecom-test-account-final-closeout-$(date +%Y%m%d-%H%M%S)}"
|
|
SUMMARY_TXT="$REPORT_DIR/summary.txt"
|
|
STATUS=0
|
|
FINDING_COUNT=0
|
|
|
|
usage() {
|
|
cat <<'EOF'
|
|
Usage:
|
|
bash scripts/qa/run-wecom-test-account-final-closeout.sh --remote
|
|
|
|
WTCLOSE2 must run on the deployment server because Enterprise WeChat API access is
|
|
restricted by trusted IP. Local execution is intentionally blocked; use bash -n
|
|
for syntax/static checks.
|
|
EOF
|
|
}
|
|
|
|
run_remote() {
|
|
local deploy_server="${DEPLOY_SERVER:-deploy@118.89.77.218}"
|
|
local remote_dir="${REMOTE_DIR:-/opt/hyb-oa-trialrun}"
|
|
local remote_env_file="${REMOTE_ENV_FILE:-${remote_dir}/deploy/trialrun/.env}"
|
|
local compose_file="${COMPOSE_FILE:-deploy/trialrun/docker-compose.yml}"
|
|
local public_base_url="${LIVE_SMOKE_BASE_URL:-$PUBLIC_BASE_URL}"
|
|
|
|
ssh "$deploy_server" \
|
|
"REMOTE_DIR='${remote_dir}' REMOTE_ENV_FILE='${remote_env_file}' COMPOSE_FILE='${compose_file}' LIVE_SMOKE_BASE_URL='${public_base_url}' bash -s" <<'REMOTE'
|
|
set -euo pipefail
|
|
cd "$REMOTE_DIR"
|
|
if docker compose version >/dev/null 2>&1; then
|
|
compose_cmd=(docker compose)
|
|
else
|
|
compose_cmd=(docker-compose)
|
|
fi
|
|
"${compose_cmd[@]}" -f "$COMPOSE_FILE" --env-file "$REMOTE_ENV_FILE" exec -T server sh -lc \
|
|
"cd /app && LIVE_SMOKE_BASE_URL='$LIVE_SMOKE_BASE_URL' WTCLOSE2_EXECUTION_MODE=remote WTCLOSE2_REMOTE_CONTEXT=deployment-server bash scripts/qa/run-wecom-test-account-final-closeout.sh"
|
|
REMOTE
|
|
}
|
|
|
|
case "${1:-}" in
|
|
--remote)
|
|
run_remote
|
|
exit $?
|
|
;;
|
|
--help|-h)
|
|
usage
|
|
exit 0
|
|
;;
|
|
"")
|
|
;;
|
|
*)
|
|
printf '[wtclose2][ERROR] unknown argument: %s\n' "$1" >&2
|
|
usage >&2
|
|
exit 2
|
|
;;
|
|
esac
|
|
|
|
if [[ "${WTCLOSE2_EXECUTION_MODE:-}" != "remote" || "${WTCLOSE2_REMOTE_CONTEXT:-}" != "deployment-server" ]]; then
|
|
printf '[wtclose2][ERROR] Enterprise WeChat API checks must run on the deployment server trusted IP. Use --remote.\n' >&2
|
|
usage >&2
|
|
exit 2
|
|
fi
|
|
|
|
mkdir -p "$REPORT_DIR"
|
|
: >"$SUMMARY_TXT"
|
|
|
|
log() {
|
|
printf '%s\n' "$*" | tee -a "$SUMMARY_TXT"
|
|
}
|
|
|
|
run_step() {
|
|
local name="$1"
|
|
shift
|
|
local log_file="$REPORT_DIR/${name}.log"
|
|
|
|
log ">>> [wtclose2] $name"
|
|
if "$@" > >(tee "$log_file") 2>&1; then
|
|
log "[wtclose2][PASS] $name"
|
|
else
|
|
local code="$?"
|
|
log "[wtclose2][FAIL] $name exit=$code"
|
|
STATUS=1
|
|
fi
|
|
}
|
|
|
|
run_finding_step() {
|
|
local name="$1"
|
|
shift
|
|
local log_file="$REPORT_DIR/${name}.log"
|
|
|
|
log ">>> [wtclose2] $name"
|
|
if "$@" > >(tee "$log_file") 2>&1; then
|
|
log "[wtclose2][PASS] $name"
|
|
else
|
|
local code="$?"
|
|
log "[wtclose2][FINDING] $name exit=$code"
|
|
FINDING_COUNT=$((FINDING_COUNT + 1))
|
|
fi
|
|
}
|
|
|
|
run_server_finding_step() {
|
|
local name="$1"
|
|
shift
|
|
local log_file="$REPORT_DIR/${name}.log"
|
|
|
|
log ">>> [wtclose2] $name"
|
|
if (cd "$SERVER_DIR" && "$@") > >(tee "$log_file") 2>&1; then
|
|
log "[wtclose2][PASS] $name"
|
|
else
|
|
local code="$?"
|
|
log "[wtclose2][FINDING] $name exit=$code"
|
|
FINDING_COUNT=$((FINDING_COUNT + 1))
|
|
fi
|
|
}
|
|
|
|
run_ready() {
|
|
run_step "public-ready" curl -fsS "$PUBLIC_BASE_URL/ready"
|
|
}
|
|
|
|
write_day_report_index() {
|
|
node - "$ROOT_DIR" "$REPORT_DIR" <<'NODE' || true
|
|
const fs = require("node:fs");
|
|
const path = require("node:path");
|
|
const [rootDir, reportDir] = process.argv.slice(2);
|
|
const qaRoot = path.join(rootDir, "artifacts/qa-flow");
|
|
|
|
function readJson(file) {
|
|
try {
|
|
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function collect(prefix) {
|
|
let dirs = [];
|
|
try {
|
|
dirs = fs.readdirSync(qaRoot, { withFileTypes: true })
|
|
.filter((entry) => entry.isDirectory() && entry.name.startsWith(prefix))
|
|
.map((entry) => path.join(qaRoot, entry.name, "summary.json"))
|
|
.filter((file) => fs.existsSync(file))
|
|
.sort()
|
|
.slice(-8);
|
|
} catch {}
|
|
return dirs.map((file) => {
|
|
const json = readJson(file);
|
|
return {
|
|
status: json?.status || json?.conclusion || "UNKNOWN",
|
|
conclusion: json?.conclusion || null,
|
|
checkedAt: json?.checkedAt || json?.generatedAt || null,
|
|
reportPath: path.resolve(file),
|
|
};
|
|
});
|
|
}
|
|
|
|
const index = {
|
|
issueId: "WTCLOSE2",
|
|
generatedAt: new Date().toISOString(),
|
|
wroad2Reports: collect("wroad2-ops-"),
|
|
warch3Reports: collect("warch3-ops-"),
|
|
wauto1Reports: collect("wecom-controlled-trial-evidence-"),
|
|
wfullsmokeReports: collect("three-day-full-goal-smoke-"),
|
|
wext1DryRunReports: collect("wecom-approval-fixtures-"),
|
|
wrule1Reports: collect("wecom-approval-rule-validator-"),
|
|
};
|
|
fs.writeFileSync(path.join(reportDir, "day-report-index.json"), `${JSON.stringify(index, null, 2)}\n`, "utf8");
|
|
NODE
|
|
}
|
|
|
|
run_wauto1() {
|
|
run_finding_step "wauto1-evidence" env \
|
|
WAUTO1_REPORT_DIR="$REPORT_DIR/wecom-controlled-trial-evidence" \
|
|
WAUTO1_EXECUTION_MODE=remote \
|
|
WAUTO1_REMOTE_CONTEXT=deployment-server \
|
|
LIVE_SMOKE_BASE_URL="$PUBLIC_BASE_URL" \
|
|
bash "$ROOT_DIR/scripts/qa/collect-wecom-controlled-trial-evidence.sh"
|
|
}
|
|
|
|
run_wfullsmoke1() {
|
|
run_finding_step "wfullsmoke1-no-public-writes" env \
|
|
WFULLSMOKE_REPORT_DIR="$REPORT_DIR/three-day-full-goal-smoke" \
|
|
WFULLSMOKE_EXECUTION_MODE=remote \
|
|
WFULLSMOKE_REMOTE_CONTEXT=deployment-server \
|
|
WFULLSMOKE_ALLOW_PUBLIC_WRITES=0 \
|
|
LIVE_SMOKE_BASE_URL="$PUBLIC_BASE_URL" \
|
|
bash "$ROOT_DIR/scripts/qa/run-three-day-full-goal-smoke.sh"
|
|
}
|
|
|
|
run_wext1ld_dryrun() {
|
|
run_server_finding_step "outing-overtime-dryrun" npm run check:wecom-approval-fixtures -- \
|
|
--format=json \
|
|
--target=outing \
|
|
--target=overtime \
|
|
--report-dir="$REPORT_DIR/wecom-approval-fixtures-dryrun"
|
|
}
|
|
|
|
run_wrule1() {
|
|
run_server_finding_step "approval-rule-validator" npm run check:wecom-approval-rule-validator -- \
|
|
--format=json \
|
|
--template=leave \
|
|
--template=missed_punch \
|
|
--template=outing \
|
|
--template=overtime \
|
|
--manual-gate=all:A \
|
|
--limit=50 \
|
|
--report-dir="$REPORT_DIR/wecom-approval-rule-validator"
|
|
}
|
|
|
|
run_privacy_smoke() {
|
|
local sensitive_pattern='(["'\'']?(WECOM_[A-Z0-9_]*(SECRET|TOKEN)|[A-Z0-9_]*(TOKEN|SECRET|PASSWORD|ENCODING_AES_KEY|ENCODINGAESKEY|CORP_SECRET|ACCESS_TOKEN)[A-Z0-9_]*)["'\'']?[[:space:]]*[:=][[:space:]]*["'\'']?[A-Za-z0-9._:/+=-]{8,}["'\'']?)|Authorization:[[:space:]]*Bearer[[:space:]]+[A-Za-z0-9._-]{8,}|access_token=[A-Za-z0-9._-]{8,}|1[3-9][0-9]{9}|[[:alnum:]._%+-]+@[[:alnum:].-]+\.[[:alpha:]]{2,}|-----BEGIN|BEGIN [A-Z ]*KEY|"[^"]*(token|secret|password|encodingAESKey|encoding_aes_key|sp_no|spNo|template_id|templateId|userid|userId|external_userid|externalUserId|external_user_id)[^"]*"[[:space:]]*:[[:space:]]*"[^"]{8,}"|\b20[0-9]{12,}\b|"(raw|rawPayload|raw_payload|payload|xml|ciphertext|Encrypt|formValue|formValues|attachments|comments)"[[:space:]]*:'
|
|
local safe_metadata_pattern='"[^"]*(secret|token)[^"]*"[[:space:]]*:[[:space:]]*"WECOM_[A-Z0-9_]*(SECRET|TOKEN)([[:space:]]|["]|$)|"[^"]*(secret|token)[^"]*"[[:space:]]*:[[:space:]]*"WECOM_[A-Z0-9_]*(SECRET|TOKEN)[^"]*(source|path|read paths|fallback)'
|
|
local safe_redacted_prefix_pattern='"[^"]*(sp_no|spNo|template_id|templateId|userid|userId|external_userid|externalUserId|external_user_id)[^"]*Prefixes?"[[:space:]]*:[[:space:]]*"[^"]{1,16}\*\*\*[^"]{0,8}"'
|
|
local matches
|
|
matches="$(grep -RInEi "$sensitive_pattern" "$REPORT_DIR" | grep -vE 'HRWDTEST-[0-9]{14}' | grep -vEi "$safe_metadata_pattern" | grep -vEi "$safe_redacted_prefix_pattern" || true)"
|
|
if [[ -n "$matches" ]]; then
|
|
printf '%s\n' "$matches"
|
|
log "[wtclose2][FAIL] report-privacy-smoke matched sensitive pattern"
|
|
STATUS=1
|
|
else
|
|
log "[wtclose2][PASS] report-privacy-smoke"
|
|
fi
|
|
}
|
|
|
|
write_summary_json() {
|
|
node - "$ROOT_DIR" "$REPORT_DIR" "$PUBLIC_BASE_URL" "$STATUS" "$FINDING_COUNT" <<'NODE' || return 1
|
|
const fs = require("node:fs");
|
|
const path = require("node:path");
|
|
const { execSync } = require("node:child_process");
|
|
|
|
const [rootDir, reportDir, baseURL, statusInput, findingCountInput] = process.argv.slice(2);
|
|
|
|
function readJson(file) {
|
|
if (!file || !fs.existsSync(file)) return null;
|
|
try {
|
|
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function child(relativePath) {
|
|
const file = path.join(reportDir, relativePath, "summary.json");
|
|
const json = readJson(file);
|
|
return {
|
|
status: json?.status || json?.conclusion || "MISSING",
|
|
conclusion: json?.conclusion || null,
|
|
reportPath: fs.existsSync(file) ? path.resolve(file) : null,
|
|
findingCount: Number(json?.findingCount || 0),
|
|
};
|
|
}
|
|
|
|
function safeStatus(value) {
|
|
return String(value || "UNKNOWN").slice(0, 80);
|
|
}
|
|
|
|
function isBlocked(value) {
|
|
return /SKIP|BLOCKED|FAIL|ERROR/i.test(value);
|
|
}
|
|
|
|
function isFinding(value) {
|
|
return /FINDING/i.test(value);
|
|
}
|
|
|
|
function isManualGap(value) {
|
|
return /PAUSED|PARTIAL|MISSING|NEEDS/i.test(value);
|
|
}
|
|
|
|
let commit = "unknown";
|
|
try {
|
|
commit = execSync("git rev-parse --short HEAD", { cwd: rootDir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
} catch {}
|
|
|
|
const dayReportIndex = readJson(path.join(reportDir, "day-report-index.json")) || {};
|
|
const childReports = {
|
|
publicReady: {
|
|
status: Number(statusInput || 0) === 0 ? "PASS" : "FAIL",
|
|
reportPath: path.resolve(path.join(reportDir, "public-ready.log")),
|
|
},
|
|
wauto1: child("wecom-controlled-trial-evidence"),
|
|
wfullsmoke1: child("three-day-full-goal-smoke"),
|
|
outingOvertimeDryRun: child("wecom-approval-fixtures-dryrun"),
|
|
wrule1: child("wecom-approval-rule-validator"),
|
|
};
|
|
|
|
const findings = [];
|
|
for (const [source, report] of Object.entries(childReports)) {
|
|
const status = safeStatus(report.status);
|
|
if (status === "PASS" || /^PASS_/.test(status)) {
|
|
if (isFinding(status) || Number(report.findingCount || 0) > 0) {
|
|
findings.push({ source, status, severity: "finding", reportPath: report.reportPath });
|
|
}
|
|
continue;
|
|
}
|
|
if (isBlocked(status)) {
|
|
findings.push({ source, status, severity: "blocker", reportPath: report.reportPath });
|
|
} else if (isManualGap(status)) {
|
|
findings.push({ source, status, severity: "manual_or_data_gap", reportPath: report.reportPath });
|
|
} else {
|
|
findings.push({ source, status, severity: "finding", reportPath: report.reportPath });
|
|
}
|
|
}
|
|
|
|
const manualGates = [
|
|
{
|
|
id: "WAPPR1",
|
|
status: "PASS_APPROVER_RULES_READY",
|
|
evidence: "2026-05-21 用户真机确认请假、补卡、外出、加班均为 A",
|
|
},
|
|
{
|
|
id: "WROAD2-BIZ1",
|
|
status: "NEEDS_CLOSEOUT_SUMMARY",
|
|
evidence: "三天打卡、请假/补卡、详情后审批和 OA 历史查看需随最终验收确认",
|
|
},
|
|
{
|
|
id: "WEXT1-FORMAL",
|
|
status: "PENDING_USER_APPROVAL",
|
|
evidence: "远端收口可解释通过后仍需单独问答批准 SMOKE002 人工提交正式模板外出/加班测试单;系统只读校验",
|
|
},
|
|
{
|
|
id: "WREAL1",
|
|
status: "PENDING_USER_APPROVAL",
|
|
evidence: "测试号证据和 WRULE1 可解释通过后,再问 1 名真实员工 + 1 名审批人/管理员观察",
|
|
},
|
|
];
|
|
|
|
let status = "PASS_TEST_ACCOUNT_CLOSEOUT_READY";
|
|
if (Number(statusInput || 0) !== 0 || findings.some((item) => item.severity === "blocker")) {
|
|
status = "SKIP/BLOCKED_BY_EXTERNAL_CONFIG";
|
|
} else if (findings.some((item) => item.severity === "manual_or_data_gap") || manualGates.some((gate) => gate.status.startsWith("NEEDS"))) {
|
|
status = "PAUSED_MANUAL_CONFIRMATION";
|
|
} else if (findings.length || Number(findingCountInput || 0) > 0) {
|
|
status = "PASS_WITH_FINDINGS";
|
|
}
|
|
|
|
const summary = {
|
|
issueId: "WTCLOSE2",
|
|
mode: "wecom-test-account-final-closeout",
|
|
status,
|
|
conclusion: status,
|
|
baseURL,
|
|
commit,
|
|
executionMode: process.env.WTCLOSE2_EXECUTION_MODE || "unknown",
|
|
trustedIpBoundary: "Enterprise WeChat API checks must run on the deployment server; local runs are blocked by default",
|
|
checkedAt: new Date().toISOString(),
|
|
reportDir: path.resolve(reportDir),
|
|
accounts: {
|
|
applicant: "SMOKE002",
|
|
approver: "SMOKE101",
|
|
adminObserverEmployeeNo: "HYD000006",
|
|
},
|
|
childReports,
|
|
sourceReports: {
|
|
dayReportIndex: path.resolve(path.join(reportDir, "day-report-index.json")),
|
|
wroad2Reports: dayReportIndex.wroad2Reports || [],
|
|
warch3Reports: dayReportIndex.warch3Reports || [],
|
|
},
|
|
manualGates,
|
|
findingCount: findings.length,
|
|
findings,
|
|
boundaries: {
|
|
noWeComWrites: true,
|
|
noWeComTemplateCreation: true,
|
|
noWeComApprovalSubmission: true,
|
|
noRealEmployees: true,
|
|
noOutingOvertimeLive: true,
|
|
wfullsmokePublicWrites: false,
|
|
onlyAllowedOaWrites: "existing WARCH3/WCHECK1 archive paths when invoked by child runners",
|
|
},
|
|
nextQuestions: {
|
|
closeout: [
|
|
"A:接受测试号收口,进入 WEXT1-FORMAL 正式模板人工提交问答。",
|
|
"B:接受 PASS_WITH_FINDINGS,但先处理指定 finding。",
|
|
"C:暂停,不进入正式模板外出/加班测试或真实试用。",
|
|
],
|
|
wext1Formal: [
|
|
"A:批准 SMOKE002 在正式模板提交外出+加班低风险测试单,SMOKE101 先看详情再审批,系统只读校验并保留审计。",
|
|
"B:只测请假/补卡/打卡,不提交外出/加班正式模板测试单。",
|
|
"C:暂停,先检查企微正式模板配置。",
|
|
],
|
|
wreal1: [
|
|
"A:1 名真实员工 + 1 名审批人/管理员观察。",
|
|
"B:继续测试账号,不引入真实员工。",
|
|
"C:暂停,先处理指定 finding。",
|
|
],
|
|
},
|
|
privacy: "status/count/report-path summaries only; no phone/email/token/Secret/EncodingAESKey/full userid/full template_id/full sp_no/form values/attachments/comments/raw payload/XML/ciphertext",
|
|
};
|
|
|
|
fs.writeFileSync(path.join(reportDir, "summary.json"), `${JSON.stringify(summary, null, 2)}\n`, "utf8");
|
|
console.log(`[wtclose2] summary=${path.join(reportDir, "summary.json")}`);
|
|
NODE
|
|
}
|
|
|
|
finish() {
|
|
write_day_report_index
|
|
write_summary_json || STATUS=1
|
|
run_privacy_smoke
|
|
write_summary_json || STATUS=1
|
|
log "[wtclose2] report=$REPORT_DIR"
|
|
exit "$STATUS"
|
|
}
|
|
|
|
trap finish EXIT
|
|
|
|
run_ready
|
|
write_day_report_index
|
|
run_wauto1
|
|
run_wfullsmoke1
|
|
run_wext1ld_dryrun
|
|
run_wrule1
|