文件
hyb-oa/scripts/qa/run-oa-real-use-daily.sh
T

577 行
21 KiB
Bash
可执行文件

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