WEXT1F 增加正式模板外出加班取证门禁
这个提交包含在:
@@ -0,0 +1,341 @@
|
||||
#!/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)[^"]*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 "[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 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 = [
|
||||
...report.configGaps.map((item) => item?.reason || item?.id),
|
||||
...report.mainBlockers.map((item) => item?.reason || item?.id),
|
||||
...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: {
|
||||
status: Number(statusInput || 0) === 0 ? "PASS" : "FAIL",
|
||||
reportPath: path.resolve(path.join(reportDir, "public-ready.log")),
|
||||
},
|
||||
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
|
||||
在新工单中引用
屏蔽一个用户