#!/usr/bin/env bash set -u -o pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" CLIENT_DIR="$ROOT_DIR/client" PUBLIC_BASE_URL="${LIVE_SMOKE_BASE_URL:-https://oa.hyinda.com}" REPORT_DIR="${OAOPS1_REPORT_DIR:-$ROOT_DIR/artifacts/qa-flow/oa-real-use-daily-$(date +%Y%m%d-%H%M%S)}" SUMMARY_TXT="$REPORT_DIR/summary.txt" STATUS=0 FINDING_COUNT=0 mkdir -p "$REPORT_DIR" : >"$SUMMARY_TXT" log() { printf '%s\n' "$*" | tee -a "$SUMMARY_TXT" } write_step_json() { local name="$1" local status="$2" local kind="$3" local code="${4:-0}" local detail="${5:-}" node - "$ROOT_DIR" "$REPORT_DIR" "$name" "$status" "$kind" "$code" "$detail" <<'NODE' || true const fs = require("node:fs"); const path = require("node:path"); const [rootDir, reportDir, name, status, kind, code, detail] = process.argv.slice(2); function reportRef(file) { const absolute = path.resolve(file); const relative = path.relative(rootDir, absolute); if (relative && !relative.startsWith("..") && !path.isAbsolute(relative)) { return relative; } return path.basename(absolute); } fs.writeFileSync(path.join(reportDir, `${name}.step.json`), `${JSON.stringify({ name, status, kind, code: Number(code || 0), detail: String(detail || "").slice(0, 600), logRef: reportRef(path.join(reportDir, `${name}.log`)), checkedAt: new Date().toISOString(), }, null, 2)}\n`, "utf8"); NODE } run_step() { local name="$1" shift local log_file="$REPORT_DIR/${name}.log" log ">>> [oaops1] $name" if "$@" > >(tee "$log_file") 2>&1; then log "[oaops1][PASS] $name" write_step_json "$name" "PASS" "required" 0 "ok" else local code="$?" log "[oaops1][FAIL] $name exit=$code" STATUS=1 write_step_json "$name" "FAIL" "required" "$code" "required step failed" fi } run_finding_step() { local name="$1" shift local log_file="$REPORT_DIR/${name}.log" log ">>> [oaops1] $name" if "$@" > >(tee "$log_file") 2>&1; then log "[oaops1][PASS] $name" write_step_json "$name" "PASS" "finding_allowed" 0 "ok" else local code="$?" log "[oaops1][FINDING] $name exit=$code" FINDING_COUNT=$((FINDING_COUNT + 1)) write_step_json "$name" "FINDING" "finding_allowed" "$code" "finding or missing runtime credential" fi } run_client_finding_step() { local name="$1" shift local log_file="$REPORT_DIR/${name}.log" log ">>> [oaops1] $name" if (cd "$CLIENT_DIR" && env "$@") > >(tee "$log_file") 2>&1; then log "[oaops1][PASS] $name" write_step_json "$name" "PASS" "finding_allowed" 0 "ok" else local code="$?" log "[oaops1][FINDING] $name exit=$code" FINDING_COUNT=$((FINDING_COUNT + 1)) write_step_json "$name" "FINDING" "finding_allowed" "$code" "finding or missing runtime credential" fi } run_client_spec_if_present() { local name="$1" local spec="$2" shift 2 if [[ ! -f "$CLIENT_DIR/$spec" ]]; then log "[oaops1][FINDING] $name missing client spec: $spec" FINDING_COUNT=$((FINDING_COUNT + 1)) write_step_json "$name" "FINDING" "finding_allowed" 0 "client spec missing: $spec" return 0 fi local cooldown="${OAOPS1_E2E_COOLDOWN_SECONDS:-2}" if [[ "$cooldown" =~ ^[0-9]+$ && "$cooldown" -gt 0 ]]; then sleep "$cooldown" fi run_client_finding_step "$name" "$@" npx playwright test "$spec" --workers=1 --reporter=list } cooldown_login_step() { local reason="$1" local cooldown="${OAOPS1_LOGIN_STEP_COOLDOWN_SECONDS:-65}" if [[ "$cooldown" =~ ^[0-9]+$ && "$cooldown" -gt 0 ]]; then log "[oaops1] login cooldown ${cooldown}s before ${reason}" sleep "$cooldown" fi } load_default_env() { local env_file="${OAOPS1_ENV_FILE:-$HOME/.hyb-oa/live-smoke.env}" if [[ "${OAOPS1_SKIP_ENV_FILE:-0}" == "1" || ! -f "$env_file" ]]; then return 0 fi set -a # shellcheck disable=SC1090 source "$env_file" set +a PUBLIC_BASE_URL="${LIVE_SMOKE_BASE_URL:-$PUBLIC_BASE_URL}" log "[oaops1] loaded masked live smoke env file" } run_ready_and_runtime() { run_step "public-ready" curl -fsS "$PUBLIC_BASE_URL/ready" run_finding_step "live-smoke-env" env LIVE_SMOKE_BASE_URL="$PUBLIC_BASE_URL" node "$ROOT_DIR/scripts/qa/check-live-smoke-env.mjs" --json run_finding_step "live-readiness" env QA_ISSUE_ID=OAOPS1 LIVE_SMOKE_BASE_URL="$PUBLIC_BASE_URL" LIVE_SMOKE_READINESS_DIR="$REPORT_DIR/live-smoke-readiness" bash "$ROOT_DIR/scripts/qa/run-live-smoke-readiness.sh" run_finding_step "oa-only-smoke-readiness" env LIVE_SMOKE_BASE_URL="$PUBLIC_BASE_URL" OA_ONLY_SMOKE_READINESS_REPORT_DIR="$REPORT_DIR/oa-only-smoke-readiness" node "$ROOT_DIR/scripts/qa/check-oa-only-smoke-readiness.mjs" cooldown_login_step "live-feature-flags" run_finding_step "live-feature-flags" env LIVE_SMOKE_BASE_URL="$PUBLIC_BASE_URL" node "$ROOT_DIR/scripts/qa/check-live-feature-flags.mjs" \ --expect payment_batch_v1=force_on \ --expect erp_sync=force_off \ --expect attendance_v1=disabled \ --expect attendance_unified_v1=enabled \ --json } run_authenticated_readonly_probe() { node - "$PUBLIC_BASE_URL" "$REPORT_DIR" <<'NODE' const fs = require("node:fs"); const path = require("node:path"); const [baseUrlRaw, reportDir] = process.argv.slice(2); const baseUrl = String(baseUrlRaw || "https://oa.hyinda.com").replace(/\/$/, ""); const credentials = { admin: { identifier: process.env.LIVE_SMOKE_ADMIN_IDENTIFIER?.trim(), password: process.env.LIVE_SMOKE_ADMIN_PASSWORD?.trim(), }, }; const probes = [ { id: "portal", role: "admin", path: "/api/portal/home", domain: "portal" }, { id: "workflow-tasks", role: "admin", path: "/api/workflow/tasks?page=1&pageSize=5", domain: "approval" }, { id: "reimbursements", role: "admin", path: "/api/reimbursements?page=1&pageSize=5", domain: "reimbursement" }, { id: "reimbursement-summary", role: "admin", path: "/api/reimbursements/stats/summary", domain: "reimbursement" }, { id: "invoice-pool", role: "admin", path: "/api/invoice-pool?page=1&pageSize=5", domain: "invoice_ocr" }, { id: "purchase-requests", role: "admin", path: "/api/purchase-requests?page=1&pageSize=5", domain: "procurement" }, { id: "contracts-registry", role: "admin", path: "/api/contracts-registry", domain: "contracts" }, { id: "projects", role: "admin", path: "/api/projects?page=1&pageSize=5", domain: "projects" }, { id: "audit-logs", role: "admin", path: "/api/audit-logs?page=1&pageSize=5", domain: "audit" }, ]; function maskIdentifier(value) { if (!value) return null; const text = String(value); if (text.length <= 4) return "***"; return `${text.slice(0, 2)}***${text.slice(-2)}`; } async function fetchJson(url, init = {}) { const response = await fetch(url, { ...init, signal: AbortSignal.timeout(20_000), }); const text = await response.text(); let json = null; try { json = text ? JSON.parse(text) : null; } catch { json = null; } return { ok: response.ok, status: response.status, json, text: text.slice(0, 200) }; } function summarizePayload(json) { const data = json?.data; if (Array.isArray(data)) return { itemCount: data.length }; if (Array.isArray(data?.items)) return { itemCount: data.items.length, total: data.total ?? null }; if (typeof data === "object" && data) { const summary = {}; for (const [key, value] of Object.entries(data)) { if (/token|secret|password|phone|email|payload|attachment|comment|form|amount|money|price|cost|balance|bank|receipt|account|invoice|tax|fee|paid|payment/i.test(key)) continue; if (typeof value === "number" && /(count|total|size|days|pending|open|closed|passed|failed)$/i.test(key)) { summary[key] = value; } if (typeof value === "boolean" && /^(enabled|disabled|ready|ok|healthy|configured|available|has[A-Z]|is[A-Z]|can[A-Z])/i.test(key)) { summary[key] = value; } if (typeof value === "string" && /^(status|mode|conclusion)$/i.test(key) && /^[a-z0-9_/-]{1,60}$/i.test(value)) { summary[key] = value; } } return summary; } return {}; } async function login(role) { const credential = credentials[role]; if (!credential?.identifier || !credential?.password) { return { status: "SKIP", reason: `missing_${role}_credentials` }; } const result = await fetchJson(`${baseUrl}/api/auth/login`, { method: "POST", headers: { "content-type": "application/json", "x-device-fingerprint": `oaops1-${role}-readonly`, }, body: JSON.stringify({ identifier: credential.identifier, password: credential.password, }), }); const accessToken = result.json?.data?.accessToken; if (!result.ok || typeof accessToken !== "string" || !accessToken) { return { status: "FAIL", reason: `login_http_${result.status}`, code: result.json?.error?.code ?? null, }; } return { status: "PASS", accessToken, maskedIdentifier: maskIdentifier(credential.identifier), }; } async function main() { const roles = {}; for (const role of Object.keys(credentials)) { roles[role] = await login(role); } const checks = []; for (const probe of probes) { const roleState = roles[probe.role]; if (roleState.status !== "PASS") { checks.push({ ...probe, status: "SKIP", reason: roleState.reason || `${probe.role}_login_not_available`, }); continue; } const result = await fetchJson(`${baseUrl}${probe.path}`, { headers: { accept: "application/json", authorization: `Bearer ${roleState.accessToken}`, }, }); checks.push({ ...probe, status: result.ok ? "PASS" : "FAIL", httpStatus: result.status, summary: result.ok ? summarizePayload(result.json) : { code: result.json?.error?.code ?? null, message: typeof result.json?.error?.message === "string" ? result.json.error.message.slice(0, 100) : null, }, }); } const blocking = checks.filter((item) => item.status === "FAIL"); const skipped = checks.filter((item) => item.status === "SKIP"); const status = blocking.length ? "FAIL" : skipped.length ? "PASS_WITH_FINDINGS" : "PASS"; const summary = { mode: "oaops1-authenticated-readonly-probe", status, baseUrl, checkedAt: new Date().toISOString(), readOnly: true, duplicateRoleChecks: "employee/finance/payment/AP are covered by oa-only-smoke-readiness to avoid repeated login throttling", roles: Object.fromEntries(Object.entries(roles).map(([role, state]) => [role, { status: state.status, reason: state.reason || null, maskedIdentifier: state.maskedIdentifier || null, }])), checks, boundaries: { noBusinessWrites: true, noExportsByDefault: true, noFileUploads: true, noWeComWrites: true, noRealPaymentExecution: true, }, }; fs.writeFileSync(path.join(reportDir, "authenticated-readonly-probe.json"), `${JSON.stringify(summary, null, 2)}\n`, "utf8"); console.log(JSON.stringify(summary, null, 2)); if (blocking.length) process.exit(1); } main().catch((error) => { console.error(`[oaops1-probe][ERROR] ${error instanceof Error ? error.message : String(error)}`); process.exit(1); }); NODE } run_mobile_and_entry_matrix() { run_client_spec_if_present "mobile-entry-matrix" "tests/e2e/r124-mobile-entry-matrix.spec.ts" E2E_BASE_URL="$PUBLIC_BASE_URL" run_client_spec_if_present "wecom-rendering-matrix" "tests/e2e/r6-wecom-mobile-rendering-hardening.spec.ts" E2E_BASE_URL="$PUBLIC_BASE_URL" run_client_spec_if_present "wecom-entry" "tests/e2e/wecom-entry.spec.ts" E2E_BASE_URL="$PUBLIC_BASE_URL" } write_manual_checklist() { cat >"$REPORT_DIR/manual-gates.md" <<'EOF' # OAOPS1 每日人工收口清单 ## 员工 - 真实报销:上传 PDF 发票,补充必要附件,提交 OA 审批。 - 采购/费用:在 OA 创建申请,关联项目;如涉及合同,关联或补录合同台账。 - 合同:只录入合同摘要、编号、负责人、项目、金额、线下归档位置;涉密合同正文、敏感报价和成本细节不上传公网 OA。 ## 审批人 - 从 OA 待办进入详情,确认报销、采购、合同或项目上下文后再审批。 - 退回时写清需要补充的材料,避免线下口头说明替代系统记录。 ## 出纳/财务 - 审批通过后创建付款批次,线下银行渠道付款。 - 上传银行回单附件后再在 OA 标记已付款。 - 每日抽查 AP 台账、报销导出、票据状态和付款批次状态。 ## 管理员 - 查看本报告 summary.json 和人工问题登记。 - 阻断项当天建修复 Issue CSV 或暂停对应流程;非阻断体验问题进入反馈池。 - 确认 payment_batch_v1=force_on,erp_sync=force_off;图片 OCR、预算、ERP、银行流水导入、真实支付直连继续冻结。 EOF } 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|encodingAESKey|sp_no|spNo|template_id|templateId|userid|userId|external_userid|externalUserId))"[[:space:]]*:[[:space:]]*"[^"*]{8,}"|\b20[0-9]{12,}\b|"(raw|rawPayload|raw_payload|payload|xml|ciphertext|Encrypt|formValue|formValues|attachments|comments)"[[:space:]]*:' local matches matches="$(grep -RInE "$sensitive_pattern" "$REPORT_DIR" || true)" if [[ -n "$matches" ]]; then while IFS= read -r line; do local match_file="${line%%:*}" local rest="${line#*:}" local match_line="${rest%%:*}" local match_ref="${match_file#$ROOT_DIR/}" printf '%s:%s:REDACTED\n' "$match_ref" "$match_line" done <<<"$matches" >"$REPORT_DIR/privacy-findings.txt" log "[oaops1][FAIL] report-privacy-smoke matched sensitive pattern" STATUS=1 write_step_json "report-privacy-smoke" "FAIL" "required" 1 "sensitive pattern matched; see privacy-findings.txt" else log "[oaops1][PASS] report-privacy-smoke" write_step_json "report-privacy-smoke" "PASS" "required" 0 "ok" 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 reportRef(file) { const absolute = path.resolve(file); const relative = path.relative(rootDir, absolute); if (relative && !relative.startsWith("..") && !path.isAbsolute(relative)) { return relative; } return path.basename(absolute); } function step(name) { return readJson(path.join(reportDir, `${name}.step.json`)) || { name, status: "MISSING", kind: "unknown", logRef: reportRef(path.join(reportDir, `${name}.log`)), }; } function child(relativePath) { const file = path.join(reportDir, relativePath, "summary.json"); const json = readJson(file); const findingCount = Number(json?.findingCount ?? json?.findings?.length ?? json?.gates?.findings?.length ?? 0); return { status: json?.status || json?.conclusion || "MISSING", reportRef: fs.existsSync(file) ? reportRef(file) : null, findingCount: Number.isFinite(findingCount) ? findingCount : 0, openCount: Number(json?.gates?.openCount ?? 0), }; } let commit = "unknown"; try { commit = execSync("git rev-parse --short HEAD", { cwd: rootDir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); } catch {} const stepNames = [ "public-ready", "live-smoke-env", "live-readiness", "oa-only-smoke-readiness", "live-feature-flags", "authenticated-readonly-probe", "mobile-entry-matrix", "wecom-rendering-matrix", "wecom-entry", "report-privacy-smoke", ]; const steps = Object.fromEntries(stepNames.map((name) => [name, step(name)])); const readonlyProbe = readJson(path.join(reportDir, "authenticated-readonly-probe.json")); const childReports = { liveReadiness: child("live-smoke-readiness"), oaOnlySmokeReadiness: child("oa-only-smoke-readiness"), }; const findings = []; for (const [name, item] of Object.entries(steps)) { const status = String(item.status || "UNKNOWN"); if (status === "PASS") continue; if (status === "FINDING") { findings.push({ source: name, status, severity: "finding" }); } else if (/FAIL|ERROR|BLOCKED/i.test(status)) { findings.push({ source: name, status, severity: item.kind === "required" ? "blocker" : "finding" }); } else if (/SKIP|MISSING|PAUSED|PARTIAL/i.test(status)) { findings.push({ source: name, status, severity: "manual_or_config" }); } } if (readonlyProbe?.status && readonlyProbe.status !== "PASS") { findings.push({ source: "authenticated-readonly-probe", status: readonlyProbe.status, severity: readonlyProbe.status === "FAIL" ? "finding" : "manual_or_config" }); } for (const [name, item] of Object.entries(childReports)) { const status = String(item.status || "UNKNOWN"); if (status === "PASS" && item.findingCount <= 0 && item.openCount <= 0) continue; if (/BLOCKED|FAIL|ERROR/i.test(status)) { findings.push({ source: `child:${name}`, status, severity: "finding", reportRef: item.reportRef }); } else if (/FINDING/i.test(status) || item.findingCount > 0) { findings.push({ source: `child:${name}`, status, severity: "finding", findingCount: item.findingCount, reportRef: item.reportRef }); } else if (/SKIP|MISSING|PAUSED|PARTIAL/i.test(status) || item.openCount > 0) { findings.push({ source: `child:${name}`, status, severity: "manual_or_config", openCount: item.openCount, reportRef: item.reportRef }); } } let status = "PASS_OA_REAL_USE_DAILY_READY"; if (Number(statusInput || 0) !== 0 || findings.some((item) => item.severity === "blocker")) { status = "SKIP/BLOCKED_BY_EXTERNAL_CONFIG"; } else if (findings.length || Number(findingCountInput || 0) > 0) { status = "PASS_WITH_FINDINGS"; } const summary = { issueId: "OAOPS1", mode: "oa-real-use-daily", status, conclusion: status, baseURL, commit, checkedAt: new Date().toISOString(), reportRef: reportRef(reportDir), window: { durationDays: 7, scope: "small-team-all-employees", realBusinessDataAllowed: true, fixPolicy: "blocking_same_day_issue_contract_nonblocking_feedback_pool", }, oaPrimaryDomains: [ "reimbursement_pdf_ocr", "approval_tasks", "procurement_expense", "contracts_registry", "projects", "payment_batches", "bank_receipt_attachments", "ap_accounting_handoff", "mobile_wecom_entry", "audit", ], frozenItems: [ "image_ocr", "complex_bpm", "budget_control", "erp_write", "bank_statement_import", "real_payment_provider", "wecom_hr_deep_mirror", "emergency_takeover", "recovery_writeback", "ruleops", ], boundaries: { defaultReadOnlyAutomation: true, manualRealBusinessWritesOnly: true, paymentBatchExpected: "force_on", erpExpected: "force_off", actualPaymentChannel: "offline_bank_channel", contractSensitiveFiles: "do_not_upload_confidential_body_sensitive_quote_cost_detail_to_public_oa", }, childReports, steps, readonlyProbe, findingCount: findings.length, findings, manualGates: { dailyCloseout: "管理员每日查看 summary.json 和 manual-gates.md,阻断即修,非阻断入反馈池。", day7Closeout: [ "A:接受 PASS_OA_REAL_USE_READY,进入下一轮体验/效率优化。", "B:接受 PASS_WITH_FINDINGS,但先处理指定 finding。", "C:暂停真实使用扩展,先做整改。", ], }, privacy: "status/count/report-path summaries only; no phone/email/token/Secret/EncodingAESKey/full external ids/full invoice image/full bank receipt/full contract body/raw payload", }; fs.writeFileSync(path.join(reportDir, "summary.json"), `${JSON.stringify(summary, null, 2)}\n`, "utf8"); console.log(`[oaops1] summary=${reportRef(path.join(reportDir, "summary.json"))}`); NODE } finish() { write_manual_checklist write_summary_json || STATUS=1 run_privacy_smoke write_summary_json || true local before_second_privacy_status="$STATUS" run_privacy_smoke if [[ "$STATUS" != "$before_second_privacy_status" ]]; then write_summary_json || true fi log "[oaops1] report=$REPORT_DIR" exit "$STATUS" } trap finish EXIT load_default_env run_ready_and_runtime cooldown_login_step "authenticated-readonly-probe" run_finding_step "authenticated-readonly-probe" run_authenticated_readonly_probe run_mobile_and_entry_matrix