diff --git a/client/src/main.tsx b/client/src/main.tsx index 93d68053..3df21a36 100644 --- a/client/src/main.tsx +++ b/client/src/main.tsx @@ -10,6 +10,8 @@ import { AppRouter } from "./router"; import { clearPwaRuntimeCaches, registerPwaIfEnabled } from "./pwa"; import "./index.css"; +document.documentElement.dataset.buildSha = String(import.meta.env.VITE_BUILD_SHA || "development").slice(0, 12); + const CHUNK_RELOAD_ATTEMPTED_KEY = "hyb_oa_chunk_reload_attempted"; const CHUNK_LOAD_ERROR_PATTERNS = [ "chunkloaderror", diff --git a/client/vite.config.ts b/client/vite.config.ts index 969f2c97..ca4fed42 100644 --- a/client/vite.config.ts +++ b/client/vite.config.ts @@ -1,4 +1,4 @@ -import { defineConfig } from "vite"; +import { defineConfig, loadEnv } from "vite"; import react from "@vitejs/plugin-react"; import { VitePWA } from "vite-plugin-pwa"; @@ -50,7 +50,22 @@ function resolveManualChunk(id: string) { return undefined; } -export default defineConfig({ +function normalizePublicBuildSha(value: string | undefined, fallback: string) { + const normalized = String(value ?? "").trim().toLowerCase(); + return /^[0-9a-f]{7,64}$/.test(normalized) ? normalized.slice(0, 12) : fallback; +} + +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, ".", "VITE_"); + const publicBuildSha = normalizePublicBuildSha( + env.VITE_BUILD_SHA, + mode === "production" ? "unknown" : "development", + ); + + return { + define: { + "import.meta.env.VITE_BUILD_SHA": JSON.stringify(publicBuildSha), + }, plugins: [ react(), VitePWA({ @@ -157,4 +172,5 @@ export default defineConfig({ }, }, }, + }; }); diff --git a/deploy/trialrun/.env.example b/deploy/trialrun/.env.example index 8e1a1c94..dcec09b1 100644 --- a/deploy/trialrun/.env.example +++ b/deploy/trialrun/.env.example @@ -31,7 +31,7 @@ SIGNED_URL_TTL_SECONDS=600 MAX_UPLOAD_SIZE_BYTES=10485760 # ===== 服务端密钥(必须修改) ===== -JWT_SECRET=change_me_in_trialrun +JWT_SECRET= FIELD_ENCRYPTION_KEY= # ===== 密码 / 登录安全 ===== @@ -85,11 +85,11 @@ WECOM_APPROVAL_CALLBACK_EVIDENCE_FILE= IM_NOTIFIER_ENABLED=true # ===== 数据库(Compose 内部连接) ===== -POSTGRES_DB=hyb_oa -POSTGRES_USER=hyboa_user -POSTGRES_PASSWORD=hyboa_password -DATABASE_URL=postgresql://hyboa_user:hyboa_password@db:5432/hyb_oa?schema=public -# SHADOW_DATABASE_URL=postgresql://hyboa_user:hyboa_password@db:5432/hyb_oa_shadow +POSTGRES_DB= +POSTGRES_USER= +POSTGRES_PASSWORD= +DATABASE_URL= +# SHADOW_DATABASE_URL= # ===== 存储与上传 ===== STORAGE_DIR=/data/storage @@ -97,11 +97,11 @@ STORAGE_DRIVER=local S3_ENDPOINT=http://minio:9000 S3_REGION=us-east-1 S3_BUCKET=hyb-oa -S3_ACCESS_KEY_ID=minioadmin -S3_SECRET_ACCESS_KEY=minioadmin +S3_ACCESS_KEY_ID= +S3_SECRET_ACCESS_KEY= S3_FORCE_PATH_STYLE=true -MINIO_ROOT_USER=minioadmin -MINIO_ROOT_PASSWORD=minioadmin +MINIO_ROOT_USER= +MINIO_ROOT_PASSWORD= # ===== OCR(off | mock | tencent) ===== OCR_PROVIDER=mock @@ -154,7 +154,7 @@ INTEGRATION_OUTBOX_RETRY_SECONDS=120 # ===== 邮件 ===== EMAIL_ENABLED=false -EMAIL_FROM=no-reply@hyinda.com +EMAIL_FROM= EMAIL_BASE_URL=https://oa.example.com SMTP_HOST= SMTP_PORT=587 @@ -177,10 +177,10 @@ AI_TIMEOUT_MS=10000 AI_AUTH_TOKEN= # ===== Trialrun 初始化管理员 ===== -TRIALRUN_ADMIN_EMAIL=admin@hyinda.com -TRIALRUN_ADMIN_PHONE=13800000000 +TRIALRUN_ADMIN_EMAIL= +TRIALRUN_ADMIN_PHONE= TRIALRUN_ADMIN_PASSWORD= -TRIALRUN_ADMIN_EMPLOYEE_NO=T0000 +TRIALRUN_ADMIN_EMPLOYEE_NO= TRIALRUN_ADMIN_NAME=Trialrun Admin TRIALRUN_ADMIN_TITLE=系统管理员 TRIALRUN_ADMIN_HIRE_DATE=2020-01-01 diff --git a/deploy/trialrun/check-source-provenance.mjs b/deploy/trialrun/check-source-provenance.mjs new file mode 100644 index 00000000..ff0fec41 --- /dev/null +++ b/deploy/trialrun/check-source-provenance.mjs @@ -0,0 +1,143 @@ +#!/usr/bin/env node +import { spawnSync } from "node:child_process"; +import path from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; + +export class SourceProvenanceError extends Error { + constructor(code) { + super(code); + this.name = "SourceProvenanceError"; + this.code = code; + } +} + +const ALLOWED_UNPUBLISHED_REASONS = new Set([ + "offline-disaster-recovery", + "remote-outage-approved", + "controlled-local-recovery", +]); + +function runGit(rootDir, args) { + return spawnSync("git", args, { + cwd: rootDir, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); +} + +function gitText(rootDir, args, failureCode) { + const result = runGit(rootDir, args); + if (result.status !== 0) { + throw new SourceProvenanceError(failureCode); + } + return result.stdout.trim(); +} + +export function collectSourceProvenance({ rootDir, allowUnpublished = false }) { + const inside = gitText(rootDir, ["rev-parse", "--is-inside-work-tree"], "not_a_git_worktree"); + if (inside !== "true") { + throw new SourceProvenanceError("not_a_git_worktree"); + } + + const dirty = gitText( + rootDir, + ["status", "--porcelain=v1", "--untracked-files=all"], + "git_status_failed", + ); + if (dirty) { + throw new SourceProvenanceError("dirty_worktree"); + } + + const commit = gitText(rootDir, ["rev-parse", "--verify", "HEAD"], "commit_unavailable"); + const branch = gitText(rootDir, ["branch", "--show-current"], "branch_unavailable") || "detached"; + const upstreamResult = runGit(rootDir, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"]); + const upstream = upstreamResult.status === 0 ? upstreamResult.stdout.trim() : null; + if (!upstream && !allowUnpublished) { + throw new SourceProvenanceError("upstream_missing"); + } + + const published = Boolean(upstream) && runGit(rootDir, ["merge-base", "--is-ancestor", commit, upstream]).status === 0; + if (!published && !allowUnpublished) { + throw new SourceProvenanceError("commit_not_in_upstream"); + } + + return { + status: "PASS", + commit, + shortCommit: commit.slice(0, 12), + branch, + upstream, + published, + overrideUsed: !published, + }; +} + +export function refreshUpstreamRef({ rootDir, allowFailure = false }) { + const branch = gitText(rootDir, ["branch", "--show-current"], "branch_unavailable"); + const upstreamResult = runGit(rootDir, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"]); + const upstream = upstreamResult.status === 0 ? upstreamResult.stdout.trim() : null; + if (!upstream) { + if (allowFailure) { + return { refreshed: false, upstream: null, remote: null, code: "upstream_missing" }; + } + throw new SourceProvenanceError("upstream_missing"); + } + + const remoteResult = runGit(rootDir, ["config", "--get", `branch.${branch}.remote`]); + const remote = remoteResult.status === 0 ? remoteResult.stdout.trim() : null; + if (!remote || remote === ".") { + if (allowFailure) { + return { refreshed: false, upstream, remote: null, code: "upstream_remote_missing" }; + } + throw new SourceProvenanceError("upstream_remote_missing"); + } + + const fetchResult = runGit(rootDir, ["fetch", "--quiet", "--prune", remote]); + if (fetchResult.status !== 0) { + if (allowFailure) { + return { refreshed: false, upstream, remote, code: "upstream_refresh_failed" }; + } + throw new SourceProvenanceError("upstream_refresh_failed"); + } + return { refreshed: true, upstream, remote, code: null }; +} + +function parseRootArg(argv) { + const item = argv.find((value) => value.startsWith("--root=")); + if (item) { + return path.resolve(item.slice("--root=".length)); + } + return path.resolve(new URL("../..", import.meta.url).pathname); +} + +function main() { + try { + const rootDir = parseRootArg(process.argv.slice(2)); + const allowUnpublished = process.env.DEPLOY_ALLOW_UNPUBLISHED_COMMIT === "1"; + const overrideReason = String(process.env.DEPLOY_UNPUBLISHED_REASON ?? "").trim(); + if (allowUnpublished && !ALLOWED_UNPUBLISHED_REASONS.has(overrideReason)) { + throw new SourceProvenanceError("unpublished_override_reason_invalid"); + } + + collectSourceProvenance({ rootDir, allowUnpublished: true }); + const refresh = refreshUpstreamRef({ rootDir, allowFailure: allowUnpublished }); + const result = collectSourceProvenance({ rootDir, allowUnpublished }); + console.log(JSON.stringify({ + ...result, + remoteVerified: refresh.refreshed, + upstreamRefreshCode: refresh.code, + overrideRequested: allowUnpublished, + overrideReason: allowUnpublished ? overrideReason : null, + })); + } catch (error) { + const code = error instanceof SourceProvenanceError ? error.code : "unexpected_error"; + console.error(JSON.stringify({ status: "FAIL", code })); + process.exitCode = 1; + } +} + +const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); +if (isMain) { + main(); +} diff --git a/deploy/trialrun/checklist.md b/deploy/trialrun/checklist.md index ce1dcae0..2fca3cc4 100644 --- a/deploy/trialrun/checklist.md +++ b/deploy/trialrun/checklist.md @@ -14,12 +14,12 @@ - [ ] 已将 OA `Caddyfile` 站点块配置到 `/etc/caddy/Caddyfile` - [ ] 已确认 `oa.hyinda.com { reverse_proxy 127.0.0.1:3000 }` 生效 - [ ] 已执行 `systemctl reload caddy` -- [ ] 已从本地执行 `bash scripts/qa/run-release-gate.sh all` 或至少执行 `bash deploy/trialrun/deploy.sh` +- [ ] 已显式提供 `DEPLOY_SERVER`、`REMOTE_DIR`,并从本地执行 `bash scripts/qa/run-release-gate.sh all` 或至少执行 `bash deploy/trialrun/deploy.sh` - [ ] 已通过 `https:///ready` 或首页登录完成最小探活 - [ ] 首次上线先执行系统初始化(migration + seed),不要直接导入旧库整库备份 - [ ] 若要导数据,仅准备最小主数据:部门、员工、角色、在用资产、活跃项目、合同元数据 - [ ] 不导入历史审批单、通知历史、审计日志、聊天记录和涉密正文附件 -- [ ] 已确认远端部署目录 `/opt/hyb-oa-trialrun` 不是 git 工作树,发布优先走 `bash deploy/trialrun/deploy.sh` +- [ ] 已确认远端部署目录不是 git 工作树,发布优先走 Git archive 模式的 `bash deploy/trialrun/deploy.sh` - [ ] 已确认 OA 前端静态资源由 server 容器内 `/app/client/dist` 提供,Caddy 只反代到 `127.0.0.1:3000` ## A. 发布前(Pre-flight) @@ -35,10 +35,10 @@ - [ ] `./scripts/backup-storage.sh`(记录输出文件名) ## B. 发布(Deploy) -- [ ] 在本地仓库切到目标版本:`git checkout `,然后执行 `bash deploy/trialrun/deploy.sh` -- [ ] 构建并启动: - - [ ] `docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env up -d --build server` - - [ ] `docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env run --rm init` +- [ ] 本地工作树 clean,目标 commit 已进入 upstream;部署日志中的 commit 与 `/ready.data.buildSha` 一致 +- [ ] 标准路径已成功刷新 upstream;若因受控离线恢复使用 unpublished override,日志包含批准原因码和 `UNPUBLISHED-OVERRIDE` 标识 +- [ ] 标准发布通过 `bash deploy/trialrun/deploy.sh` 执行;脚本自动注入完整后端 SHA 与 12 位前端 SHA,并完成构建、init 与 smoke +- [ ] 若为首次初始化或受控应急而直接使用 Compose,已显式设置 `BUILD_SHA=` 与 `VITE_BUILD_SHA=`;未使用 `unknown` - [ ] 探活: - [ ] `curl -fsS http://127.0.0.1:3000/ready` - [ ] Runtime smoke: @@ -49,6 +49,7 @@ - [ ] 若 external deps smoke 通过,已确认通过前提不是“企微关闭 + OCR mock”误通过 - [ ] 若启用腾讯 OCR,已脱敏核查 `OCR_PROVIDER=tencent`、`TENCENT_SECRET_ID` 长度、`TENCENT_SECRET_KEY` 长度和 `TENCENT_OCR_REGION` - [ ] 若本次包含前端体验修复,已在容器内确认 `/app/client/dist` 含关键 test id 或公网 asset hash 已更新 +- [ ] 公网 bundle 扫描确认包含匹配的 12 位构建标识,且不包含完整提交 SHA - [ ] 登录验证已由 live smoke 与 release gate 覆盖;仅首次接入或第三方后台配置变更时补做人工确认 ## C. 发布后(Post-check) @@ -77,7 +78,7 @@ ## E. R12-lite 试点切换检查 -- [ ] 已执行 `cd server && DATABASE_URL=postgresql://hyboa_user:hyboa_password@127.0.0.1:5432/hyb_oa_r12?schema=public npm run seed:config` +- [ ] 已执行 `cd server && DATABASE_URL= npm run seed:config` - [ ] 运行环境已打开 R12-lite 所需 env 开关 - [ ] 功能开关页已将 `budget_precheck_v1`、`payment_batch_v1`、`portal_home_v1`、`global_search_v1`、`announcements_v1`、`workflow_definition_admin_v1`、`cutover_console_v1` 切到 `inherit` 或 `force_on` - [ ] 旧 OA 主数据 CSV 已完成 dry-run,结果为 `dry_run_ok` diff --git a/deploy/trialrun/deploy.sh b/deploy/trialrun/deploy.sh index 83a86c06..ac8e674d 100755 --- a/deploy/trialrun/deploy.sh +++ b/deploy/trialrun/deploy.sh @@ -5,7 +5,7 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -DEPLOY_SERVER="${DEPLOY_SERVER:-deploy@118.89.77.218}" +DEPLOY_SERVER="${DEPLOY_SERVER:-}" REMOTE_DIR="${REMOTE_DIR:-/opt/hyb-oa-trialrun}" REMOTE_ENV_FILE="${REMOTE_ENV_FILE:-${REMOTE_DIR}/deploy/trialrun/.env}" ENABLE_WORKER="${ENABLE_WORKER:-0}" @@ -16,6 +16,16 @@ LOCAL_ENV_FILE="deploy/trialrun/.env" ENV_CONTRACT_SCRIPT="deploy/trialrun/validate-env-contract.js" RUNTIME_SMOKE_SCRIPT="server/src/scripts/smoke-trialrun-runtime.js" EXTERNAL_DEPS_SMOKE_SCRIPT="server/src/scripts/smoke-external-deps.js" +PROVENANCE_SCRIPT="deploy/trialrun/check-source-provenance.mjs" +STAGING_DIR="" + +cleanup() { + if [[ -n "$STAGING_DIR" && -d "$STAGING_DIR" ]]; then + rm -rf "$STAGING_DIR" + fi +} + +trap cleanup EXIT need_cmd() { command -v "$1" >/dev/null 2>&1 || { @@ -27,6 +37,34 @@ need_cmd() { need_cmd rsync need_cmd ssh need_cmd node +need_cmd git +need_cmd tar +need_cmd curl +need_cmd mktemp + +if [[ -z "$DEPLOY_SERVER" ]]; then + echo "[ERROR] 必须显式提供 DEPLOY_SERVER" >&2 + exit 1 +fi + +if [[ ! -f "${ROOT_DIR}/${PROVENANCE_SCRIPT}" ]]; then + echo "[ERROR] 缺少 ${PROVENANCE_SCRIPT}" >&2 + exit 1 +fi + +echo ">>> 0. 校验 Git 来源与发布提交" +PROVENANCE_JSON="$(node "${ROOT_DIR}/${PROVENANCE_SCRIPT}")" +SOURCE_COMMIT="$(node -e 'const p=JSON.parse(process.argv[1]); process.stdout.write(p.commit)' "$PROVENANCE_JSON")" +SOURCE_SHORT_COMMIT="$(node -e 'const p=JSON.parse(process.argv[1]); process.stdout.write(p.shortCommit)' "$PROVENANCE_JSON")" +SOURCE_BRANCH="$(node -e 'const p=JSON.parse(process.argv[1]); process.stdout.write(p.branch)' "$PROVENANCE_JSON")" +SOURCE_PUBLISHED="$(node -e 'const p=JSON.parse(process.argv[1]); process.stdout.write(String(p.published))' "$PROVENANCE_JSON")" +SOURCE_REMOTE_VERIFIED="$(node -e 'const p=JSON.parse(process.argv[1]); process.stdout.write(String(p.remoteVerified))' "$PROVENANCE_JSON")" +SOURCE_OVERRIDE_USED="$(node -e 'const p=JSON.parse(process.argv[1]); process.stdout.write(String(p.overrideUsed))' "$PROVENANCE_JSON")" +SOURCE_OVERRIDE_REASON="$(node -e 'const p=JSON.parse(process.argv[1]); process.stdout.write(String(p.overrideReason || ""))' "$PROVENANCE_JSON")" +echo "[deploy-source] commit=${SOURCE_COMMIT} branch=${SOURCE_BRANCH} published=${SOURCE_PUBLISHED} remoteVerified=${SOURCE_REMOTE_VERIFIED}" +if [[ "$SOURCE_OVERRIDE_USED" == "true" || "$SOURCE_REMOTE_VERIFIED" != "true" ]]; then + echo "[deploy-source][UNPUBLISHED-OVERRIDE] reason=${SOURCE_OVERRIDE_REASON} remoteVerified=${SOURCE_REMOTE_VERIFIED}" >&2 +fi if [[ ! -f "${ROOT_DIR}/${COMPOSE_FILE}" ]]; then echo "[ERROR] 缺少 ${COMPOSE_FILE}" >&2 @@ -53,13 +91,17 @@ if [[ "${SYNC_ENV}" == "1" && ! -f "${ROOT_DIR}/${LOCAL_ENV_FILE}" ]]; then exit 1 fi -echo ">>> 0. 校验部署环境契约" +echo ">>> 1. 校验部署环境契约" node "${ROOT_DIR}/${ENV_CONTRACT_SCRIPT}" -echo ">>> 1. 创建远端目录" +echo ">>> 2. 从已验证提交生成不可变同步目录" +STAGING_DIR="$(mktemp -d "${TMPDIR:-/tmp}/hyb-oa-deploy.XXXXXX")" +git -C "$ROOT_DIR" archive --format=tar "$SOURCE_COMMIT" | tar -xf - -C "$STAGING_DIR" + +echo ">>> 3. 创建远端目录" ssh "${DEPLOY_SERVER}" "mkdir -p '${REMOTE_DIR}'" -echo ">>> 2. 同步 OA 仓库到服务器" +echo ">>> 4. 同步已验证提交到服务器" rsync -az --delete \ --exclude '.git' \ --exclude '.github' \ @@ -67,6 +109,7 @@ rsync -az --delete \ --exclude '.agents' \ --exclude '.codex' \ --exclude '.claude' \ + --exclude '.trellis' \ --exclude 'node_modules' \ --exclude 'client/node_modules' \ --exclude 'server/node_modules' \ @@ -81,20 +124,20 @@ rsync -az --delete \ --exclude 'coverage' \ --exclude 'dist' \ --exclude '*.log' \ - "${ROOT_DIR}/" "${DEPLOY_SERVER}:${REMOTE_DIR}/" + "${STAGING_DIR}/" "${DEPLOY_SERVER}:${REMOTE_DIR}/" if [[ "${SYNC_ENV}" == "1" ]]; then - echo ">>> 3. 同步部署环境变量" + echo ">>> 5. 同步部署环境变量" ssh "${DEPLOY_SERVER}" "mkdir -p '$(dirname "${REMOTE_ENV_FILE}")'" rsync -az "${ROOT_DIR}/${LOCAL_ENV_FILE}" "${DEPLOY_SERVER}:${REMOTE_ENV_FILE}" else - echo ">>> 3. 跳过 .env 同步(默认安全模式)" + echo ">>> 5. 跳过 .env 同步(默认安全模式)" echo " 如果你确认本地 deploy/trialrun/.env 可直接上服务器,可用:SYNC_ENV=1 bash deploy/trialrun/deploy.sh" fi -echo ">>> 4. 在服务器执行部署与 smoke" +echo ">>> 6. 在服务器执行部署与 smoke" ssh "${DEPLOY_SERVER}" \ - "REMOTE_DIR='${REMOTE_DIR}' REMOTE_ENV_FILE='${REMOTE_ENV_FILE}' COMPOSE_FILE='${COMPOSE_FILE}' ENABLE_WORKER='${ENABLE_WORKER}' ENABLE_MINIO='${ENABLE_MINIO}' bash -s" <<'REMOTE' + "REMOTE_DIR='${REMOTE_DIR}' REMOTE_ENV_FILE='${REMOTE_ENV_FILE}' COMPOSE_FILE='${COMPOSE_FILE}' ENABLE_WORKER='${ENABLE_WORKER}' ENABLE_MINIO='${ENABLE_MINIO}' BUILD_SHA='${SOURCE_COMMIT}' VITE_BUILD_SHA='${SOURCE_SHORT_COMMIT}' bash -s" <<'REMOTE' set -euo pipefail cd "$REMOTE_DIR" mkdir -p deploy/trialrun/data/postgres deploy/trialrun/data/storage deploy/trialrun/data/minio @@ -147,12 +190,17 @@ if ! "${compose_cmd[@]}" exec -T server sh -lc 'cd /app/server && npx prisma mig exit 1 fi -echo ">>> 4.6 等待 /ready" +echo ">>> 4.6 等待 /ready 并核对构建标识" ready_ok=0 for i in $(seq 1 60); do - if curl -fsS http://127.0.0.1:3000/ready >/dev/null; then - ready_ok=1 - break + if ready_payload="$(curl -fsS http://127.0.0.1:3000/ready 2>/dev/null)"; then + if READY_PAYLOAD="$ready_payload" EXPECTED_BUILD_SHA="${BUILD_SHA:0:12}" node -e ' + const payload = JSON.parse(process.env.READY_PAYLOAD || "{}"); + if (payload?.data?.buildSha !== process.env.EXPECTED_BUILD_SHA) process.exit(1); + '; then + ready_ok=1 + break + fi fi sleep 2 done @@ -190,18 +238,27 @@ for required_key in LIVE_SMOKE_ADMIN_IDENTIFIER LIVE_SMOKE_ADMIN_PASSWORD done if [[ "$LIVE_SMOKE_READY" == "1" ]]; then - echo ">>> 5. 执行部署后 live smoke" + echo ">>> 7. 执行部署后 live smoke" bash "${ROOT_DIR}/scripts/qa/run-web-gates.sh" smoke-live else - echo ">>> 5. 跳过部署后 live smoke(未提供完整 LIVE_SMOKE_* 凭据)" + echo ">>> 7. 跳过部署后 live smoke(未提供完整凭据)" fi -echo ">>> 6. 执行公网前端资源文案扫描" -node "${ROOT_DIR}/scripts/qa/check-public-bundle-copy.mjs" "${LIVE_SMOKE_BASE_URL:-https://oa.hyinda.com}" +PUBLIC_BASE_URL="${LIVE_SMOKE_BASE_URL:-https://oa.hyinda.com}" +echo ">>> 8. 核对公网构建标识" +PUBLIC_READY_PAYLOAD="$(curl -fsS "${PUBLIC_BASE_URL}/ready")" +READY_PAYLOAD="$PUBLIC_READY_PAYLOAD" EXPECTED_BUILD_SHA="$SOURCE_SHORT_COMMIT" node -e ' + const payload = JSON.parse(process.env.READY_PAYLOAD || "{}"); + if (payload?.data?.buildSha !== process.env.EXPECTED_BUILD_SHA) process.exit(1); +' -echo ">>> 7. 部署完成" -echo " 远端目录: ${REMOTE_DIR}" -echo " 环境文件: ${REMOTE_ENV_FILE}" +echo ">>> 9. 执行公网前端资源文案扫描" +PUBLIC_EXPECTED_BUILD_SHA="$SOURCE_SHORT_COMMIT" \ +PUBLIC_FORBIDDEN_BUILD_SHA="$SOURCE_COMMIT" \ +node "${ROOT_DIR}/scripts/qa/check-public-bundle-copy.mjs" "$PUBLIC_BASE_URL" + +echo ">>> 10. 部署完成" +echo " 来源提交: ${SOURCE_COMMIT}" echo " 已自动执行:契约校验 + /ready + external deps smoke + runtime smoke + 公网前端资源文案扫描" echo " 若本地已配置 LIVE_SMOKE_*,部署后会自动补跑网页 live smoke" echo " 请确认 Caddy 或等效反向代理已把 OA 域名反代到 127.0.0.1:3000" diff --git a/deploy/trialrun/docker-compose.yml b/deploy/trialrun/docker-compose.yml index d4199e6c..e9388180 100644 --- a/deploy/trialrun/docker-compose.yml +++ b/deploy/trialrun/docker-compose.yml @@ -55,6 +55,9 @@ services: build: context: ../.. dockerfile: deploy/trialrun/Dockerfile + args: + BUILD_SHA: ${BUILD_SHA:?set BUILD_SHA to the approved full commit} + VITE_BUILD_SHA: ${VITE_BUILD_SHA:?set VITE_BUILD_SHA to the matching short commit} container_name: hyb-oa-trialrun-server restart: unless-stopped depends_on: @@ -64,6 +67,7 @@ services: - ./.env environment: NODE_ENV: production + BUILD_SHA: ${BUILD_SHA:?set BUILD_SHA to the approved full commit} ports: - "3000:3000" volumes: @@ -73,6 +77,9 @@ services: build: context: ../.. dockerfile: deploy/trialrun/Dockerfile + args: + BUILD_SHA: ${BUILD_SHA:?set BUILD_SHA to the approved full commit} + VITE_BUILD_SHA: ${VITE_BUILD_SHA:?set VITE_BUILD_SHA to the matching short commit} container_name: hyb-oa-trialrun-worker restart: unless-stopped profiles: ["worker"] @@ -84,6 +91,7 @@ services: command: ["npm", "run", "worker"] environment: NODE_ENV: production + BUILD_SHA: ${BUILD_SHA:?set BUILD_SHA to the approved full commit} SERVE_CLIENT_DIST: "false" JOBS_ENABLED: "true" volumes: @@ -93,6 +101,9 @@ services: build: context: ../.. dockerfile: deploy/trialrun/Dockerfile + args: + BUILD_SHA: ${BUILD_SHA:?set BUILD_SHA to the approved full commit} + VITE_BUILD_SHA: ${VITE_BUILD_SHA:?set VITE_BUILD_SHA to the matching short commit} container_name: hyb-oa-trialrun-init depends_on: db: @@ -101,6 +112,7 @@ services: - ./.env environment: NODE_ENV: production + BUILD_SHA: ${BUILD_SHA:?set BUILD_SHA to the approved full commit} volumes: - ./data/storage:${STORAGE_DIR} entrypoint: ["sh", "-c"] diff --git a/deploy/trialrun/runbook.md b/deploy/trialrun/runbook.md index 2ca37921..48373025 100644 --- a/deploy/trialrun/runbook.md +++ b/deploy/trialrun/runbook.md @@ -5,21 +5,18 @@ ## 0A. 如果官网 `hyb-web` 已在同机运行(推荐方案) - 官网继续保持 `hyinda.com / www.hyinda.com` 静态托管,不要改官网目录和配置。 - OA 新增独立二级域名,例如 `oa.hyinda.com`。 -- OA 推荐部署目录:`/opt/hyb-oa-trialrun`。 +- OA 部署目录由受控环境中的 `REMOTE_DIR` 指定,不在仓库固化机器路径。 - OA 推荐访问方式:`Caddy(443) -> 127.0.0.1:3000 -> docker-compose(server)`。 - 首次初始化时: 1. 服务器以 root 执行 `bash deploy/trialrun/setup-server.sh` 2. 手工创建 `deploy/trialrun/.env` 3. 手工更新 OA 的 `Caddyfile` 站点块并确认 TLS 正常 - 4. 本地执行 `bash scripts/qa/run-release-gate.sh all`(或至少执行 `bash deploy/trialrun/deploy.sh`) + 4. 本地通过受控配置显式提供 `DEPLOY_SERVER`、`REMOTE_DIR`,再执行 `bash scripts/qa/run-release-gate.sh all`(或至少执行 `bash deploy/trialrun/deploy.sh`) -## 0B. 当前已核实的 `118.89.77.218` 部署基线(2026-05-04) -- SSH 用户: - - 常规部署:`deploy@118.89.77.218` - - 系统配置:`root@118.89.77.218` - - `shunli` 当前不可免密登录 +## 0B. 历史部署基线(基础设施信息已脱敏) +- SSH 目标、部署账号和机器地址只保存在受控运维配置中,通过 `DEPLOY_SERVER` 显式传入。 - 反向代理:`caddy` 正在运行,`oa.hyinda.com` 已反代到 `127.0.0.1:3000` -- 部署目录:`/opt/hyb-oa-trialrun` +- 部署目录通过 `REMOTE_DIR` 显式传入。 - 远端目录当前不是 git 工作树;发布应优先从本地执行 `bash deploy/trialrun/deploy.sh`,由脚本 `rsync` 到服务器 - 远端容器编排当前实际为 `docker-compose 1.29.2`;本 runbook 命令以 `docker-compose` 为准 - 当前公网 `.env` 已确认:`APP_ORIGIN=https://oa.hyinda.com`、`COOKIE_SECURE=true`、`PUBLIC_SECURITY_HARDENING_ENABLED=true` @@ -43,6 +40,7 @@ cp deploy/trialrun/.env.example deploy/trialrun/.env 环境变量约束: - `deploy/trialrun/.env` 现在是 trialrun 部署事实源,`server / worker / init` 统一通过 `env_file` 注入。 +- `BUILD_SHA` / `VITE_BUILD_SHA` 由发布入口按已批准提交临时注入,不写入长期 `.env`;前者供后端运行时核对,后者只允许 12 位短 SHA 进入公网 bundle。 - 推荐使用无引号值,例如 `COOKIE_SECURE=true`、`PORT=3000`。 - 历史上已经写成带引号的 `.env` 当前仍兼容,但建议后续逐步改成无引号。 - 正式部署前可先执行 `node deploy/trialrun/validate-env-contract.js` 做静态契约检查。 @@ -60,10 +58,14 @@ cp deploy/trialrun/.env.example deploy/trialrun/.env ```bash cd /path/to/hyb-oa node deploy/trialrun/validate-env-contract.js +export BUILD_SHA="" +export VITE_BUILD_SHA="" docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env up -d db docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env up -d --build server ``` +以上直接 Compose 命令仅用于首次初始化或受控应急;正式发布优先使用 `deploy/trialrun/deploy.sh`,由脚本从 clean、已进入 upstream 的提交自动注入两个构建标识。缺少任一标识时 Compose 必须拒绝解析。 + 可选:启用 MinIO(对象存储,F21): ```bash cd /path/to/hyb-oa @@ -100,7 +102,9 @@ docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/ ``` 说明: -- `deploy/trialrun/deploy.sh` 现已自动执行 `validate-env-contract.js`、`/ready` 探活、`smoke-external-deps.js` 与 `smoke-trialrun-runtime.js`。 +- `deploy/trialrun/deploy.sh` 会先拒绝脏工作树、确认提交已进入 upstream,再从该提交生成 Git archive;随后自动执行环境契约、带 build SHA 的 `/ready` 核对、external deps smoke 与 runtime smoke。 +- 标准发布会先刷新当前 branch 的 upstream remote-tracking ref,再判断目标提交是否已发布。 +- 只有明确的离线灾备或已批准远端故障场景才可设置 `DEPLOY_ALLOW_UNPUBLISHED_COMMIT=1`,并同时提供 allowlist 原因码 `DEPLOY_UNPUBLISHED_REASON=offline-disaster-recovery|remote-outage-approved|controlled-local-recovery`;部署日志会醒目标记 override,该开关不放宽 clean-tree 要求。 - 若本地存在完整 `LIVE_SMOKE_*` 环境变量,部署脚本会继续执行 `client npm run test:e2e:live`,用于公网只读 smoke。 - 推荐直接执行 `bash scripts/qa/run-release-gate.sh all`,让 static / local smoke / full-local / deploy / live smoke 形成单一放行链。 - 若再出现“数据库开了但功能被环境硬关”的情况,部署会直接失败,不再等到手工点页面才发现。 @@ -231,7 +235,7 @@ curl -fsS http://127.0.0.1:3000/ready 步骤: 1) 先做备份(见 E14 脚本) -2) 从本地仓库执行 `bash deploy/trialrun/deploy.sh`(推荐);该脚本会把当前工作树 `rsync` 到 `/opt/hyb-oa-trialrun`,不依赖远端 `git pull` +2) 从本地仓库执行 `DEPLOY_SERVER= REMOTE_DIR= bash deploy/trialrun/deploy.sh`(推荐);脚本只同步已验证提交的 Git archive,不依赖远端 `git pull`,也不会把本地 ignored 文件带入发布。 - `artifacts/` 和 `output/` 是运行期证据/截图目录,不作为源码发布输入,也不由 `rsync --delete` 清理远端历史证据。 3) 构建并重启: ```bash @@ -310,7 +314,7 @@ docker logs -f hyb-oa-trialrun-server ## 6. R12-lite 试点切换 Runbook ### 6.1 打开本轮功能前置条件 -- 执行 `cd server && DATABASE_URL=postgresql://hyboa_user:hyboa_password@127.0.0.1:5432/hyb_oa_r12?schema=public npm run seed:config`,确保 R12-lite 开关与默认配置落库。 +- 执行 `cd server && DATABASE_URL= npm run seed:config`,确保 R12-lite 开关与默认配置落库。 - 在运行环境启用对应 env:`BUDGET_PRECHECK_ENABLED=true`、`PAYMENT_BATCH_ENABLED=true`、`PORTAL_HOME_ENABLED=true`、`GLOBAL_SEARCH_ENABLED=true`、`ANNOUNCEMENTS_ENABLED=true`、`WORKFLOW_DEFINITION_ADMIN_ENABLED=true`、`CUTOVER_CONSOLE_ENABLED=true`。 - 登录系统后在“功能开关”页将对应开关切到 `inherit` 或 `force_on`;仅设置 env 不会自动放开 DB 中的 `force_off` 记录。 diff --git a/deploy/trialrun/validate-env-contract.js b/deploy/trialrun/validate-env-contract.js index d3c89818..006172dc 100755 --- a/deploy/trialrun/validate-env-contract.js +++ b/deploy/trialrun/validate-env-contract.js @@ -8,10 +8,21 @@ const envExamplePath = path.join(rootDir, 'deploy/trialrun/.env.example'); const composePath = path.join(rootDir, 'deploy/trialrun/docker-compose.yml'); const requiredServices = ['server', 'worker', 'init']; +const deploymentInjectedEnvKeys = new Set(['BUILD_SHA']); const allowedOverrides = { - server: ['NODE_ENV'], - worker: ['NODE_ENV', 'SERVE_CLIENT_DIST', 'JOBS_ENABLED'], - init: ['NODE_ENV'], + server: ['NODE_ENV', 'BUILD_SHA'], + worker: ['NODE_ENV', 'BUILD_SHA', 'SERVE_CLIENT_DIST', 'JOBS_ENABLED'], + init: ['NODE_ENV', 'BUILD_SHA'], +}; +const requiredRuntimeOverrides = { + server: ['BUILD_SHA'], + worker: ['BUILD_SHA'], + init: ['BUILD_SHA'], +}; +const requiredBuildArgs = { + server: ['BUILD_SHA', 'VITE_BUILD_SHA'], + worker: ['BUILD_SHA', 'VITE_BUILD_SHA'], + init: ['BUILD_SHA', 'VITE_BUILD_SHA'], }; function readFile(filePath) { @@ -117,10 +128,15 @@ const envExampleKeys = extractEnvExampleKeys(envExampleContent); const errors = []; for (const key of configKeys) { - if (!envExampleKeys.has(key)) { + if (!deploymentInjectedEnvKeys.has(key) && !envExampleKeys.has(key)) { errors.push(`.env.example 缺少 ${key}`); } } +for (const key of deploymentInjectedEnvKeys) { + if (envExampleKeys.has(key)) { + errors.push(`.env.example 不应持久化部署注入键 ${key}`); + } +} for (const serviceName of requiredServices) { const block = extractServiceBlock(composeContent, serviceName); @@ -138,10 +154,22 @@ for (const serviceName of requiredServices) { const illegalOverrides = environmentKeys.filter( (key) => !(allowedOverrides[serviceName] ?? []).includes(key), ); + const missingRuntimeOverrides = (requiredRuntimeOverrides[serviceName] ?? []).filter( + (key) => !environmentKeys.includes(key), + ); + const missingBuildArgs = (requiredBuildArgs[serviceName] ?? []).filter( + (key) => !new RegExp(`^\\s{8}${key}:`, 'm').test(block), + ); if (illegalOverrides.length) { errors.push(`服务 ${serviceName} 存在未授权 override: ${illegalOverrides.join(', ')}`); } + if (missingRuntimeOverrides.length) { + errors.push(`服务 ${serviceName} 缺少运行时 override: ${missingRuntimeOverrides.join(', ')}`); + } + if (missingBuildArgs.length) { + errors.push(`服务 ${serviceName} 缺少构建参数: ${missingBuildArgs.join(', ')}`); + } } if (errors.length) { diff --git a/docs/测试规约与回归门禁.md b/docs/测试规约与回归门禁.md index d02e95a6..854ee9b0 100644 --- a/docs/测试规约与回归门禁.md +++ b/docs/测试规约与回归门禁.md @@ -110,7 +110,7 @@ R7E 前和后续非微小改动至少从以下组合中按风险选择;进入 | 类别 | 命令或证据 | 口径 | | --- | --- | --- | -| 历史 CSV 兼容 | `python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py ` | 仅当当前任务包含 legacy/export CSV 时校验通过 | +| 历史 CSV 兼容 | `python3 ` | 仅当当前任务包含 legacy/export CSV 时校验通过 | | 前端基础 | `cd client && npm run typecheck`、`cd client && npm run build` | 类型和构建通过,Vite chunk warning 不单独阻断 | | UI 规则脚本 | `node scripts/qa/check-ui-design-rules.mjs --strict` | 当前 UI 静态规则 0 findings;视觉与交互裁决以 `frontend-design` 为准 | | MIF7 移动矩阵 | `cd client && npx playwright test tests/e2e/dg7-mobile-information-dedupe.spec.ts --workers=1 --reporter=list` | 首页、进度/审批、通知、三类详情和财务首页通过;公网时设置 `E2E_BASE_URL=https://oa.hyinda.com` | diff --git a/docs/试运行与自动化验证手册.md b/docs/试运行与自动化验证手册.md index 38aa74ed..0eba3b97 100644 --- a/docs/试运行与自动化验证手册.md +++ b/docs/试运行与自动化验证手册.md @@ -240,9 +240,9 @@ PROJECT_CLOSURE_CHECKLIST_ENABLED=true ## 公网与部署 -当前公网服务器信息以实际配置为准。已知历史信息: +当前公网服务器信息以受控运维配置为准。历史基础设施信息已脱敏: -- 服务器:`118.89.77.218` +- 服务器:由 `DEPLOY_SERVER` 在执行时显式提供,不写入仓库。 - 反代:Caddy - 域名示例:`oa.hyinda.com` diff --git a/docs/需求-资产管理规约.md b/docs/需求-资产管理规约.md index 60c77f8a..bec598e1 100644 --- a/docs/需求-资产管理规约.md +++ b/docs/需求-资产管理规约.md @@ -83,7 +83,7 @@ V1 管理口径以实物责任、流转、盘点和离职交接为准,不按 资产材料上传必须先明确材料类型,例如采购来源证明、领用/归还证明、盘点证据、处置证明、维修记录、电子资料索引或备份验证证据。不同类型适用不同权限、敏感级别、审计和展示位置;合同正文、发票原件、付款回单等业务原文仍走对应业务模块,不进入资产万能附件入口。 -历史目录第一轮只允许从 `/home/shunli/Projects/faculty-daily-assistant/公司/00_保密索引` 读取脱敏索引和元数据,默认 dry-run。公网写入真实索引必须另行确认并带写入开关。 +历史目录第一轮只允许从 `` 读取脱敏索引和元数据,默认 dry-run。公网写入真实索引必须另行确认并带写入开关。 部署服务器空间不足时,不把普通办公电脑共享目录作为生产附件后端。优先顺序为:继续本地存储短期承载授权业务附件、只保存索引并由负责人线下调取原件、再评估腾讯云 COS 或受控 NAS/MinIO。家用群晖 NAS 可作为冷归档、备份或低风险 MinIO 试点;只有具备 VPN、私有 bucket、OA 服务账户、UPS、容量告警和备份恢复演练后,才可扩大使用范围。 diff --git a/scripts/check-minimal.sh b/scripts/check-minimal.sh index f83dc398..4776c290 100755 --- a/scripts/check-minimal.sh +++ b/scripts/check-minimal.sh @@ -3,21 +3,31 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -echo "=== [1/5] Process Contract Gate ===" +echo "=== [1/7] Process Contract Gate ===" ( cd "$ROOT_DIR" node scripts/qa/check-process-contract.mjs --baseline ) echo "" -echo "=== [2/5] Server Prisma Validate ===" +echo "=== [2/7] Governance + Sensitive Content ===" +( + cd "$ROOT_DIR" + node scripts/qa/check-trellis-governance.mjs + node scripts/qa/check-sensitive-content.mjs + bash -n scripts/qa/run-live-smoke-readiness.sh + bash -n deploy/trialrun/deploy.sh +) + +echo "" +echo "=== [3/7] Server Prisma Validate ===" ( cd "$ROOT_DIR/server" npm run prisma:validate ) echo "" -echo "=== [3/5] Server Static Checks ===" +echo "=== [4/7] Server Static Checks ===" ( cd "$ROOT_DIR/server" node --check src/routes/reimbursements.js @@ -69,7 +79,14 @@ echo "=== [3/5] Server Static Checks ===" ) echo "" -echo "=== [4/5] Client Typecheck + Build ===" +echo "=== [5/7] Server Unit Tests ===" +( + cd "$ROOT_DIR/server" + npm test +) + +echo "" +echo "=== [6/7] Client Typecheck + Build ===" ( cd "$ROOT_DIR/client" npm run typecheck @@ -77,7 +94,7 @@ echo "=== [4/5] Client Typecheck + Build ===" ) echo "" -echo "=== [5/5] Feature Matrix Consistency ===" +echo "=== [7/7] Feature Matrix Consistency ===" ( cd "$ROOT_DIR" python3 scripts/validate-feature-matrix.py diff --git a/scripts/qa/check-live-smoke-env.mjs b/scripts/qa/check-live-smoke-env.mjs index 61b85dde..7e9f313f 100755 --- a/scripts/qa/check-live-smoke-env.mjs +++ b/scripts/qa/check-live-smoke-env.mjs @@ -3,6 +3,22 @@ import { existsSync, readdirSync, statSync } from "node:fs"; import { extname } from "node:path"; const baseUrl = (process.env.LIVE_SMOKE_BASE_URL || "https://oa.hyinda.com").trim().replace(/\/$/, ""); +const expectedBuildShaInput = String(process.env.LIVE_SMOKE_EXPECTED_BUILD_SHA ?? "").trim(); + +function normalizeBuildSha(value) { + const normalized = String(value ?? "").trim().toLowerCase(); + if (normalized === "unknown") { + return "unknown"; + } + return /^[0-9a-f]{7,64}$/.test(normalized) ? normalized.slice(0, 12) : null; +} + +function buildShaMatches(expected, observed) { + if (!expected || !observed || observed === "unknown") { + return null; + } + return expected.startsWith(observed) || observed.startsWith(expected); +} function present(key) { return Boolean(process.env[key]?.trim()); @@ -108,7 +124,7 @@ function addMissing(checks) { checks.missing.push("ocrOrEmployeeCredentialReady"); } if (!checks.liveOcr.invoiceSampleReady) { - checks.missing.push("PLAYWRIGHT_OCR_LIVE_FILE 或 PLAYWRIGHT_OCR_LIVE_DIR 中的 PDF 样票"); + checks.missing.push("invoiceSampleReady"); } } @@ -121,12 +137,40 @@ async function main() { const ready = await fetchJson(`${baseUrl}/ready`); const wecomConfig = await fetchJson(`${baseUrl}/api/auth/wecom/config`); const authCapabilities = await fetchJson(`${baseUrl}/api/auth/capabilities`); + const findings = checks.missing.map((code) => ({ code, severity: "manual_or_config" })); + if (!ready.ok) findings.push({ code: "publicReady", severity: "blocker" }); + const expectedBuildSha = normalizeBuildSha(expectedBuildShaInput); + const observedBuildSha = normalizeBuildSha( + ready.json?.data?.buildSha ?? ready.json?.error?.details?.buildSha, + ); + const buildMatches = buildShaMatches(expectedBuildSha, observedBuildSha); + if (ready.ok && (!observedBuildSha || observedBuildSha === "unknown")) { + findings.push({ code: "publicBuildShaUnavailable", severity: "manual_or_config" }); + } else if (ready.ok && expectedBuildSha && buildMatches === false) { + findings.push({ code: "publicBuildShaMismatch", severity: "manual_or_config" }); + } + if (expectedBuildShaInput && !expectedBuildSha) { + findings.push({ code: "expectedBuildShaInvalid", severity: "manual_or_config" }); + } + if (!wecomConfig.ok) findings.push({ code: "wecomConfigReady", severity: "blocker" }); + if (!authCapabilities.ok) findings.push({ code: "authCapabilitiesReady", severity: "blocker" }); + const status = findings.some((finding) => finding.severity === "blocker") + ? "FAIL" + : findings.length + ? "PASS_WITH_FINDINGS" + : "PASS"; const result = { + status, + findingCount: findings.length, + findings, checkedAt: new Date().toISOString(), ...checks, publicReady: { ok: ready.ok, status: ready.status, + expectedBuildSha, + observedBuildSha, + buildShaMatches: buildMatches, }, wecomConfig: { ok: wecomConfig.ok, @@ -135,7 +179,7 @@ async function main() { entryEnabled: wecomConfig.json?.data?.entryEnabled ?? null, oauthEnabled: wecomConfig.json?.data?.oauthEnabled ?? null, defaultReturnTo: wecomConfig.json?.data?.defaultReturnTo ?? null, - loginUrl: wecomConfig.json?.data?.loginUrl ?? null, + loginUrlReady: Boolean(wecomConfig.json?.data?.loginUrl), }, authCapabilities: { ok: authCapabilities.ok, @@ -150,8 +194,10 @@ async function main() { if (jsonOnly) { console.log(JSON.stringify(result, null, 2)); } else { + console.log(`[live-smoke-env] status=${result.status} findings=${result.findingCount}`); console.log(`[live-smoke-env] base=${result.baseUrl}`); console.log(`[live-smoke-env] publicReady=${result.publicReady.ok} status=${result.publicReady.status}`); + console.log(`[live-smoke-env] buildShaObserved=${result.publicReady.observedBuildSha ?? "unavailable"} match=${result.publicReady.buildShaMatches ?? "not_checked"}`); console.log(`[live-smoke-env] wecom=${result.wecomConfig.statusText ?? "unknown"} entry=${result.wecomConfig.entryEnabled} oauth=${result.wecomConfig.oauthEnabled}`); console.log(`[live-smoke-env] authCapabilities=${result.authCapabilities.ok} emailDelivery=${result.authCapabilities.emailDeliveryReady}`); console.log(`[live-smoke-env] liveSmokeReady=${result.liveSmokeReady}`); @@ -161,7 +207,7 @@ async function main() { } } - if (strict && (!result.liveSmokeReady || !result.liveOcrReady || !result.publicReady.ok || !result.wecomConfig.ok || !result.authCapabilities.ok)) { + if (strict && result.status !== "PASS") { process.exit(1); } } diff --git a/scripts/qa/check-pending-ocr.mjs b/scripts/qa/check-pending-ocr.mjs index c372ee66..e8978765 100644 --- a/scripts/qa/check-pending-ocr.mjs +++ b/scripts/qa/check-pending-ocr.mjs @@ -1,78 +1,129 @@ #!/usr/bin/env node +import path from "node:path"; import process from "node:process"; -import { PrismaClient } from "../../server/node_modules/@prisma/client/index.js"; +import { fileURLToPath } from "node:url"; -function parseThresholds() { - const arg = process.argv.find((item) => item.startsWith("--thresholds=")); +export function parseThresholds(argv = process.argv.slice(2)) { + const arg = argv.find((item) => item.startsWith("--thresholds=")); const raw = arg ? arg.slice("--thresholds=".length) : "2,5,10"; - return raw - .split(",") - .map((item) => Number(item.trim())) - .filter((item) => Number.isFinite(item) && item > 0) - .sort((a, b) => a - b); + return Array.from(new Set( + raw + .split(",") + .map((item) => Number(item.trim())) + .filter((item) => Number.isFinite(item) && item > 0) + .map((item) => Math.floor(item)), + )).sort((a, b) => a - b); } -function minutesAgo(minutes) { - return new Date(Date.now() - minutes * 60_000); +function minutesAgo(minutes, now) { + return new Date(now.getTime() - minutes * 60_000); +} + +export async function buildPendingOcrSummary({ + databaseReady, + thresholds, + countPending, + now = new Date(), +}) { + if (!databaseReady) { + return { + mode: "pending-ocr-readiness", + status: "SKIP", + findingCount: 1, + findings: [{ code: "database_not_configured", severity: "manual_or_config" }], + thresholds: [], + checkedAt: now.toISOString(), + }; + } + + const thresholdResults = []; + for (const thresholdMinutes of thresholds) { + const count = Number(await countPending({ + status: "pending_ocr", + created_at: { lte: minutesAgo(thresholdMinutes, now) }, + })); + thresholdResults.push({ + thresholdMinutes, + count: Number.isFinite(count) && count > 0 ? Math.floor(count) : 0, + }); + } + + const findingCount = thresholdResults.reduce((maximum, item) => Math.max(maximum, item.count), 0); + return { + mode: "pending-ocr-readiness", + status: findingCount > 0 ? "PASS_WITH_FINDINGS" : "PASS", + findingCount, + findings: findingCount > 0 + ? [{ code: "pending_ocr_detected", severity: "finding", count: findingCount }] + : [], + thresholds: thresholdResults, + checkedAt: now.toISOString(), + }; +} + +export async function executePendingOcrCheck({ + databaseReady = Boolean(process.env.DATABASE_URL?.trim()), + thresholds = parseThresholds(), +} = {}) { + if (!databaseReady) { + return buildPendingOcrSummary({ databaseReady: false, thresholds, countPending: async () => 0 }); + } + + const prismaClientModule = await import("../../server/src/prismaClient.js"); + const createPrismaClient = prismaClientModule.createPrismaClient + ?? prismaClientModule.default?.createPrismaClient; + if (typeof createPrismaClient !== "function") { + throw new Error("Prisma client factory is unavailable"); + } + + const { prisma, pool } = createPrismaClient(); + try { + return await buildPendingOcrSummary({ + databaseReady: true, + thresholds, + countPending: (where) => prisma.invoicePool.count({ where }), + }); + } finally { + try { + await prisma.$disconnect(); + } finally { + await pool.end(); + } + } +} + +function printSummary(summary, jsonOnly) { + if (jsonOnly) { + console.log(JSON.stringify(summary, null, 2)); + return; + } + console.log(`[pending-ocr][${summary.status}] findings=${summary.findingCount}`); + for (const item of summary.thresholds) { + console.log(`[pending-ocr] thresholdMinutes=${item.thresholdMinutes} count=${item.count}`); + } } async function main() { - if (!process.env.DATABASE_URL) { - console.log("[pending-ocr][SKIP] missing DATABASE_URL"); - return; - } - - const thresholds = parseThresholds(); - const prisma = new PrismaClient(); - + const jsonOnly = process.argv.includes("--json"); try { - const summary = []; - for (const minutes of thresholds) { - const where = { - status: "pending_ocr", - created_at: { lte: minutesAgo(minutes) }, - }; - const count = await prisma.invoicePool.count({ where }); - const rows = await prisma.invoicePool.findMany({ - where, - orderBy: { created_at: "asc" }, - take: 50, - select: { - id: true, - created_at: true, - updated_at: true, - ocr_attempts: true, - ocr_last_requested_at: true, - ocr_last_error: true, - ocr_locked_at: true, - ocr_lock_owner: true, - }, - }); - - summary.push({ - thresholdMinutes: minutes, - count, - sampleLimit: 50, - items: rows.map((item) => ({ - id: item.id, - createdAt: item.created_at?.toISOString?.() ?? null, - updatedAt: item.updated_at?.toISOString?.() ?? null, - attempts: item.ocr_attempts ?? 0, - lastRequestedAt: item.ocr_last_requested_at?.toISOString?.() ?? null, - lastError: item.ocr_last_error ?? null, - lockedAt: item.ocr_locked_at?.toISOString?.() ?? null, - lockOwner: item.ocr_lock_owner ?? null, - })), - }); - } - - console.log(JSON.stringify({ ok: true, thresholds: summary }, null, 2)); - } finally { - await prisma.$disconnect(); + const summary = await executePendingOcrCheck(); + printSummary(summary, jsonOnly); + } catch { + const summary = { + mode: "pending-ocr-readiness", + status: "FAIL", + findingCount: 1, + findings: [{ code: "pending_ocr_check_failed", severity: "blocker" }], + thresholds: [], + checkedAt: new Date().toISOString(), + }; + printSummary(summary, jsonOnly); + console.error("[pending-ocr][FAIL] database check failed"); + process.exitCode = 1; } } -main().catch((error) => { - console.error(`[pending-ocr][FAIL] ${error instanceof Error ? error.message : String(error)}`); - process.exit(1); -}); +const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); +if (isMain) { + await main(); +} diff --git a/scripts/qa/check-public-bundle-copy.mjs b/scripts/qa/check-public-bundle-copy.mjs index 3e2ca410..5a86352b 100644 --- a/scripts/qa/check-public-bundle-copy.mjs +++ b/scripts/qa/check-public-bundle-copy.mjs @@ -4,11 +4,21 @@ import process from "node:process"; const baseUrl = normalizeBaseUrl(process.argv[2] ?? process.env.PUBLIC_BASE_URL ?? process.env.LIVE_SMOKE_BASE_URL ?? "https://oa.hyinda.com"); const focusedAssetPattern = /(MobileReimbursementDetailPage|ReimbursementDetailPage|page-mobile|page-assets|page-travel)/; const forbiddenTerms = ["绑定整单", "不绑定明细", "识别完成后可在", "发票池", "OCR"]; +const expectedBuildSha = normalizeBuildSha(process.env.PUBLIC_EXPECTED_BUILD_SHA); +const forbiddenFullBuildSha = normalizeBuildSha(process.env.PUBLIC_FORBIDDEN_BUILD_SHA, { keepFull: true }); function normalizeBaseUrl(value) { return String(value).replace(/\/+$/, ""); } +function normalizeBuildSha(value, { keepFull = false } = {}) { + const normalized = String(value ?? "").trim().toLowerCase(); + if (!/^[0-9a-f]{7,64}$/.test(normalized)) { + return null; + } + return keepFull ? normalized : normalized.slice(0, 12); +} + function toAssetPath(value) { if (value.startsWith("/")) { return value; @@ -27,6 +37,7 @@ async function fetchText(path) { async function collectAssets() { const seen = new Set(); const queue = []; + const sources = new Map(); const html = await fetchText("/"); for (const match of html.matchAll(/(?:src|href)=["'](\/assets\/[^"']+\.js)["']/g)) { @@ -40,6 +51,7 @@ async function collectAssets() { for (let index = 0; index < queue.length; index += 1) { const assetPath = queue[index]; const source = await fetchText(assetPath); + sources.set(assetPath, source); for (const match of source.matchAll(/\.\/([A-Za-z0-9_./-]+\.js)/g)) { const nextPath = toAssetPath(match[1]); if (!seen.has(nextPath)) { @@ -49,28 +61,56 @@ async function collectAssets() { } } - return Array.from(seen).sort(); + return { + assets: Array.from(seen).sort(), + sources, + }; } -const assets = await collectAssets(); +const { assets, sources } = await collectAssets(); const findings = []; +let expectedBuildShaFound = expectedBuildSha === null; +let forbiddenFullBuildShaFound = false; for (const assetPath of assets) { + const source = sources.get(assetPath) ?? ""; + if (expectedBuildSha && source.toLowerCase().includes(expectedBuildSha)) { + expectedBuildShaFound = true; + } + if ( + forbiddenFullBuildSha + && forbiddenFullBuildSha !== expectedBuildSha + && source.toLowerCase().includes(forbiddenFullBuildSha) + ) { + forbiddenFullBuildShaFound = true; + } + if (!focusedAssetPattern.test(assetPath)) { continue; } - const source = await fetchText(assetPath); for (const term of forbiddenTerms) { if (source.includes(term)) { - findings.push({ assetPath, term }); + findings.push({ assetPath, category: "forbidden_copy", term }); } } } -console.log(`[public-bundle-copy] base=${baseUrl} scanned=${assets.length} focused=${assets.filter((item) => focusedAssetPattern.test(item)).length} findings=${findings.length}`); +if (!expectedBuildShaFound) { + findings.push({ assetPath: "public-bundle", category: "build_sha_missing" }); +} +if (forbiddenFullBuildShaFound) { + findings.push({ assetPath: "public-bundle", category: "full_build_sha_exposed" }); +} + +const buildMarkerStatus = expectedBuildSha === null + ? "NOT_CHECKED" + : expectedBuildShaFound && !forbiddenFullBuildShaFound + ? "PASS" + : "FAIL"; +console.log(`[public-bundle-copy] base=${baseUrl} scanned=${assets.length} focused=${assets.filter((item) => focusedAssetPattern.test(item)).length} buildMarker=${buildMarkerStatus} findings=${findings.length}`); for (const finding of findings) { - console.log(`ERROR ${finding.assetPath} contains ${finding.term}`); + console.log(`ERROR ${finding.assetPath} category=${finding.category}`); } if (findings.length) { diff --git a/scripts/qa/check-sensitive-content.mjs b/scripts/qa/check-sensitive-content.mjs new file mode 100644 index 00000000..129e066f --- /dev/null +++ b/scripts/qa/check-sensitive-content.mjs @@ -0,0 +1,155 @@ +#!/usr/bin/env node +import { existsSync, readFileSync } from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; + +export const DEFAULT_SCAN_FILES = [ + "AGENTS.md", + "README.md", + "server/README.md", + "client/README.md", + "server/.env.example", + "docker-compose.yml", + "deploy/trialrun/deploy.sh", + "deploy/trialrun/.env.example", + "deploy/trialrun/runbook.md", + "deploy/trialrun/checklist.md", + "deploy/trialrun/setup-server.sh", + "docs/Agent开发交付实践.md", + "docs/文档索引.md", + "docs/文档事实源状态矩阵.md", + "docs/产品设计规则.md", + "docs/报销发票附件规则.md", + "docs/试运行与自动化验证手册.md", +]; +const CURRENT_DOCUMENT_REGISTRY = "docs/文档事实源状态矩阵.md"; + +const TEXT_PATTERNS = [ + { + category: "private_key", + pattern: /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/, + }, + { + category: "bearer_value", + pattern: /Authorization:\s*Bearer\s+[A-Za-z0-9._-]{8,}/i, + }, + { + category: "credential_in_url", + pattern: /postgres(?:ql)?:\/\/[^\s:/<>]+:[^\s@<>]+@/i, + }, + { + category: "assigned_secret", + pattern: /\b[A-Z][A-Z0-9_]*(?:PASSWORD|SECRET|TOKEN|API_KEY)[A-Z0-9_]*\b\s*[:=]\s*["']?(?!\s*(?:<|\$\{|\*|""|''|REDACTED|example))[A-Za-z0-9._/+=:-]{8,}/, + }, + { + category: "personal_workspace_path", + pattern: /\/(?:home|Users)\/(?!<)[^/\s`'"()]+\/[A-Za-z0-9._/ -]+/, + }, + { + category: "email_account", + pattern: /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/, + }, + { + category: "phone_account", + pattern: /(? !Number.isInteger(item) || item < 0 || item > 255)) { + return false; + } + if (parts[0] === 10 || parts[0] === 127 || parts[0] === 0) return false; + if (parts[0] === 169 && parts[1] === 254) return false; + if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return false; + if (parts[0] === 192 && parts[1] === 168) return false; + if (parts[0] >= 224) return false; + return true; +} + +export function scanSensitiveText(text, fileRef) { + const findings = []; + String(text).split(/\r?\n/).forEach((line, index) => { + for (const { category, pattern } of TEXT_PATTERNS) { + if (category === "credential_in_url" && (/\$\{/.test(line) || /<[^>]+>/.test(line))) { + continue; + } + if (pattern.test(line)) { + findings.push({ fileRef, line: index + 1, category }); + } + } + const ipv4Values = line.match(/(? cell.trim()); + if (!cells.some((cell) => cell === "`CURRENT`" || cell === "CURRENT")) { + continue; + } + const contractCell = cells[1] ?? ""; + for (const match of contractCell.matchAll(/`(docs\/[^`|]+\.(?:md|csv))`/g)) { + const normalized = path.posix.normalize(match[1]); + if (normalized.startsWith("docs/") && !normalized.includes("..")) { + files.add(normalized); + } + } + } + return [...files].sort(); +} + +export function resolveDefaultScanFiles(rootDir) { + const registryPath = path.join(rootDir, CURRENT_DOCUMENT_REGISTRY); + const currentDocuments = existsSync(registryPath) + ? extractCurrentDocumentFiles(readFileSync(registryPath, "utf8")) + : []; + return [...new Set([...DEFAULT_SCAN_FILES, ...currentDocuments])]; +} + +export function scanSensitiveContent({ rootDir, files }) { + const findings = []; + let filesScanned = 0; + const scanFiles = files ?? resolveDefaultScanFiles(rootDir); + for (const fileRef of scanFiles) { + const file = path.join(rootDir, fileRef); + if (!existsSync(file)) { + continue; + } + filesScanned += 1; + findings.push(...scanSensitiveText(readFileSync(file, "utf8"), fileRef)); + } + return { + status: findings.length ? "FAIL" : "PASS", + findingCount: findings.length, + filesScanned, + findings, + }; +} + +function main() { + const rootDir = path.resolve(new URL("../..", import.meta.url).pathname); + const result = scanSensitiveContent({ rootDir }); + console.log(JSON.stringify(result, null, 2)); + if (result.findingCount > 0) { + process.exitCode = 1; + } +} + +const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); +if (isMain) { + main(); +} diff --git a/scripts/qa/check-trellis-governance.mjs b/scripts/qa/check-trellis-governance.mjs index efb74ce7..f1bebaa5 100644 --- a/scripts/qa/check-trellis-governance.mjs +++ b/scripts/qa/check-trellis-governance.mjs @@ -2,6 +2,7 @@ import fs from "node:fs"; import path from "node:path"; +import { fileURLToPath } from "node:url"; const repoRoot = process.cwd(); @@ -87,17 +88,33 @@ function parseJsonl(relativePath) { }); } -function checkContextManifest(result, taskDir, action) { +export function contextManifestPolicy(dispatchMode) { + const inlineDispatch = dispatchMode === "inline"; + return { + required: !inlineDispatch, + requireRealEntries: !inlineDispatch, + rejectSeedRows: !inlineDispatch, + }; +} + +function checkContextManifest(result, taskDir, action, dispatchMode) { const relativePath = `${taskDir}/${action}.jsonl`; + const policy = contextManifestPolicy(dispatchMode); if (!exists(relativePath)) { - fail(result, `task.${action}_missing`, `${taskDir} missing ${action}.jsonl`); + if (policy.required) { + fail(result, `task.${action}_missing`, `${taskDir} missing ${action}.jsonl`); + } else { + pass(result, `task.${action}_inline_optional`, `${taskDir} uses inline dispatch; ${action}.jsonl is optional`); + } return; } const entries = parseJsonl(relativePath); let realEntryCount = 0; for (const { value, line } of entries) { if (value._example) { - fail(result, `task.${action}_seed`, `${relativePath}:${line} still contains seed _example row`); + if (policy.rejectSeedRows) { + fail(result, `task.${action}_seed`, `${relativePath}:${line} still contains seed _example row`); + } continue; } if (typeof value.file !== "string" || !value.file.trim()) { @@ -117,8 +134,10 @@ function checkContextManifest(result, taskDir, action) { } realEntryCount += 1; } - if (realEntryCount === 0) { + if (realEntryCount === 0 && policy.requireRealEntries) { fail(result, `task.${action}_empty`, `${relativePath} must contain at least one real context entry`); + } else if (realEntryCount === 0) { + pass(result, `task.${action}_inline_optional`, `${relativePath} has no injected context because dispatch_mode=inline`); } } @@ -187,6 +206,8 @@ function checkCoreFiles(result) { function checkTaskContracts(result) { const taskDirs = listTaskDirs(); + const config = exists(".trellis/config.yaml") ? readText(".trellis/config.yaml") : ""; + const dispatchMode = /dispatch_mode:\s*sub-agent\b/.test(config) ? "sub-agent" : "inline"; if (taskDirs.length === 0) { fail(result, "tasks.none", "no .trellis/tasks/* directories found"); return; @@ -227,8 +248,8 @@ function checkTaskContracts(result) { if (complexTaskPattern.test(complexText) && !exists(`${dir}/design.md`) && !exists(`${dir}/implement.md`)) { fail(result, "task.complex_design", `${dir} complex task must include design.md or implement.md`); } - checkContextManifest(result, dir, "implement"); - checkContextManifest(result, dir, "check"); + checkContextManifest(result, dir, "implement", dispatchMode); + checkContextManifest(result, dir, "check", dispatchMode); } if (result.status === "PASS") pass(result, "task_contracts", `${taskDirs.length} task directories have Trellis contracts`); } @@ -296,16 +317,23 @@ function checkSensitiveData(result) { if (result.status === "PASS") pass(result, "sensitive_scan", `${scanFiles.length} Trellis files scanned`); } -const result = createResult(); -try { - checkCoreFiles(result); - checkTaskContracts(result); - checkDocsRegistration(result); - checkRemovedProjectSkillReferences(result); - checkSensitiveData(result); -} catch (error) { - fail(result, "exception", error instanceof Error ? error.message : String(error)); +export function runGovernanceChecks() { + const result = createResult(); + try { + checkCoreFiles(result); + checkTaskContracts(result); + checkDocsRegistration(result); + checkRemovedProjectSkillReferences(result); + checkSensitiveData(result); + } catch (error) { + fail(result, "exception", error instanceof Error ? error.message : String(error)); + } + return result; } -console.log(JSON.stringify(result, null, 2)); -process.exit(result.status === "PASS" ? 0 : 1); +const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); +if (isMain) { + const result = runGovernanceChecks(); + console.log(JSON.stringify(result, null, 2)); + process.exit(result.status === "PASS" ? 0 : 1); +} diff --git a/scripts/qa/live-smoke-readiness-report.mjs b/scripts/qa/live-smoke-readiness-report.mjs new file mode 100644 index 00000000..636b201c --- /dev/null +++ b/scripts/qa/live-smoke-readiness-report.mjs @@ -0,0 +1,293 @@ +#!/usr/bin/env node +import { execFileSync } from "node:child_process"; +import { + existsSync, + mkdirSync, + readFileSync, + readdirSync, + statSync, + writeFileSync, +} from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; + +export const READINESS_STEP_NAMES = [ + "environment", + "publicReady", + "publicBundle", + "pendingOcr", + "liveSmoke", + "liveOcr", + "privacy", +]; + +const CHILD_STEP_NAMES = ["environment", "pendingOcr", "liveSmoke", "liveOcr"]; +const BLOCKING_STATUS_PATTERN = /FAIL|ERROR|BLOCKED/i; +const MANUAL_STATUS_PATTERN = /SKIP|MISSING|PAUSED|PARTIAL|PASS_WITH_FINDINGS/i; + +export function toReportRef(rootDir, file) { + const absolute = path.resolve(file); + const relative = path.relative(path.resolve(rootDir), absolute); + if (relative && !relative.startsWith("..") && !path.isAbsolute(relative)) { + return relative.split(path.sep).join("/"); + } + return path.basename(absolute); +} + +function safeBaseUrl(value) { + try { + const url = new URL(String(value || "")); + url.username = ""; + url.password = ""; + return `${url.origin}${url.pathname.replace(/\/+$/, "")}`; + } catch { + return null; + } +} + +function normalizeFindingCount(status, value) { + const parsed = Number(value || 0); + const count = Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : 0; + return status === "PASS" ? count : Math.max(1, count); +} + +function normalizeStep(name, input = {}) { + const status = String(input.status || "MISSING").toUpperCase(); + return { + name, + status, + kind: input.kind === "required" ? "required" : "finding_allowed", + code: Number(input.code || 0), + findingCount: normalizeFindingCount(status, input.findingCount), + detail: String(input.detail || "").slice(0, 240), + logRef: input.logRef || null, + artifactRef: input.artifactRef || null, + checkedAt: input.checkedAt || null, + }; +} + +export function evaluateReadinessSteps(stepInputs) { + const steps = Object.fromEntries( + READINESS_STEP_NAMES.map((name) => [name, normalizeStep(name, stepInputs?.[name])]), + ); + const findings = []; + + for (const [name, step] of Object.entries(steps)) { + if (step.status === "PASS" && step.findingCount === 0) { + continue; + } + const blocking = step.kind === "required" && BLOCKING_STATUS_PATTERN.test(step.status); + findings.push({ + source: name, + status: step.status, + severity: blocking + ? "blocker" + : MANUAL_STATUS_PATTERN.test(step.status) + ? "manual_or_config" + : "finding", + findingCount: step.findingCount, + reportRef: step.artifactRef || step.logRef, + }); + } + + const findingCount = findings.reduce((total, finding) => total + Math.max(1, finding.findingCount), 0); + const status = findings.some((finding) => finding.severity === "blocker") + ? "FAIL" + : findingCount > 0 + ? "PASS_WITH_FINDINGS" + : "PASS"; + + return { status, findingCount, findings, steps }; +} + +function readJson(file) { + if (!existsSync(file)) { + return null; + } + try { + return JSON.parse(readFileSync(file, "utf8")); + } catch { + return null; + } +} + +function currentCommit(rootDir) { + try { + return execFileSync("git", ["rev-parse", "HEAD"], { + cwd: rootDir, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + } catch { + return "unknown"; + } +} + +export function buildReadinessSummary({ + rootDir, + reportDir, + baseUrl, + commit = currentCommit(rootDir), + checkedAt = new Date().toISOString(), +}) { + const stepInputs = Object.fromEntries( + READINESS_STEP_NAMES.map((name) => [ + name, + readJson(path.join(reportDir, `${name}.step.json`)) || { + status: "MISSING", + kind: "required", + logRef: toReportRef(rootDir, path.join(reportDir, `${name}.log`)), + }, + ]), + ); + const evaluated = evaluateReadinessSteps(stepInputs); + const environmentReport = readJson(path.join(reportDir, "environment.json")); + const childReports = Object.fromEntries( + CHILD_STEP_NAMES.map((name) => { + const step = evaluated.steps[name]; + return [name, { + status: step.status, + findingCount: step.findingCount, + reportRef: step.artifactRef || step.logRef, + }]; + }), + ); + + return { + issueId: process.env.QA_ISSUE_ID?.trim() || null, + mode: "live-smoke-readiness", + status: evaluated.status, + conclusion: evaluated.status, + baseUrl: safeBaseUrl(baseUrl), + commit, + sourceCommit: commit, + expectedBuildSha: environmentReport?.publicReady?.expectedBuildSha ?? null, + observedBuildSha: environmentReport?.publicReady?.observedBuildSha ?? null, + buildShaMatches: environmentReport?.publicReady?.buildShaMatches ?? null, + checkedAt, + reportRef: toReportRef(rootDir, reportDir), + findingCount: evaluated.findingCount, + findings: evaluated.findings, + childReports, + steps: evaluated.steps, + boundaries: { + readOnlyChecksOnly: true, + noCredentialValues: true, + noInvoiceSamplesEmbedded: true, + noAbsoluteWorkspacePaths: true, + }, + }; +} + +function writeStep({ rootDir, reportDir, name, status, kind, code, findingCount, detail, artifactFile }) { + if (!READINESS_STEP_NAMES.includes(name)) { + throw new Error(`unknown readiness step: ${name}`); + } + const step = normalizeStep(name, { + status, + kind, + code, + findingCount, + detail, + logRef: toReportRef(rootDir, path.join(reportDir, `${name}.log`)), + artifactRef: artifactFile ? toReportRef(rootDir, artifactFile) : null, + checkedAt: new Date().toISOString(), + }); + mkdirSync(reportDir, { recursive: true }); + writeFileSync(path.join(reportDir, `${name}.step.json`), `${JSON.stringify(step, null, 2)}\n`, "utf8"); +} + +function collectReportFiles(directory) { + if (!existsSync(directory)) { + return []; + } + const files = []; + for (const entry of readdirSync(directory)) { + const file = path.join(directory, entry); + const stat = statSync(file); + if (stat.isDirectory()) { + files.push(...collectReportFiles(file)); + } else if (/\.(json|log|txt)$/i.test(entry) && entry !== "privacy-findings.json") { + files.push(file); + } + } + return files; +} + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +export function scanReadinessPrivacy({ rootDir, reportDir }) { + const patterns = [ + { category: "workspace_path", pattern: new RegExp(escapeRegExp(path.resolve(rootDir)), "g") }, + { category: "secret_field", pattern: /\b[A-Z][A-Z0-9_]*(?:PASSWORD|SECRET|TOKEN|ENCODING_AES_KEY)[A-Z0-9_]*\b/g }, + { category: "bearer_value", pattern: /Authorization:\s*Bearer\s+[A-Za-z0-9._-]{8,}/gi }, + { category: "private_key", pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----/g }, + { category: "email", pattern: /[\w.%+-]+@[\w.-]+\.[A-Za-z]{2,}/g }, + { category: "phone", pattern: /(? { + for (const { category, pattern } of patterns) { + pattern.lastIndex = 0; + if (pattern.test(line)) { + findings.push({ + fileRef: toReportRef(rootDir, file), + line: index + 1, + category, + }); + } + } + }); + } + + const report = { + status: findings.length ? "FAIL" : "PASS", + findingCount: findings.length, + findings, + checkedAt: new Date().toISOString(), + }; + writeFileSync(path.join(reportDir, "privacy-findings.json"), `${JSON.stringify(report, null, 2)}\n`, "utf8"); + return report; +} + +function usage() { + console.error("Usage: live-smoke-readiness-report.mjs ..."); +} + +function main() { + const [command, ...args] = process.argv.slice(2); + if (command === "write-step") { + const [rootDir, reportDir, name, status, kind, code, findingCount, detail = "", artifactFile = ""] = args; + writeStep({ rootDir, reportDir, name, status, kind, code, findingCount, detail, artifactFile }); + return; + } + if (command === "summarize") { + const [rootDir, reportDir, baseUrl] = args; + const summary = buildReadinessSummary({ rootDir, reportDir, baseUrl }); + writeFileSync(path.join(reportDir, "summary.json"), `${JSON.stringify(summary, null, 2)}\n`, "utf8"); + console.log(JSON.stringify({ status: summary.status, findingCount: summary.findingCount })); + return; + } + if (command === "privacy") { + const [rootDir, reportDir] = args; + const report = scanReadinessPrivacy({ rootDir, reportDir }); + console.log(JSON.stringify({ status: report.status, findingCount: report.findingCount })); + if (report.findingCount > 0) { + process.exitCode = 1; + } + return; + } + usage(); + process.exitCode = 2; +} + +const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); +if (isMain) { + main(); +} diff --git a/scripts/qa/run-live-smoke-readiness.sh b/scripts/qa/run-live-smoke-readiness.sh old mode 100755 new mode 100644 index 5509cc43..74754038 --- a/scripts/qa/run-live-smoke-readiness.sh +++ b/scripts/qa/run-live-smoke-readiness.sh @@ -1,30 +1,114 @@ #!/usr/bin/env bash -set -euo pipefail +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="${LIVE_SMOKE_READINESS_DIR:-$ROOT_DIR/artifacts/qa-flow/live-smoke-readiness-$(date +%Y%m%d-%H%M%S)}" +REPORT_TOOL="$ROOT_DIR/scripts/qa/live-smoke-readiness-report.mjs" +STATUS=0 +SOURCE_COMMIT="$(git -C "$ROOT_DIR" rev-parse HEAD 2>/dev/null || printf 'unknown')" +SOURCE_SHORT_COMMIT="${SOURCE_COMMIT:0:12}" +EXPECTED_BUILD_SHA="${LIVE_SMOKE_EXPECTED_BUILD_SHA:-$SOURCE_SHORT_COMMIT}" +FORBIDDEN_FULL_BUILD_SHA="" +if [[ "$SOURCE_COMMIT" =~ ^[0-9a-fA-F]{40,64}$ && "${EXPECTED_BUILD_SHA:0:12}" == "$SOURCE_SHORT_COMMIT" ]]; then + FORBIDDEN_FULL_BUILD_SHA="$SOURCE_COMMIT" +fi mkdir -p "$REPORT_DIR" -write_summary() { - local exit_code="$?" - local status="PASS" - if [[ "$exit_code" -ne 0 ]]; then - status="FAIL" - fi - QA_REPORT_DIR="$REPORT_DIR" \ - QA_ISSUE_ID="${QA_ISSUE_ID:-}" \ - QA_BASE_URL="$PUBLIC_BASE_URL" \ - QA_MODE="live-smoke-readiness" \ - QA_STATUS="$status" \ - QA_SUMMARY_NOTES="公网 /ready、public bundle、pending OCR、live smoke 和真实 OCR 可用性检查;缺少凭据或样票时按脚本规则记录 SKIP。" \ - node "$ROOT_DIR/scripts/qa/write-qa-summary.mjs" >/dev/null 2>&1 || true - return "$exit_code" +write_step() { + local name="$1" + local status="$2" + local kind="$3" + local code="$4" + local finding_count="$5" + local detail="$6" + local artifact_file="${7:-}" + node "$REPORT_TOOL" write-step \ + "$ROOT_DIR" "$REPORT_DIR" "$name" "$status" "$kind" "$code" "$finding_count" "$detail" "$artifact_file" } -trap write_summary EXIT +apply_step_status() { + local status="$1" + local kind="$2" + if [[ "$kind" == "required" && "$status" =~ FAIL|ERROR|BLOCKED ]]; then + STATUS=1 + fi +} + +run_command_step() { + local name="$1" + local kind="$2" + local detail="$3" + shift 3 + local log_file="$REPORT_DIR/${name}.log" + local code=0 + local status="PASS" + local finding_count=0 + + echo ">>> [live-readiness] $name" + if "$@" > >(tee "$log_file") 2>&1; then + echo "[live-readiness][PASS] $name" + else + code="$?" + status="FAIL" + finding_count=1 + echo "[live-readiness][FAIL] $name exit=$code" + fi + write_step "$name" "$status" "$kind" "$code" "$finding_count" "$detail" "$log_file" + apply_step_status "$status" "$kind" +} + +run_json_step() { + local name="$1" + local kind="$2" + local detail="$3" + local artifact_file="$4" + shift 4 + local log_file="$REPORT_DIR/${name}.log" + local code=0 + + echo ">>> [live-readiness] $name" + if "$@" >"$artifact_file" 2> >(tee "$log_file" >&2); then + code=0 + else + code="$?" + fi + + local parsed + parsed="$(node -e ' + const fs = require("node:fs"); + try { + const value = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); + const status = String(value.status || "FAIL").toUpperCase(); + const count = Number(value.findingCount || 0); + process.stdout.write(`${status}\t${Number.isFinite(count) ? count : 1}`); + } catch { + process.stdout.write("FAIL\t1"); + process.exitCode = 1; + } + ' "$artifact_file")" || true + local status="FAIL" + local finding_count=1 + IFS=$'\t' read -r status finding_count <<<"$parsed" + if [[ "$code" -ne 0 && ! "$status" =~ FAIL|ERROR|BLOCKED ]]; then + status="FAIL" + finding_count=1 + fi + + echo "[live-readiness][$status] $name findings=$finding_count" + write_step "$name" "$status" "$kind" "$code" "$finding_count" "$detail" "$artifact_file" + apply_step_status "$status" "$kind" +} + +write_skip_step() { + local name="$1" + local detail="$2" + local log_file="$REPORT_DIR/${name}.log" + echo "[live-readiness][SKIP] $name:$detail" | tee "$log_file" + write_step "$name" "SKIP" "finding_allowed" 0 1 "$detail" "$log_file" +} has_live_smoke_credentials() { [[ -n "${LIVE_SMOKE_ADMIN_IDENTIFIER:-}" ]] && @@ -54,39 +138,66 @@ has_live_ocr_sample() { return 1 } -echo ">>> [live-readiness] 环境与公网入口检查" -node "$ROOT_DIR/scripts/qa/check-live-smoke-env.mjs" --json > "$REPORT_DIR/env.json" -node "$ROOT_DIR/scripts/qa/check-live-smoke-env.mjs" +run_json_step \ + "environment" \ + "required" \ + "public endpoints and safe readiness labels" \ + "$REPORT_DIR/environment.json" \ + env LIVE_SMOKE_BASE_URL="$PUBLIC_BASE_URL" LIVE_SMOKE_EXPECTED_BUILD_SHA="$EXPECTED_BUILD_SHA" node "$ROOT_DIR/scripts/qa/check-live-smoke-env.mjs" --json -echo ">>> [live-readiness] /ready: $PUBLIC_BASE_URL" -curl -fsS "$PUBLIC_BASE_URL/ready" >/dev/null +run_command_step \ + "publicReady" \ + "required" \ + "public readiness endpoint" \ + curl -fsS "$PUBLIC_BASE_URL/ready" -echo ">>> [live-readiness] 公网资源文案扫描" -node "$ROOT_DIR/scripts/qa/check-public-bundle-copy.mjs" "$PUBLIC_BASE_URL" | tee "$REPORT_DIR/public-bundle-copy.txt" +run_command_step \ + "publicBundle" \ + "required" \ + "public bundle copy and build marker scan" \ + env PUBLIC_EXPECTED_BUILD_SHA="$EXPECTED_BUILD_SHA" PUBLIC_FORBIDDEN_BUILD_SHA="$FORBIDDEN_FULL_BUILD_SHA" node "$ROOT_DIR/scripts/qa/check-public-bundle-copy.mjs" "$PUBLIC_BASE_URL" -echo ">>> [live-readiness] pending OCR 扫描" -node "$ROOT_DIR/scripts/qa/check-pending-ocr.mjs" --thresholds=2,5,10 | tee "$REPORT_DIR/pending-ocr.txt" +run_json_step \ + "pendingOcr" \ + "required" \ + "pending OCR count summary" \ + "$REPORT_DIR/pending-ocr.json" \ + node "$ROOT_DIR/scripts/qa/check-pending-ocr.mjs" --thresholds=2,5,10 --json if has_live_smoke_credentials; then - echo ">>> [live-readiness] 执行公网 live smoke" - LIVE_SMOKE_BASE_URL="$PUBLIC_BASE_URL" bash "$ROOT_DIR/scripts/qa/run-web-gates.sh" smoke-live | tee "$REPORT_DIR/live-smoke.txt" + run_command_step \ + "liveSmoke" \ + "required" \ + "authenticated public smoke" \ + env LIVE_SMOKE_BASE_URL="$PUBLIC_BASE_URL" bash "$ROOT_DIR/scripts/qa/run-web-gates.sh" smoke-live else - echo ">>> [live-readiness] SKIP live smoke:缺少完整 LIVE_SMOKE_* 凭据" | tee "$REPORT_DIR/live-smoke.txt" + write_skip_step "liveSmoke" "authenticated smoke prerequisites are incomplete" fi if has_live_ocr_credentials && has_live_ocr_sample; then - echo ">>> [live-readiness] 执行真实 OCR 样本复核" - ( - cd "$CLIENT_DIR" - PLAYWRIGHT_MANUAL=1 \ - PLAYWRIGHT_OCR_LIVE=1 \ - PLAYWRIGHT_OCR_ALLOW_DUPLICATE="${PLAYWRIGHT_OCR_ALLOW_DUPLICATE:-1}" \ - E2E_BASE_URL="$PUBLIC_BASE_URL" \ - npx playwright test tests/e2e/manual/live-invoice-ocr.spec.ts --workers=1 --reporter=list - ) | tee "$REPORT_DIR/live-ocr.txt" + run_command_step \ + "liveOcr" \ + "required" \ + "live OCR sample verification" \ + bash -c 'cd "$1" && PLAYWRIGHT_MANUAL=1 PLAYWRIGHT_OCR_LIVE=1 PLAYWRIGHT_OCR_ALLOW_DUPLICATE="${PLAYWRIGHT_OCR_ALLOW_DUPLICATE:-1}" E2E_BASE_URL="$2" npx playwright test tests/e2e/manual/live-invoice-ocr.spec.ts --workers=1 --reporter=list' _ "$CLIENT_DIR" "$PUBLIC_BASE_URL" else - echo ">>> [live-readiness] SKIP 真实 OCR:缺少 smoke 员工凭据或 PLAYWRIGHT_OCR_LIVE_FILE/PLAYWRIGHT_OCR_LIVE_DIR" | tee "$REPORT_DIR/live-ocr.txt" + write_skip_step "liveOcr" "OCR credentials or PDF sample are incomplete" fi -echo "[live-readiness][PASS] readiness 检查完成" -echo "[live-readiness] report=$REPORT_DIR" +if node "$REPORT_TOOL" privacy "$ROOT_DIR" "$REPORT_DIR" >"$REPORT_DIR/privacy.log" 2>&1; then + write_step "privacy" "PASS" "required" 0 0 "readiness report privacy scan" "$REPORT_DIR/privacy-findings.json" +else + write_step "privacy" "FAIL" "required" 1 1 "redacted privacy findings detected" "$REPORT_DIR/privacy-findings.json" + STATUS=1 +fi + +if ! node "$REPORT_TOOL" summarize "$ROOT_DIR" "$REPORT_DIR" "$PUBLIC_BASE_URL"; then + STATUS=1 +fi + +summary_status="$(node -e 'const s=require(process.argv[1]); process.stdout.write(String(s.status || "UNKNOWN"))' "$REPORT_DIR/summary.json" 2>/dev/null || printf 'UNKNOWN')" +summary_ref="$(node -e 'const s=require(process.argv[1]); process.stdout.write(String(s.reportRef || "summary unavailable"))' "$REPORT_DIR/summary.json" 2>/dev/null || printf 'summary unavailable')" +echo "[live-readiness][$summary_status] readiness 检查完成" +echo "[live-readiness] report=$summary_ref" + +exit "$STATUS" diff --git a/server/src/app.js b/server/src/app.js index 80bfd2e2..816d3789 100644 --- a/server/src/app.js +++ b/server/src/app.js @@ -104,8 +104,10 @@ function setStaticCacheHeaders(res, filePath) { } } -async function buildApp() { +async function buildApp(dependencies = {}) { const config = getConfig(); + const prismaPluginImpl = dependencies.prismaPlugin ?? prismaPlugin; + const startJobsImpl = dependencies.startJobs ?? startJobs; const app = fastify({ logger: { @@ -246,7 +248,7 @@ async function buildApp() { }); }); - await app.register(prismaPlugin); + await app.register(prismaPluginImpl); await app.register(authPlugin); if (config.nodeEnv !== "production" && config.openapiDocsEnabled) { @@ -254,10 +256,6 @@ async function buildApp() { app.log.info("OpenAPI docs enabled at /api/docs"); } - if (config.otelTracesEnabled) { - app.log.info("OTel tracing enabled"); - } - app.get( "/health", { @@ -267,16 +265,17 @@ async function buildApp() { response: { 200: buildDataEnvelopeSchema({ type: "object", - required: ["ok"], + required: ["ok", "buildSha"], properties: { ok: { type: "boolean" }, + buildSha: { type: "string" }, }, }), }, }, }, async (request, reply) => { - return reply.send({ requestId: request.id, data: { ok: true } }); + return reply.send({ requestId: request.id, data: { ok: true, buildSha: app.config.buildSha } }); }, ); @@ -289,16 +288,17 @@ async function buildApp() { response: { 200: buildDataEnvelopeSchema({ type: "object", - required: ["ok"], + required: ["ok", "buildSha"], properties: { ok: { type: "boolean" }, + buildSha: { type: "string" }, }, }), 503: buildErrorEnvelopeSchema({ description: "数据库不可用", codeExample: "SYS_503", messageExample: "Service Unavailable", - detailsExample: { db: "unreachable" }, + detailsExample: { db: "unreachable", buildSha: "abcdef123456" }, }), }, }, @@ -306,14 +306,14 @@ async function buildApp() { async (request, reply) => { try { await app.prisma.$queryRaw`SELECT 1`; - return reply.send({ requestId: request.id, data: { ok: true } }); + return reply.send({ requestId: request.id, data: { ok: true, buildSha: app.config.buildSha } }); } catch (error) { app.log.error({ err: error, requestId: request.id }, "Readiness check failed"); throw new AppError({ code: "SYS_503", message: "Service Unavailable", statusCode: 503, - details: { db: "unreachable" }, + details: { db: "unreachable", buildSha: app.config.buildSha }, }); } }, @@ -399,7 +399,7 @@ async function buildApp() { await app.register(aiRoutes, { prefix: "/api" }); await app.register(exportsRoutes, { prefix: "/api" }); - await startJobs(app); + await startJobsImpl(app); return app; } diff --git a/server/src/config.js b/server/src/config.js index e7617c66..6ae162fa 100644 --- a/server/src/config.js +++ b/server/src/config.js @@ -11,6 +11,11 @@ function requiredEnv(name) { return value; } +function normalizeBuildSha(value) { + const sha = String(value || "").trim().toLowerCase(); + return /^[0-9a-f]{7,64}$/.test(sha) ? sha.slice(0, 12) : "unknown"; +} + function getConfig() { const nodeEnv = process.env.NODE_ENV ?? "development"; const serverRoot = path.resolve(__dirname, ".."); @@ -21,6 +26,7 @@ function getConfig() { nodeEnv, port: Number(process.env.PORT ?? 3000), host: process.env.HOST ?? "0.0.0.0", + buildSha: normalizeBuildSha(process.env.BUILD_SHA), metricsEnabled: process.env.METRICS_ENABLED !== "false", diff --git a/server/tests/unit/app.health.test.js b/server/tests/unit/app.health.test.js new file mode 100644 index 00000000..390639f0 --- /dev/null +++ b/server/tests/unit/app.health.test.js @@ -0,0 +1,87 @@ +const fp = require("fastify-plugin"); + +const { buildApp } = require("../../src/app"); + +const startJobs = vi.fn(async () => ({ driver: "disabled" })); + +function createPrismaPlugin(prisma) { + return fp(async (app) => { + app.decorate("prisma", prisma); + }, { name: "test-prisma" }); +} + +async function buildHealthApp({ buildSha, queryRaw = vi.fn().mockResolvedValue([{ ok: 1 }]) }) { + vi.stubEnv("NODE_ENV", "test"); + vi.stubEnv("JWT_SECRET", "test-secret"); + vi.stubEnv("DATABASE_URL", ""); + vi.stubEnv("BUILD_SHA", buildSha); + vi.stubEnv("JOBS_ENABLED", "false"); + vi.stubEnv("METRICS_ENABLED", "false"); + + const prisma = { + $queryRaw: queryRaw, + employee: { + findUnique: vi.fn(), + }, + }; + const app = await buildApp({ + prismaPlugin: createPrismaPlugin(prisma), + startJobs, + }); + return { app, queryRaw }; +} + +describe("app build provenance endpoints", () => { + afterEach(() => { + vi.clearAllMocks(); + vi.unstubAllEnvs(); + }); + + it("returns the same redacted build SHA from health and readiness", async () => { + const { app } = await buildHealthApp({ + buildSha: "ABCDEF1234567890ABCDEF1234567890ABCDEF12", + }); + + try { + const health = await app.inject({ method: "GET", url: "/health" }); + const ready = await app.inject({ method: "GET", url: "/ready" }); + + expect(health.statusCode).toBe(200); + expect(ready.statusCode).toBe(200); + expect(health.json().data).toEqual({ ok: true, buildSha: "abcdef123456" }); + expect(ready.json().data).toEqual({ ok: true, buildSha: "abcdef123456" }); + } finally { + await app.close(); + } + }, 20_000); + + it("uses a non-identifying marker when no valid build SHA is supplied", async () => { + const { app } = await buildHealthApp({ buildSha: "local-working-tree" }); + + try { + const health = await app.inject({ method: "GET", url: "/health" }); + expect(health.json().data.buildSha).toBe("unknown"); + } finally { + await app.close(); + } + }, 20_000); + + it("keeps the build marker visible when readiness fails", async () => { + const queryRaw = vi.fn().mockRejectedValueOnce(new Error("database unavailable")); + const { app } = await buildHealthApp({ + buildSha: "ABCDEF1234567890ABCDEF1234567890ABCDEF12", + queryRaw, + }); + + try { + const ready = await app.inject({ method: "GET", url: "/ready" }); + expect(ready.statusCode).toBe(503); + expect(ready.json().error.details).toEqual({ + db: "unreachable", + buildSha: "abcdef123456", + }); + } finally { + await app.close(); + } + }, 20_000); +}); diff --git a/server/tests/unit/app.static-cache.test.js b/server/tests/unit/app.static-cache.test.js index ef08181a..2a5bf584 100644 --- a/server/tests/unit/app.static-cache.test.js +++ b/server/tests/unit/app.static-cache.test.js @@ -1,32 +1,23 @@ const fs = require("node:fs/promises"); const os = require("node:os"); const path = require("node:path"); - -const createPrismaClient = vi.fn(() => ({ - prisma: { - $connect: vi.fn().mockResolvedValue(undefined), - $disconnect: vi.fn().mockResolvedValue(undefined), - employee: { - findUnique: vi.fn(), - }, - }, - pool: { - end: vi.fn().mockResolvedValue(undefined), - }, -})); +const fp = require("fastify-plugin"); const startJobs = vi.fn(async () => ({ driver: "disabled" })); -vi.mock("../../src/prismaClient", () => ({ - createPrismaClient, -})); - -vi.mock("../../src/jobs/jobSystem", () => ({ - startJobs, -})); - const { buildApp } = require("../../src/app"); +function createPrismaPlugin() { + return fp(async (app) => { + app.decorate("prisma", { + $queryRaw: vi.fn().mockResolvedValue([{ ok: 1 }]), + employee: { + findUnique: vi.fn(), + }, + }); + }, { name: "test-prisma" }); +} + async function createClientDistFixture() { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "hyb-oa-static-cache-")); await fs.mkdir(path.join(tempDir, "assets"), { recursive: true }); @@ -46,8 +37,12 @@ async function buildStaticApp(clientDistDir) { vi.stubEnv("CLIENT_DIST_DIR", clientDistDir); vi.stubEnv("STORAGE_DIR", path.join(clientDistDir, "..", "storage")); vi.stubEnv("JOBS_ENABLED", "false"); + vi.stubEnv("DATABASE_URL", ""); - return buildApp(); + return buildApp({ + prismaPlugin: createPrismaPlugin(), + startJobs, + }); } describe("app static cache headers", () => { diff --git a/server/tests/unit/scripts/check-pending-ocr.test.js b/server/tests/unit/scripts/check-pending-ocr.test.js new file mode 100644 index 00000000..aeff8551 --- /dev/null +++ b/server/tests/unit/scripts/check-pending-ocr.test.js @@ -0,0 +1,85 @@ +const path = require("node:path"); +const { pathToFileURL } = require("node:url"); + +let pendingOcr; + +describe("pending OCR readiness contract", () => { + beforeAll(async () => { + const scriptPath = path.resolve(__dirname, "../../../../scripts/qa/check-pending-ocr.mjs"); + pendingOcr = await import(pathToFileURL(scriptPath).href); + }); + + it("normalizes unique positive thresholds", () => { + expect(pendingOcr.parseThresholds(["--thresholds=10,2,5,2,0,invalid"])).toEqual([2, 5, 10]); + }); + + it("reports a non-clean SKIP when the database target is unavailable", async () => { + const summary = await pendingOcr.buildPendingOcrSummary({ + databaseReady: false, + thresholds: [2, 5, 10], + countPending: vi.fn(), + now: new Date("2026-07-15T00:00:00.000Z"), + }); + + expect(summary).toMatchObject({ + status: "SKIP", + findingCount: 1, + findings: [{ code: "database_not_configured", severity: "manual_or_config" }], + thresholds: [], + }); + }); + + it("reports PASS when no pending OCR rows exceed any threshold", async () => { + const countPending = vi.fn(async () => 0); + const summary = await pendingOcr.buildPendingOcrSummary({ + databaseReady: true, + thresholds: [2, 5], + countPending, + now: new Date("2026-07-15T00:00:00.000Z"), + }); + + expect(summary).toMatchObject({ status: "PASS", findingCount: 0, findings: [] }); + expect(summary.thresholds).toEqual([ + { thresholdMinutes: 2, count: 0 }, + { thresholdMinutes: 5, count: 0 }, + ]); + expect(countPending).toHaveBeenNthCalledWith(1, { + status: "pending_ocr", + created_at: { lte: new Date("2026-07-14T23:58:00.000Z") }, + }); + }); + + it("reports PASS_WITH_FINDINGS using the unique pending row upper bound", async () => { + const counts = [2, 4, 7]; + const summary = await pendingOcr.buildPendingOcrSummary({ + databaseReady: true, + thresholds: [2, 5, 10], + countPending: vi.fn(async () => counts.shift()), + now: new Date("2026-07-15T00:00:00.000Z"), + }); + + expect(summary).toMatchObject({ + status: "PASS_WITH_FINDINGS", + findingCount: 7, + findings: [{ code: "pending_ocr_detected", severity: "finding", count: 7 }], + }); + }); + + it("constructs and closes the shared Prisma 7 adapter without opening a database connection", async () => { + const previousDatabaseUrl = process.env.DATABASE_URL; + process.env.DATABASE_URL = "postgresql://local-user:local-password@127.0.0.1:1/hyb_oa_test"; + + try { + await expect(pendingOcr.executePendingOcrCheck({ + databaseReady: true, + thresholds: [], + })).resolves.toMatchObject({ status: "PASS", findingCount: 0 }); + } finally { + if (previousDatabaseUrl === undefined) { + delete process.env.DATABASE_URL; + } else { + process.env.DATABASE_URL = previousDatabaseUrl; + } + } + }); +}); diff --git a/server/tests/unit/scripts/check-sensitive-content.test.js b/server/tests/unit/scripts/check-sensitive-content.test.js new file mode 100644 index 00000000..49589eee --- /dev/null +++ b/server/tests/unit/scripts/check-sensitive-content.test.js @@ -0,0 +1,77 @@ +const path = require("node:path"); +const { pathToFileURL } = require("node:url"); + +const ROOT_DIR = path.resolve(__dirname, "../../../.."); +let scanner; + +describe("current fact-source sensitive content gate", () => { + beforeAll(async () => { + const scriptPath = path.join(ROOT_DIR, "scripts/qa/check-sensitive-content.mjs"); + scanner = await import(pathToFileURL(scriptPath).href); + }); + + it("reports only redacted metadata for secret, account, path and infrastructure matches", () => { + const source = [ + "SERVICE_PASSWORD=do-not-disclose-value", + "contact=person@example.invalid", + "phone=13800000000", + "workspace=/home/developer/private/project", + "server=203.0.113.10", + ].join("\n"); + + const findings = scanner.scanSensitiveText(source, "fixture.md"); + + expect(findings.map((item) => item.category)).toEqual(expect.arrayContaining([ + "assigned_secret", + "email_account", + "phone_account", + "personal_workspace_path", + "public_ipv4", + ])); + expect(JSON.stringify(findings)).not.toMatch(/do-not-disclose|person@|13800000000|203\.0\.113\.10/); + }); + + it("allows explicit placeholders, environment interpolation and loopback addresses", () => { + const source = [ + "SERVICE_PASSWORD=", + "DATABASE_URL=${LOCAL_DATABASE_URL}", + "admin=", + "workspace=/server", + "server=127.0.0.1", + ].join("\n"); + + expect(scanner.scanSensitiveText(source, "fixture.md")).toEqual([]); + }); + + it("derives current docs from the status matrix without pulling historical issue rows", () => { + const matrix = [ + "| ID | Contract | Type | Purpose | Status |", + "| --- | --- | --- | --- | --- |", + "| `CURRENT-DOC` | `docs/current.md` | fact | current | `CURRENT` |", + "| `HISTORY-DOC` | `docs/history.md` | evidence | old | `EVIDENCE` |", + "| `CURRENT-ISSUE` | `issues/current.csv` | `CURRENT` | issue only |", + ].join("\n"); + + expect(scanner.extractCurrentDocumentFiles(matrix)).toEqual(["docs/current.md"]); + }); + + it("includes every CURRENT docs contract in the default scan set", () => { + const files = scanner.resolveDefaultScanFiles(ROOT_DIR); + + expect(files).toEqual(expect.arrayContaining([ + "docs/测试规约与回归门禁.md", + "docs/需求-资产管理规约.md", + "docs/管理员手册.md", + "docs/员工快速上手.md", + ])); + }); + + it("passes the registered current fact sources and deployment entrypoints", () => { + expect(scanner.scanSensitiveContent({ rootDir: ROOT_DIR })).toMatchObject({ + status: "PASS", + findingCount: 0, + filesScanned: expect.any(Number), + findings: [], + }); + }); +}); diff --git a/server/tests/unit/scripts/check-source-provenance.test.js b/server/tests/unit/scripts/check-source-provenance.test.js new file mode 100644 index 00000000..bade8297 --- /dev/null +++ b/server/tests/unit/scripts/check-source-provenance.test.js @@ -0,0 +1,157 @@ +const { execFile } = require("node:child_process"); +const { mkdtemp, rm, writeFile } = require("node:fs/promises"); +const os = require("node:os"); +const path = require("node:path"); +const { promisify } = require("node:util"); +const { pathToFileURL } = require("node:url"); + +const execFileAsync = promisify(execFile); +const ROOT_DIR = path.resolve(__dirname, "../../../.."); +let provenance; +let repoDir; + +async function git(args, cwd = repoDir) { + return execFileAsync("git", args, { cwd }); +} + +async function createCommit(message = "initial") { + await git(["add", "tracked.txt"]); + await git(["commit", "-m", message]); +} + +describe("deployment source provenance", () => { + beforeAll(async () => { + const scriptPath = path.join(ROOT_DIR, "deploy/trialrun/check-source-provenance.mjs"); + provenance = await import(pathToFileURL(scriptPath).href); + }); + + beforeEach(async () => { + repoDir = await mkdtemp(path.join(os.tmpdir(), "hyb-oa-provenance-test-")); + await git(["init", "--initial-branch=main"]); + await git(["config", "user.name", "Test User"]); + await git(["config", "user.email", "test@example.invalid"]); + await writeFile(path.join(repoDir, "tracked.txt"), "initial\n", "utf8"); + await createCommit(); + }); + + afterEach(async () => { + await rm(repoDir, { recursive: true, force: true }); + }); + + it("requires an upstream by default and permits an explicit unpublished override", () => { + expect(() => provenance.collectSourceProvenance({ rootDir: repoDir })).toThrowError( + expect.objectContaining({ code: "upstream_missing" }), + ); + + expect(provenance.collectSourceProvenance({ rootDir: repoDir, allowUnpublished: true })).toMatchObject({ + status: "PASS", + published: false, + overrideUsed: true, + branch: "main", + }); + }); + + it("rejects tracked and untracked working-tree changes before any deployment", async () => { + await writeFile(path.join(repoDir, "tracked.txt"), "changed\n", "utf8"); + await writeFile(path.join(repoDir, "untracked.txt"), "untracked\n", "utf8"); + + expect(() => provenance.collectSourceProvenance({ rootDir: repoDir, allowUnpublished: true })).toThrowError( + expect.objectContaining({ code: "dirty_worktree" }), + ); + }); + + it("accepts a clean commit that is contained in its upstream", async () => { + const remoteDir = await mkdtemp(path.join(os.tmpdir(), "hyb-oa-provenance-remote-")); + try { + await execFileAsync("git", ["init", "--bare", remoteDir]); + await git(["remote", "add", "origin", remoteDir]); + await git(["push", "--set-upstream", "origin", "main"]); + + expect(provenance.collectSourceProvenance({ rootDir: repoDir })).toMatchObject({ + status: "PASS", + published: true, + overrideUsed: false, + upstream: "origin/main", + }); + } finally { + await rm(remoteDir, { recursive: true, force: true }); + } + }); + + it("rejects a clean local commit that has not reached upstream", async () => { + const remoteDir = await mkdtemp(path.join(os.tmpdir(), "hyb-oa-provenance-remote-")); + try { + await execFileAsync("git", ["init", "--bare", remoteDir]); + await git(["remote", "add", "origin", remoteDir]); + await git(["push", "--set-upstream", "origin", "main"]); + await writeFile(path.join(repoDir, "tracked.txt"), "ahead\n", "utf8"); + await createCommit("ahead"); + + expect(() => provenance.collectSourceProvenance({ rootDir: repoDir })).toThrowError( + expect.objectContaining({ code: "commit_not_in_upstream" }), + ); + expect(provenance.collectSourceProvenance({ rootDir: repoDir, allowUnpublished: true })).toMatchObject({ + published: false, + overrideUsed: true, + }); + } finally { + await rm(remoteDir, { recursive: true, force: true }); + } + }); + + it("refreshes the remote-tracking ref before deciding whether a commit is still published", async () => { + const remoteDir = await mkdtemp(path.join(os.tmpdir(), "hyb-oa-provenance-remote-")); + const replacementDir = await mkdtemp(path.join(os.tmpdir(), "hyb-oa-provenance-replacement-")); + try { + await execFileAsync("git", ["init", "--bare", remoteDir]); + await git(["remote", "add", "origin", remoteDir]); + await git(["push", "--set-upstream", "origin", "main"]); + expect(provenance.collectSourceProvenance({ rootDir: repoDir })).toMatchObject({ published: true }); + + await execFileAsync("git", ["init", "--initial-branch=main"], { cwd: replacementDir }); + await execFileAsync("git", ["config", "user.name", "Replacement"], { cwd: replacementDir }); + await execFileAsync("git", ["config", "user.email", "replacement@example.invalid"], { cwd: replacementDir }); + await writeFile(path.join(replacementDir, "replacement.txt"), "replacement\n", "utf8"); + await execFileAsync("git", ["add", "replacement.txt"], { cwd: replacementDir }); + await execFileAsync("git", ["commit", "-m", "replacement"], { cwd: replacementDir }); + await execFileAsync("git", ["remote", "add", "origin", remoteDir], { cwd: replacementDir }); + await execFileAsync("git", ["push", "--force", "origin", "main"], { cwd: replacementDir }); + + expect(provenance.collectSourceProvenance({ rootDir: repoDir })).toMatchObject({ published: true }); + expect(provenance.refreshUpstreamRef({ rootDir: repoDir })).toMatchObject({ + refreshed: true, + upstream: "origin/main", + remote: "origin", + }); + expect(() => provenance.collectSourceProvenance({ rootDir: repoDir })).toThrowError( + expect.objectContaining({ code: "commit_not_in_upstream" }), + ); + } finally { + await rm(remoteDir, { recursive: true, force: true }); + await rm(replacementDir, { recursive: true, force: true }); + } + }); + + it("requires an allowlisted audit reason for the unpublished override", async () => { + const script = path.join(ROOT_DIR, "deploy/trialrun/check-source-provenance.mjs"); + + await expect(execFileAsync("node", [script, `--root=${repoDir}`], { + env: { ...process.env, DEPLOY_ALLOW_UNPUBLISHED_COMMIT: "1" }, + })).rejects.toMatchObject({ code: 1 }); + + const { stdout } = await execFileAsync("node", [script, `--root=${repoDir}`], { + env: { + ...process.env, + DEPLOY_ALLOW_UNPUBLISHED_COMMIT: "1", + DEPLOY_UNPUBLISHED_REASON: "offline-disaster-recovery", + }, + }); + expect(JSON.parse(stdout)).toMatchObject({ + published: false, + remoteVerified: false, + overrideUsed: true, + overrideRequested: true, + overrideReason: "offline-disaster-recovery", + }); + }); +}); diff --git a/server/tests/unit/scripts/check-trellis-governance.test.js b/server/tests/unit/scripts/check-trellis-governance.test.js new file mode 100644 index 00000000..f672b2bf --- /dev/null +++ b/server/tests/unit/scripts/check-trellis-governance.test.js @@ -0,0 +1,27 @@ +const path = require("node:path"); +const { pathToFileURL } = require("node:url"); + +let governance; + +describe("Trellis context manifest governance", () => { + beforeAll(async () => { + const scriptPath = path.resolve(__dirname, "../../../../scripts/qa/check-trellis-governance.mjs"); + governance = await import(pathToFileURL(scriptPath).href); + }); + + it("does not require sub-agent manifests in Codex inline mode", () => { + expect(governance.contextManifestPolicy("inline")).toEqual({ + required: false, + requireRealEntries: false, + rejectSeedRows: false, + }); + }); + + it("keeps strict manifest validation for sub-agent dispatch", () => { + expect(governance.contextManifestPolicy("sub-agent")).toEqual({ + required: true, + requireRealEntries: true, + rejectSeedRows: true, + }); + }); +}); diff --git a/server/tests/unit/scripts/live-smoke-readiness-report.test.js b/server/tests/unit/scripts/live-smoke-readiness-report.test.js new file mode 100644 index 00000000..8d4eef17 --- /dev/null +++ b/server/tests/unit/scripts/live-smoke-readiness-report.test.js @@ -0,0 +1,178 @@ +const { execFile } = require("node:child_process"); +const { mkdtemp, readFile, rm } = require("node:fs/promises"); +const http = require("node:http"); +const os = require("node:os"); +const path = require("node:path"); +const { promisify } = require("node:util"); +const { pathToFileURL } = require("node:url"); + +const execFileAsync = promisify(execFile); +const ROOT_DIR = path.resolve(__dirname, "../../../.."); +let readinessReport; + +function passSteps() { + return Object.fromEntries( + readinessReport.READINESS_STEP_NAMES.map((name) => [name, { + status: "PASS", + kind: "required", + findingCount: 0, + logRef: `artifacts/qa-flow/test/${name}.log`, + }]), + ); +} + +function listen(server) { + return new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + server.off("error", reject); + resolve(); + }); + }); +} + +function close(server) { + return new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); +} + +const EXPECTED_BUILD_SHA = "abcdef123456"; + +describe("live smoke readiness aggregation", () => { + beforeAll(async () => { + const scriptPath = path.join(ROOT_DIR, "scripts/qa/live-smoke-readiness-report.mjs"); + readinessReport = await import(pathToFileURL(scriptPath).href); + }); + + it.each([ + ["missing database", "pendingOcr"], + ["missing login credentials", "liveSmoke"], + ["missing OCR sample", "liveOcr"], + ])("propagates %s as PASS_WITH_FINDINGS", (_label, stepName) => { + const steps = passSteps(); + steps[stepName] = { + ...steps[stepName], + status: "SKIP", + findingCount: 1, + }; + + const result = readinessReport.evaluateReadinessSteps(steps); + + expect(result.status).toBe("PASS_WITH_FINDINGS"); + expect(result.findingCount).toBeGreaterThan(0); + expect(result.findings).toEqual(expect.arrayContaining([ + expect.objectContaining({ source: stepName, severity: "manual_or_config" }), + ])); + }); + + it("reports clean PASS only when every prerequisite and step is ready", () => { + expect(readinessReport.evaluateReadinessSteps(passSteps())).toMatchObject({ + status: "PASS", + findingCount: 0, + findings: [], + }); + }); + + it("treats a required command failure as a blocker", () => { + const steps = passSteps(); + steps.publicReady = { ...steps.publicReady, status: "FAIL", findingCount: 1 }; + + expect(readinessReport.evaluateReadinessSteps(steps)).toMatchObject({ + status: "FAIL", + findingCount: 1, + }); + }); + + it("runs the shell parent against a local public stub and writes a redacted non-clean summary", async () => { + const reportDir = await mkdtemp(path.join(os.tmpdir(), "hyb-oa-readiness-test-")); + const server = http.createServer((request, response) => { + response.setHeader("content-type", "application/json; charset=utf-8"); + if (request.url === "/ready") { + response.end(JSON.stringify({ data: { ok: true, buildSha: EXPECTED_BUILD_SHA } })); + return; + } + if (request.url === "/api/auth/wecom/config") { + response.end(JSON.stringify({ + data: { + status: "ready", + entryEnabled: true, + oauthEnabled: true, + defaultReturnTo: "/portal", + loginUrl: "/api/auth/wecom/login", + }, + })); + return; + } + if (request.url === "/api/auth/capabilities") { + response.end(JSON.stringify({ + data: { + emailDelivery: { ready: true, statusReasons: [] }, + emailLoginCode: { enabled: true }, + passwordReset: { enabled: true }, + }, + })); + return; + } + if (request.url === "/") { + response.setHeader("content-type", "text/html; charset=utf-8"); + response.end('OA'); + return; + } + if (request.url === "/assets/index-test.js") { + response.setHeader("content-type", "application/javascript; charset=utf-8"); + response.end(`document.documentElement.dataset.buildSha="${EXPECTED_BUILD_SHA}";`); + return; + } + response.statusCode = 404; + response.end(JSON.stringify({ error: "not_found" })); + }); + + try { + await listen(server); + const address = server.address(); + const env = { ...process.env }; + for (const key of [ + "DATABASE_URL", + "LIVE_SMOKE_ADMIN_IDENTIFIER", + "LIVE_SMOKE_ADMIN_PASSWORD", + "LIVE_SMOKE_EMPLOYEE_IDENTIFIER", + "LIVE_SMOKE_EMPLOYEE_PASSWORD", + "PLAYWRIGHT_OCR_LIVE_IDENTIFIER", + "PLAYWRIGHT_OCR_LIVE_PASSWORD", + "PLAYWRIGHT_OCR_LIVE_FILE", + "PLAYWRIGHT_OCR_LIVE_DIR", + "LIVE_OCR_INVOICE_FILE", + ]) { + delete env[key]; + } + env.LIVE_SMOKE_BASE_URL = `http://127.0.0.1:${address.port}`; + env.LIVE_SMOKE_READINESS_DIR = reportDir; + env.LIVE_SMOKE_EXPECTED_BUILD_SHA = EXPECTED_BUILD_SHA; + env.QA_ISSUE_ID = "READINESS-TEST"; + + const result = await execFileAsync("bash", [path.join(ROOT_DIR, "scripts/qa/run-live-smoke-readiness.sh")], { + cwd: ROOT_DIR, + env, + maxBuffer: 2 * 1024 * 1024, + }); + const summaryText = await readFile(path.join(reportDir, "summary.json"), "utf8"); + const summary = JSON.parse(summaryText); + + expect(result.stdout).toContain("[live-readiness][PASS_WITH_FINDINGS]"); + expect(summary.status).toBe("PASS_WITH_FINDINGS"); + expect(summary.findingCount).toBeGreaterThan(0); + expect(summary.steps.pendingOcr.status).toBe("SKIP"); + expect(summary.steps.liveSmoke.status).toBe("SKIP"); + expect(summary.steps.liveOcr.status).toBe("SKIP"); + expect(summary.steps.privacy.status).toBe("PASS"); + expect(summary.expectedBuildSha).toBe(EXPECTED_BUILD_SHA); + expect(summary.observedBuildSha).toBe(EXPECTED_BUILD_SHA); + expect(summary.buildShaMatches).toBe(true); + expect(summary.childReports.pendingOcr.findingCount).toBeGreaterThan(0); + expect(summaryText).not.toContain(ROOT_DIR); + expect(summaryText).not.toMatch(/DATABASE_URL|LIVE_SMOKE_ADMIN_PASSWORD|PLAYWRIGHT_OCR_LIVE_FILE/); + } finally { + await close(server); + await rm(reportDir, { recursive: true, force: true }); + } + }, 30_000); +});