文件
hyb-oa/scripts/qa/run-wecom-formal-outing-overtime-evidence.sh
T

352 行
12 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="${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