#!/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="${WEXT1F_REPORT_DIR:-$ROOT_DIR/artifacts/qa-flow/wecom-formal-outing-overtime-evidence-$(date +%Y%m%d-%H%M%S)}" SUMMARY_TXT="$REPORT_DIR/summary.txt" STATUS=0 REQUIRED_MANUAL_CONFIRMATION="SMOKE002_SUBMITTED_SMOKE101_REVIEWED" usage() { cat <<'EOF' Usage: bash scripts/qa/run-wecom-formal-outing-overtime-evidence.sh --remote WEXT1-FORMAL 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. This runner assumes SMOKE002 has manually submitted formal outing/overtime approval records in Enterprise WeChat. It does not create templates, submit approvals, or write Enterprise WeChat. It only reads WeCom approval APIs from the deployment server and persists pulled details to the OA archive behind the existing WECOM_ARCHIVE_WRITE=1 + --persist-to-db gate. 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}" local manual_confirmation="" if [[ "${WEXT1F_MANUAL_CONFIRMATION:-}" == "$REQUIRED_MANUAL_CONFIRMATION" ]]; then manual_confirmation="$REQUIRED_MANUAL_CONFIRMATION" fi ssh "$deploy_server" \ "REMOTE_DIR='${remote_dir}' REMOTE_ENV_FILE='${remote_env_file}' COMPOSE_FILE='${compose_file}' LIVE_SMOKE_BASE_URL='${public_base_url}' WEXT1F_MANUAL_CONFIRMATION='${manual_confirmation}' 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' WEXT1F_MANUAL_CONFIRMATION='$WEXT1F_MANUAL_CONFIRMATION' WEXT1F_EXECUTION_MODE=remote WEXT1F_REMOTE_CONTEXT=deployment-server bash scripts/qa/run-wecom-formal-outing-overtime-evidence.sh" REMOTE } case "${1:-}" in --remote) run_remote exit $? ;; --help|-h) usage exit 0 ;; "") ;; *) printf '[wext1f][ERROR] unknown argument: %s\n' "$1" >&2 usage >&2 exit 2 ;; esac if [[ "${WEXT1F_EXECUTION_MODE:-}" != "remote" || "${WEXT1F_REMOTE_CONTEXT:-}" != "deployment-server" ]]; then printf '[wext1f][ERROR] Enterprise WeChat API checks must run on the deployment server trusted IP. Use --remote.\n' >&2 usage >&2 exit 2 fi if [[ "$ROOT_DIR" != "/app" ]]; then printf '[wext1f][ERROR] remote execution must run inside the deployment server container at /app. Use --remote.\n' >&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 ">>> [wext1f] $name" if "$@" > >(tee "$log_file") 2>&1; then log "[wext1f][PASS] $name" else local code="$?" log "[wext1f][FAIL] $name exit=$code" STATUS=1 fi } run_server_step() { local name="$1" shift local log_file="$REPORT_DIR/${name}.log" log ">>> [wext1f] $name" if (cd "$SERVER_DIR" && "$@") > >(tee "$log_file") 2>&1; then log "[wext1f][PASS] $name" else local code="$?" log "[wext1f][FAIL] $name exit=$code" STATUS=1 fi } run_ready() { run_step "public-ready" curl -fsS "$PUBLIC_BASE_URL/ready" } run_formal_compensation_archive() { run_server_step "formal-outing-overtime-compensation-archive" env \ WECOM_ARCHIVE_WRITE=1 \ npm run check:wecom-approval-compensation-poc -- \ --live \ --format=json \ --target=outing \ --target=overtime \ --discover-template-ids \ --discover-applicant-employee-no=SMOKE002 \ --filter-creator-employee-no=SMOKE002 \ --persist-to-db \ --report-dir="$REPORT_DIR/wecom-approval-compensation-poc" } run_archive_offline() { run_server_step "formal-outing-overtime-archive-offline" npm run check:wecom-approval-archive -- \ --offline \ --format=json \ --template=outing \ --template=overtime \ --limit=50 \ --report-dir="$REPORT_DIR/wecom-approval-archive" } run_rule_validator() { run_server_step "formal-outing-overtime-rule-validator" npm run check:wecom-approval-rule-validator -- \ --format=json \ --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)[^"]*Prefix(es)?"[[: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 "[wext1f][FAIL] report-privacy-smoke matched sensitive pattern" STATUS=1 else log "[wext1f][PASS] report-privacy-smoke" fi } write_summary_json() { node - "$ROOT_DIR" "$REPORT_DIR" "$PUBLIC_BASE_URL" "$STATUS" <<'NODE' || return 1 const fs = require("node:fs"); const path = require("node:path"); const { execSync } = require("node:child_process"); const [rootDir, reportDir, baseURL, statusInput] = 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, mainBlockers: Array.isArray(json?.mainBlockers) ? json.mainBlockers : [], configGaps: Array.isArray(json?.configGaps) ? json.configGaps : [], templateDataGaps: Array.isArray(json?.checks?.templateDataGaps) ? json.checks.templateDataGaps : [], findingCount: Number(json?.findingCount || 0), }; } function publicReadyReport() { const file = path.join(reportDir, "public-ready.log"); const body = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : ""; return { status: /"ok"\s*:\s*true/.test(body) ? "PASS" : "MISSING", reportPath: fs.existsSync(file) ? path.resolve(file) : null, mainBlockers: [], configGaps: [], templateDataGaps: [], findingCount: 0, }; } function safeStatus(value) { return String(value || "UNKNOWN").slice(0, 120); } function isPassLike(value) { return /^(PASS|PASS_|PASS_RULE_MATCH|PASS_WITH_ADMIN_FALLBACK|PASS_ARCHIVE_HISTORY_READABLE)/i.test(safeStatus(value)); } function isFindingPass(value) { return /^PASS_WITH_FINDINGS$/i.test(safeStatus(value)); } function isNoDataGap(report) { const reasons = [ ...(Array.isArray(report.configGaps) ? report.configGaps : []).map((item) => item?.reason || item?.id), ...(Array.isArray(report.mainBlockers) ? report.mainBlockers : []).map((item) => item?.reason || item?.id), ...(Array.isArray(report.templateDataGaps) ? report.templateDataGaps : []).map((item) => item?.reason || item?.id), ].filter(Boolean).join(" "); return /no_wecom_approval_archive_rows|no_existing_approval_in_window|archive_history_data|approval_compensation_window_empty/i.test(reasons); } let commit = "unknown"; try { commit = execSync("git rev-parse --short HEAD", { cwd: rootDir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); } catch {} const childReports = { publicReady: publicReadyReport(), compensationArchive: child("wecom-approval-compensation-poc"), archiveOffline: child("wecom-approval-archive"), ruleValidator: child("wecom-approval-rule-validator"), }; const findings = []; let hasBlocker = Number(statusInput || 0) !== 0; let hasManualGap = false; let hasFinding = false; for (const [source, report] of Object.entries(childReports)) { const status = safeStatus(report.status); if (isPassLike(status)) { if (isFindingPass(status) || Number(report.findingCount || 0) > 0) { hasFinding = true; findings.push({ source, status, severity: "finding", reportPath: report.reportPath }); } continue; } if (/PARTIAL|PAUSED|MISSING|NEEDS/i.test(status) || isNoDataGap(report)) { hasManualGap = true; findings.push({ source, status, severity: "manual_or_data_gap", reportPath: report.reportPath }); continue; } hasBlocker = true; findings.push({ source, status, severity: "blocker", reportPath: report.reportPath }); } let status = "PASS_FORMAL_OUTING_OVERTIME_READY"; if (hasBlocker) { status = "SKIP/BLOCKED_BY_EXTERNAL_CONFIG"; } else if (hasManualGap) { status = "PAUSED_MANUAL_CONFIRMATION"; } else if (hasFinding) { status = "PASS_WITH_FINDINGS"; } const summary = { issueId: "WEXT1F", mode: "wecom-formal-outing-overtime-evidence", status, conclusion: status, baseURL, commit, executionMode: process.env.WEXT1F_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, findingCount: findings.length, findings, manualPrerequisites: [ "SMOKE002 has manually submitted formal outing and overtime approval records.", "SMOKE101 has opened detail pages before approving or rejecting.", "WAPPR1 manual gate remains all:A for outing and overtime.", ], manualConfirmation: { required: "SMOKE002_SUBMITTED_SMOKE101_REVIEWED", provided: process.env.WEXT1F_MANUAL_CONFIRMATION === "SMOKE002_SUBMITTED_SMOKE101_REVIEWED", }, boundaries: { noWeComWrites: true, noWeComTemplateCreation: true, noWeComApprovalAutoSubmission: true, noRealEmployees: true, onlyAllowedOaWrites: "approval archive persistence via WECOM_ARCHIVE_WRITE=1 + --persist-to-db", }, nextQuestions: { wreal1: [ "A:1 名真实员工 + 1 名审批人/管理员观察,试用 3 天。", "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(`[wext1f] summary=${path.join(reportDir, "summary.json")}`); NODE } finish() { write_summary_json || STATUS=1 run_privacy_smoke write_summary_json || STATUS=1 log "[wext1f] report=$REPORT_DIR" exit "$STATUS" } trap finish EXIT if [[ "${WEXT1F_MANUAL_CONFIRMATION:-}" != "$REQUIRED_MANUAL_CONFIRMATION" ]]; then log "[wext1f][PAUSED] missing manual confirmation: set WEXT1F_MANUAL_CONFIRMATION=$REQUIRED_MANUAL_CONFIRMATION only after SMOKE002 submitted formal outing/overtime and SMOKE101 reviewed details." exit 0 fi run_ready run_formal_compensation_archive run_archive_offline run_rule_validator