#!/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