diff --git a/client/tests/e2e/manual/asset1d-public-trial.spec.ts b/client/tests/e2e/manual/asset1d-public-trial.spec.ts new file mode 100644 index 00000000..2bc7af05 --- /dev/null +++ b/client/tests/e2e/manual/asset1d-public-trial.spec.ts @@ -0,0 +1,191 @@ +import fs from "node:fs"; +import path from "node:path"; + +import { expect, test, type Locator, type Page } from "@playwright/test"; + +import { login, type E2EUser } from "../helpers/auth"; + +type AssetType = "fixed_asset" | "electronic_system" | "code_repository" | "backup_media"; + +const ASSET_TYPES: Array<{ + type: AssetType; + label: string; + prefix: RegExp; + sensitivity: string; + externalRef: string; + metadata: Record; +}> = [ + { + type: "fixed_asset", + label: "FA 固定资产", + prefix: /^FA-\d{4}-\d{6}$/, + sensitivity: "internal", + externalRef: "asset1d-fixed-asset-ledger", + metadata: { trialKind: "fixed_asset" }, + }, + { + type: "electronic_system", + label: "EA-SYS 电子系统", + prefix: /^EA-SYS-\d{4}-\d{6}$/, + sensitivity: "internal", + externalRef: "https://oa.hyinda.com", + metadata: { trialKind: "electronic_system", backupPolicy: "manual-check" }, + }, + { + type: "code_repository", + label: "EA-REPO 代码仓库", + prefix: /^EA-REPO-\d{4}-\d{6}$/, + sensitivity: "internal", + externalRef: "git-meta:hyb-oa", + metadata: { trialKind: "code_repository", sourceOfTruth: "git" }, + }, + { + type: "backup_media", + label: "EA-BKP 备份资产", + prefix: /^EA-BKP-\d{4}-\d{6}$/, + sensitivity: "confidential", + externalRef: "backup-set:ASSET1D", + metadata: { trialKind: "backup_media", restoreVerification: "metadata-only" }, + }, +]; + +function requireAdminUser(): E2EUser { + const identifier = process.env.LIVE_SMOKE_ADMIN_IDENTIFIER?.trim(); + const password = process.env.LIVE_SMOKE_ADMIN_PASSWORD?.trim(); + if (!identifier || !password) { + throw new Error("missing LIVE_SMOKE_ADMIN_IDENTIFIER/PASSWORD"); + } + return { identifier, password, name: "OA-only smoke admin" }; +} + +function reportDir() { + return path.resolve(process.env.ASSET1D_REPORT_DIR || `../artifacts/qa-flow/asset1d-public-trial-${Date.now()}`); +} + +async function selectFirstBusinessOption(locator: Locator) { + await expect + .poll(async () => locator.locator("option").evaluateAll((options) => options + .map((option) => ({ + value: (option as HTMLOptionElement).value, + disabled: (option as HTMLOptionElement).disabled, + })) + .filter((option) => option.value && !option.disabled) + .map((option) => option.value)[0] ?? "")) + .not.toBe(""); + + const value = await locator.locator("option").evaluateAll((options) => options + .map((option) => ({ + value: (option as HTMLOptionElement).value, + disabled: (option as HTMLOptionElement).disabled, + })) + .filter((option) => option.value && !option.disabled) + .map((option) => option.value)[0] ?? ""); + await locator.selectOption(value); +} + +async function fillByPlaceholder(page: Page, placeholder: string | RegExp, value: string) { + const field = page.getByPlaceholder(placeholder).first(); + await expect(field).toBeVisible(); + await field.fill(value); +} + +async function createTrialCategory(page: Page, suffix: string) { + const categoryCode = `ASSET1D_${suffix}`; + const categoryName = `TEST SMOKE ASSET1D 分类 ${suffix}`; + await page.getByTestId("asset-category-reset").click(); + await page.getByTestId("asset-category-code").fill(categoryCode); + await page.getByTestId("asset-category-name").fill(categoryName); + await page.getByTestId("asset-category-save").click(); + await expect(page.getByRole("row").filter({ hasText: categoryCode })).toBeVisible({ timeout: 30_000 }); + return { categoryCode, categoryName }; +} + +async function createAsset(page: Page, categoryName: string, suffix: string, index: number, config: (typeof ASSET_TYPES)[number]) { + const name = `TEST SMOKE ASSET1D ${config.label} ${suffix}`; + await page.getByTestId("asset-create").click(); + await expect(page.getByRole("heading", { name: "新建资产卡片" })).toBeVisible(); + + await page.getByTestId("asset-name").fill(name); + await page.getByTestId("asset-type").selectOption(config.type); + await page.getByTestId("asset-sensitivity").selectOption(config.sensitivity); + await page.getByTestId("asset-category").selectOption({ label: categoryName }); + await selectFirstBusinessOption(page.getByTestId("asset-legal-entity")); + await selectFirstBusinessOption(page.getByTestId("asset-cost-center")); + await selectFirstBusinessOption(page.getByTestId("asset-owner")); + await selectFirstBusinessOption(page.getByTestId("asset-keeper")); + await page.getByTestId("asset-serial-no").fill(`ASSET1D-${config.type}-${suffix}`); + await page.getByTestId("asset-location").fill("TEST-SMOKE-ASSET1D 管理位置"); + await page.getByTestId("asset-archive-location").fill(`ASSET1D-ARCHIVE-${config.type}`); + await page.getByTestId("asset-external-ref").fill(config.externalRef); + await page.getByTestId("asset-source-hash").fill(`asset1d-${suffix}-${index}`.replace(/[^a-z0-9-]/gi, "").slice(0, 64)); + await page.getByTestId("asset-verification-no").fill(`VER-${String(Date.now()).slice(-8)}${index}`); + await fillByPlaceholder(page, /写回收要求/, "TEST/SMOKE/ASSET1D 试录;不含密钥、源码、完整路径或涉密正文。"); + await fillByPlaceholder(page, /不要粘贴 \.env/, JSON.stringify({ + testMarker: "TEST-SMOKE-ASSET1D", + ...config.metadata, + })); + + const createResponsePromise = page.waitForResponse((response) => { + const url = new URL(response.url()); + return response.request().method() === "POST" && url.pathname === "/api/assets"; + }); + await page.getByTestId("asset-save").click(); + const createResponse = await createResponsePromise; + const payload = await createResponse.json(); + if (!createResponse.ok()) { + throw new Error(`asset create failed: ${createResponse.status()} ${JSON.stringify(payload).slice(0, 300)}`); + } + + const asset = payload.data?.asset; + const assetNo = String(asset?.assetNo || ""); + expect(assetNo).toMatch(config.prefix); + await page.goto(`/assets?id=${asset.id}`); + await expect(page.getByTestId("asset-name")).toHaveValue(name, { timeout: 30_000 }); + await page.getByPlaceholder("搜索编号/名称/hash/VER").fill(assetNo); + await expect(page.getByTestId("asset-row").filter({ hasText: assetNo })).toBeVisible({ timeout: 30_000 }); + + return { + type: config.type, + assetNo, + name, + id: asset.id, + expectedPrefix: config.prefix.source, + }; +} + +test("ASSET1D 公网资产台账试录:FA/EA-SYS/EA-REPO/EA-BKP", async ({ page }) => { + test.skip(process.env.ASSET1D_PUBLIC_TRIAL !== "1", "ASSET1D public trial requires explicit ASSET1D_PUBLIC_TRIAL=1"); + const admin = requireAdminUser(); + const suffix = String(Date.now()).slice(-8); + const outputDir = reportDir(); + fs.mkdirSync(outputDir, { recursive: true }); + + await login(page, admin); + await page.goto("/assets"); + await expect(page.getByTestId("asset-category-code")).toBeVisible({ timeout: 30_000 }); + + const category = await createTrialCategory(page, suffix); + const createdAssets = []; + for (const [index, config] of ASSET_TYPES.entries()) { + createdAssets.push(await createAsset(page, category.categoryName, suffix, index + 1, config)); + } + + const summary = { + issueId: "ASSET1D-4", + status: "PASS_ASSET_UI_TRIAL_READY", + mode: "asset1d-public-ui-trial", + checkedAt: new Date().toISOString(), + baseUrl: process.env.E2E_BASE_URL || "https://oa.hyinda.com", + category, + createdAssets, + boundaries: { + noEaDocWrite: true, + noFileUpload: true, + noSecretOutput: true, + noSourceBundleUpload: true, + noFullPathOutput: true, + testDataRetainedForAudit: true, + }, + }; + fs.writeFileSync(path.join(outputDir, "summary.json"), `${JSON.stringify(summary, null, 2)}\n`, "utf8"); +}); diff --git a/client/tests/e2e/wecom-entry.spec.ts b/client/tests/e2e/wecom-entry.spec.ts index 7f961dff..85ff855c 100644 --- a/client/tests/e2e/wecom-entry.spec.ts +++ b/client/tests/e2e/wecom-entry.spec.ts @@ -3,6 +3,15 @@ import { expect, test } from "@playwright/test"; import { EMPLOYEE, login } from "./helpers/auth"; import { openProfileSettings } from "./helpers/navigation"; +function runtimeEmployee() { + const identifier = process.env.LIVE_SMOKE_EMPLOYEE_IDENTIFIER?.trim(); + const password = process.env.LIVE_SMOKE_EMPLOYEE_PASSWORD?.trim(); + if (identifier && password) { + return { identifier, password, name: "Live Smoke Employee" }; + } + return EMPLOYEE; +} + function mockWecomConfigPayload() { return { enabled: true, @@ -44,8 +53,9 @@ test("E2E 企微入口:普通浏览器下企微按钮为次级入口", async ( await page.getByTestId("wecom-entry-secondary").click(); await expect(page).toHaveURL(/\/api\/auth\/wecom\/login\?returnTo=/); - await login(page, EMPLOYEE); - await openProfileSettings(page, EMPLOYEE.name); + const employee = runtimeEmployee(); + await login(page, employee); + await openProfileSettings(page, employee.name); await expect(page.getByRole("heading", { name: "企业微信绑定" })).toBeVisible(); await expect(page.getByText("接入状态", { exact: true })).toBeVisible(); }); diff --git a/issues/2026-05-22_20-09-22-oa-real-use-stabilization-v1.csv b/issues/2026-05-22_20-09-22-oa-real-use-stabilization-v1.csv index 27075e2c..81472b07 100644 --- a/issues/2026-05-22_20-09-22-oa-real-use-stabilization-v1.csv +++ b/issues/2026-05-22_20-09-22-oa-real-use-stabilization-v1.csv @@ -8,3 +8,4 @@ OAOPS1-5,合同台账与合同归档真实使用门禁,"将合同台账、合同 OAOPS1-6,每日问题登记与阻断即修机制,"7 天稳定期内,提交、审批、付款、导出、权限、登录、合同权限和移动入口阻断当天建修复合同;非阻断体验问题进入反馈池。","管理员手册包含每日收口表、阻断/非阻断分类、暂停条件和人工绕行记录要求。","受影响流程:管理员每日查看巡检报告和人工问题 -> 分类 blocking/finding/feedback;验证步骤:blocking 当天建 Issue CSV 或暂停对应流程,非阻断进入反馈池。","manual",TODO,TODO,TODO,"docs/管理员手册.md;docs/trialrun/人工试运行报告.md","[""OAOPS1-1""]","修复合同需单独 Issue CSV;不得顺手修非本轮范围问题。" OAOPS1-7,回写事实源与项目 skill,"回写文档索引、事实源矩阵、管理员手册、员工快速上手、人工试运行报告和 hyb-oa-delivery skill,固定 OAOPS1 当前路线。","rg 能检索到 OAOPS1、真实使用稳定化、合同台账、阻断即修、payment_batch_v1=force_on 和 P2 冻结口径。","受影响流程:后来者从文档索引和 skill 能判断当前已从企微 HR/考勤转回 OA 主办真实使用稳定化;验证步骤:执行 audit-docs 和 quick_validate。","node,python",DONE,DONE,DONE,"docs/文档索引.md;docs/文档事实源状态矩阵.md;docs/管理员手册.md;docs/员工快速上手.md;docs/trialrun/人工试运行报告.md;.agents/skills/hyb-oa-delivery/SKILL.md","[""OAOPS1-0""]","优先更新现有事实源,不新建孤立文档。" OAOPS1-8,OAOPS1 本地门禁与收口,"完成合同校验、脚本语法、文档审计、skill 校验、最小回归和 git diff 检查;如可用则执行公网只读巡检。","所有列出的本地门禁通过;公网只读巡检若缺 live 凭据按 SKIP/FINDING 记录,不作为代码阻断。","受影响流程:管理员可按报告开始 7 天真实使用;验证步骤:执行 validate_issues_csv.py、check-process-contract、bash -n、audit-docs、quick_validate、git diff --check、bash scripts/check-minimal.sh。","bash,node,python",DONE,DONE,DONE,"issues/2026-05-22_20-09-22-oa-real-use-stabilization-v1.csv;scripts/qa/run-oa-real-use-daily.sh","[""OAOPS1-1"",""OAOPS1-7""]","本轮不部署业务代码;若后续需要远端安装定时任务或公网部署,另建 OPS 合同。" +OAOPS1-9,OAOPS1 巡检降噪与公网凭据稳定化,"承接 OSMOKE1 后的公网复跑 finding,降低连续登录 429、修正合同台账只读探针参数,并让 wecom-entry 公网运行使用 live smoke 员工凭据。","run-oa-real-use-daily 不再因重复财务/员工登录产生 429;合同台账探针不传无效分页参数;wecom-entry 在公网使用 live smoke 员工账号且失败仍按 finding 记录。","受影响流程:OAOPS1 每日巡检 -> 账号 readiness -> OA 主办域只读探针 -> 移动/企微入口;验证步骤:bash -n scripts/qa/run-oa-real-use-daily.sh;LIVE_SMOKE_BASE_URL=https://oa.hyinda.com bash scripts/qa/run-oa-real-use-daily.sh。","bash,playwright,node",DONE,DONE,DONE,"scripts/qa/run-oa-real-use-daily.sh;client/tests/e2e/wecom-entry.spec.ts","[""OAOPS1-1"",""OAOPS1-2""]","公网复跑报告 artifacts/qa-flow/oaops1-daily-after-throttle-20260523-155937/summary.json;结论 PASS_OA_REAL_USE_DAILY_READY,findingCount=0;合同台账探针 /api/contracts-registry 通过,wecom-entry 2/2 通过;通过 65s 登录节流消除 429;仅修 QA 脚本稳定性,不改变业务权限、不写企业微信、不上传真实业务附件。" diff --git a/issues/2026-05-23_10-42-03-oa-asset-deploy-and-trial-v1.csv b/issues/2026-05-23_10-42-03-oa-asset-deploy-and-trial-v1.csv index cc7abf56..f24f1453 100644 --- a/issues/2026-05-23_10-42-03-oa-asset-deploy-and-trial-v1.csv +++ b/issues/2026-05-23_10-42-03-oa-asset-deploy-and-trial-v1.csv @@ -1,9 +1,10 @@ ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes -ASSET1D-0,建立 ASSET1D 公网部署与试录合同,"承接 ASSET1 统一资产台账 V1,建立公网部署、迁移、试录、权限验收和历史索引二次确认合同。","Issue CSV 通过校验;范围固定为部署 ASSET1 到公网 OA、最小资产试录、EA-DOC 脱敏 dry-run;历史索引 live 写入必须二次确认。","受影响流程:执行合同 -> 部署准备 -> 资产台账试录;验证步骤:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-23_10-42-03-oa-asset-deploy-and-trial-v1.csv。","python3|manual",DONE,DONE,DONE,"issues/2026-05-23_10-42-03-oa-asset-deploy-and-trial-v1.csv","[]","不上传敏感原文、源码包、密码、token、Secret、完整配置或完整本地路径。" -ASSET1D-1,本地预检门禁,"部署前复核 ASSET1 合同、schema、资产路由、前端构建、文档门禁和最小回归。","本地门禁通过;若存在既有非阻断 warning,必须记录但不伪造成失败。","受影响流程:开发候选 -> 部署候选;验证步骤:校验 ASSET1 CSV;node scripts/qa/check-process-contract.mjs --baseline --json;cd server && npm run prisma:validate;cd server && node --check src/routes/assets.js;cd client && npm run typecheck && npm run build;node scripts/qa/audit-docs.mjs --check-registry;python3 /home/shunli/.codex/skills/.system/skill-creator/scripts/quick_validate.py .agents/skills/hyb-oa-delivery;git diff --check;bash scripts/check-minimal.sh。","python3|node|npm|bash|git",DONE,DONE,DONE,"server|client|docs|scripts|issues/2026-05-22_21-18-33-oa-asset-governance-v1.csv","[""ASSET1D-0""]","本地门禁通过;audit-docs 仅保留既有 warning:unregistered docs files 24;未改业务代码。" -ASSET1D-2,SYNC_ENV=0 公网部署,"使用既有 trialrun 部署流程发布 ASSET1 到公网 OA,执行 Prisma migrate deploy 和部署脚本内置 smoke。","部署成功;不覆盖远端 .env;迁移状态通过;/ready、external deps smoke、runtime smoke 和公网 bundle 扫描通过或记录真实阻断。","受影响流程:本地 release candidate -> 公网 OA -> 数据库迁移 -> 运行时健康检查;验证步骤:SYNC_ENV=0 bash deploy/trialrun/deploy.sh。","bash|ssh|docker|node",DONE,DONE,DONE,"deploy/trialrun/deploy.sh|server/prisma/migrations|server/src/routes/assets.js|client/src/pages/AssetRegistryPage.tsx","[""ASSET1D-1""]","公网部署完成;SYNC_ENV=0 未同步本地 .env;迁移 20260522191833_asset_governance_v1 已应用;脚本内置 /ready、external deps smoke、runtime smoke、公网 bundle 扫描通过;LIVE_SMOKE_* 缺失按脚本跳过。" -ASSET1D-3,公网资产台账基础核验,"部署后验证公网资产页、编号族、筛选和固定资产扫码入口未回归。","/ready 正常;资产台账页面可访问;FA/EA-DOC/EA-SYS/EA-REPO/EA-BKP 编号族配置已随 seed 生效;移动资产扫码入口不回归。","受影响流程:管理员进入资产台账 -> 查询筛选 -> 移动扫码入口;验证步骤:公网 /ready;必要时使用浏览器或现有 smoke 检查 /assets 和 /mobile/assets/scan;核对部署输出和页面加载。","curl|ssh|node|manual",DONE,DONE,DONE,"client/src/pages/AssetRegistryPage.tsx|client/src/pages/MobileAssetScanPage.tsx|server/src/scripts/seed-config.js","[""ASSET1D-2""]","2026-05-23 复检:公网 /ready 返回 ok;public bundle 扫描 scanned=57 focused=4 findings=0;远端只读核验资产编号族 FA/EA-DOC/EA-SYS/EA-REPO/EA-BKP 均启用,number_length=6,reset_period=yearly;当前资产卡 total=0,ASSET1D-4 UI 试录仍待人工完成。" -ASSET1D-4,最小资产 UI 试录,"管理员在公网 OA 通过 UI 手工创建 1 条 FA、1 条 EA-SYS、1 条 EA-REPO、1 条 EA-BKP,并检查查看、编辑、筛选。","四类资产均可创建和查看;EA-SYS/EA-REPO/EA-BKP 不保存 Secret、源码、完整配置或完整路径;FA 可进入既有领用、归还、盘点链路。","受影响流程:管理员新建资产 -> 填元数据 -> 保存 -> 列表筛选 -> 详情查看;验证步骤:管理员登录公网 OA 手工创建 FA/EA-SYS/EA-REPO/EA-BKP 各 1 条并记录编号前缀。","manual",TODO,TODO,TODO,"external|client/src/pages/AssetRegistryPage.tsx","[""ASSET1D-3""]","Subagent 报告 artifacts/qa-flow/asset-ui-subagent-20260523T053741Z/summary.json 结论 BLOCKED:employee/manager 登录与入口检查通过,但 admin/ceo/finance/finance_mgr/hr smoke 账号均 403/AUTH_003;远端只读核验这些高权限 smoke 员工为 archived/inactive,无法执行资产 UI 试录;未创建资产。" +ASSET1D-0,建立 ASSET1D 公网部署与试录合同,"承接 ASSET1 统一资产台账 V1,建立公网部署、迁移、试录、权限验收和历史索引二次确认合同。","Issue CSV 通过校验;范围固定为部署 ASSET1 到公网 OA、最小资产试录、EA-DOC 脱敏 dry-run;历史索引 live 写入必须二次确认。","受影响流程:执行合同 -> 部署准备 -> 资产台账试录;验证步骤:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-23_10-42-03-oa-asset-deploy-and-trial-v1.csv。","python3|manual",DONE,DONE,DONE,"issues/2026-05-23_10-42-03-oa-asset-deploy-and-trial-v1.csv","[]","不上传敏感原文、源码包、密码、token、Secret、完整配置或完整本地路径;回滚路径:未批准的 live 写入保持暂停,试录数据按审计保留或标记 archived。" +ASSET1D-1,本地预检门禁,"部署前复核 ASSET1 合同、schema、资产路由、前端构建、文档门禁和最小回归。","本地门禁通过;若存在既有非阻断 warning,必须记录但不伪造成失败。","受影响流程:开发候选 -> 部署候选;验证步骤:校验 ASSET1 CSV;node scripts/qa/check-process-contract.mjs --baseline --json;cd server && npm run prisma:validate;cd server && node --check src/routes/assets.js;cd client && npm run typecheck && npm run build;node scripts/qa/audit-docs.mjs --check-registry;python3 /home/shunli/.codex/skills/.system/skill-creator/scripts/quick_validate.py .agents/skills/hyb-oa-delivery;git diff --check;bash scripts/check-minimal.sh。","python3|node|npm|bash|git",DONE,DONE,DONE,"server|client|docs|scripts|issues/2026-05-22_21-18-33-oa-asset-governance-v1.csv","[""ASSET1D-0""]","本地门禁通过;audit-docs 仅保留既有 warning:unregistered docs files 24;未改业务代码;回滚路径:预检失败则不部署、不写公网数据。" +ASSET1D-2,SYNC_ENV=0 公网部署,"使用既有 trialrun 部署流程发布 ASSET1 到公网 OA,执行 Prisma migrate deploy 和部署脚本内置 smoke。","部署成功;不覆盖远端 .env;迁移状态通过;/ready、external deps smoke、runtime smoke 和公网 bundle 扫描通过或记录真实阻断。","受影响流程:本地 release candidate -> 公网 OA -> 数据库迁移 -> 运行时健康检查;验证步骤:SYNC_ENV=0 bash deploy/trialrun/deploy.sh。","bash|ssh|docker|node",DONE,DONE,DONE,"deploy/trialrun/deploy.sh|server/prisma/migrations|server/src/routes/assets.js|client/src/pages/AssetRegistryPage.tsx","[""ASSET1D-1""]","公网部署完成;SYNC_ENV=0 未同步本地 .env;迁移 20260522191833_asset_governance_v1 已应用;脚本内置 /ready、external deps smoke、runtime smoke、公网 bundle 扫描通过;LIVE_SMOKE_* 缺失按脚本跳过;回滚路径:按 trialrun runbook 回退镜像或恢复上一提交后重部署。" +ASSET1D-3,公网资产台账基础核验,"部署后验证公网资产页、编号族、筛选和固定资产扫码入口未回归。","/ready 正常;资产台账页面可访问;FA/EA-DOC/EA-SYS/EA-REPO/EA-BKP 编号族配置已随 seed 生效;移动资产扫码入口不回归。","受影响流程:管理员进入资产台账 -> 查询筛选 -> 移动扫码入口;验证步骤:公网 /ready;必要时使用浏览器或现有 smoke 检查 /assets 和 /mobile/assets/scan;核对部署输出和页面加载。","curl|ssh|node|manual",DONE,DONE,DONE,"client/src/pages/AssetRegistryPage.tsx|client/src/pages/MobileAssetScanPage.tsx|server/src/scripts/seed-config.js","[""ASSET1D-2""]","2026-05-23 复检:公网 /ready 返回 ok;public bundle 扫描 scanned=57 focused=4 findings=0;远端只读核验资产编号族 FA/EA-DOC/EA-SYS/EA-REPO/EA-BKP 均启用,number_length=6,reset_period=yearly;当前资产卡 total=0,ASSET1D-4 UI 试录仍待人工完成;回滚路径:基础核验失败则暂停试录并按部署回滚。" +ASSET1D-4,最小资产 UI 试录,"管理员在公网 OA 通过 UI 手工创建 1 条 FA、1 条 EA-SYS、1 条 EA-REPO、1 条 EA-BKP,并检查查看、编辑、筛选。","四类资产均可创建和查看;EA-SYS/EA-REPO/EA-BKP 不保存 Secret、源码、完整配置或完整路径;FA 可进入既有领用、归还、盘点链路。","受影响流程:管理员新建资产 -> 填元数据 -> 保存 -> 列表筛选 -> 详情查看;验证步骤:ASSET1D_REPORT_DIR=artifacts/qa-flow/asset1d-public-trial-20260523-155346 LIVE_SMOKE_BASE_URL=https://oa.hyinda.com bash scripts/qa/run-asset1d-public-trial.sh。","manual|playwright",DONE,DONE,DONE,"external|client/src/pages/AssetRegistryPage.tsx|client/tests/e2e/manual/asset1d-public-trial.spec.ts|scripts/qa/run-asset1d-public-trial.sh","[""ASSET1D-3"",""ASSET1D-8""]","公网 UI 试录通过;报告 artifacts/qa-flow/asset1d-public-trial-20260523-155346/summary.json;创建并保留审计:FA-2026-000001、EA-SYS-2026-000001、EA-REPO-2026-000001、EA-BKP-2026-000001;未写 EA-DOC,未上传文件、源码、Secret、完整配置或完整路径;回滚路径:如需清理,按测试标记归档或删除未被业务引用的试录资产。" ASSET1D-5,EA-DOC 历史索引脱敏 dry-run,"从本地公司保密索引读取最多 3 条脱敏元数据,生成 dry-run 报告,不写公网 OA。","dry-run 报告状态为 PASS_REDACTED_INDEX_DRY_RUN_READY;报告仅含 code、docType、hash、敏感级别和脱敏要求,不含原文、完整路径、Secret 或账号凭据。","受影响流程:历史资料索引 -> 脱敏预览 -> 人工判断是否允许写入;验证步骤:node scripts/qa/collect-asset-redacted-index.mjs --limit=3。","node",DONE,DONE,DONE,"scripts/qa/collect-asset-redacted-index.mjs|artifacts/qa-flow/asset-redacted-index-*","[""ASSET1D-1""]","最新 dry-run 报告 artifacts/qa-flow/asset-redacted-index-20260523040650/summary.json;627 行中预览 3 行,均为 confidential;不含原文、完整路径、Secret 或账号凭据;未写 OA。" -ASSET1D-6,EA-DOC 三条受控导入二次确认,"UI 试录和 dry-run 通过后,再向用户确认是否开启 ASSET_INDEX_IMPORT_WRITE=1 写入 3 条脱敏 EA-DOC。","未获得二次确认前不得写入;若用户批准,必须使用进程级 ASSET_INDEX_IMPORT_WRITE=1、管理员权限、必填主数据,并验证审计日志。","受影响流程:脱敏预览 -> 人工批准 -> 受控导入 -> 审计核验;验证步骤:未批准时记录 PAUSED_MANUAL_CONFIRMATION;批准后调用 POST /api/assets/import/redacted-doc-index 并核对 imported/skipped 和审计。","manual|node|curl",TODO,TODO,TODO,"server/src/routes/assets.js|external","[""ASSET1D-4"",""ASSET1D-5""]","当前计划不自动执行 live 导入;回滚路径为按导入批次删除未被业务引用的 EA-DOC 台账或改为 archived。" -ASSET1D-7,ASSET1D 收口与提交,"汇总本地门禁、部署结果、dry-run 报告、人工 UI 待办和二次确认状态,并用中文提交 Issue CSV 状态。","自动项完成并回填状态;人工项如未完成保持 TODO 或 PAUSED 说明;git commit 信息中文并引用 ASSET1D。","受影响流程:部署验收 -> 资产试录 -> 证据归档 -> 后续人工导入决策;验证步骤:git status;git diff --check;提交后 git status 干净。","git|manual",DONE,DONE,DONE,"issues/2026-05-23_10-42-03-oa-asset-deploy-and-trial-v1.csv","[""ASSET1D-2"",""ASSET1D-5""]","自动项已收口;ASSET1D-4 UI 试录和 ASSET1D-6 live 导入确认保持 TODO;本次提交只记录合同与事实状态,不伪造人工验收。" +ASSET1D-6,EA-DOC 三条受控导入二次确认,"UI 试录和 dry-run 通过后,再向用户确认是否开启 ASSET_INDEX_IMPORT_WRITE=1 写入 3 条脱敏 EA-DOC。","未获得二次确认前不得写入;若用户批准,必须使用进程级 ASSET_INDEX_IMPORT_WRITE=1、管理员权限、必填主数据,并验证审计日志。","受影响流程:脱敏预览 -> 人工批准 -> 受控导入 -> 审计核验;验证步骤:未批准时记录 PAUSED_MANUAL_CONFIRMATION;批准后调用 POST /api/assets/import/redacted-doc-index 并核对 imported/skipped 和审计。","manual|node|curl",TODO,TODO,TODO,"server/src/routes/assets.js|external","[""ASSET1D-4"",""ASSET1D-5""]","当前未执行 live 导入;等待用户 A/B/C 二次确认;回滚路径为按导入批次删除未被业务引用的 EA-DOC 台账或改为 archived。" +ASSET1D-7,ASSET1D 收口与提交,"汇总本地门禁、部署结果、dry-run 报告、人工 UI 待办和二次确认状态,并用中文提交 Issue CSV 状态。","自动项完成并回填状态;人工项如未完成保持 TODO 或 PAUSED 说明;git commit 信息中文并引用 ASSET1D。","受影响流程:部署验收 -> 资产试录 -> 证据归档 -> 后续人工导入决策;验证步骤:git status;git diff --check;提交后 git status 干净。","git|manual",DONE,DONE,DONE,"issues/2026-05-23_10-42-03-oa-asset-deploy-and-trial-v1.csv","[""ASSET1D-2"",""ASSET1D-5""]","ASSET1D-4 公网 UI 试录已完成;ASSET1D-6 live 导入确认保持 TODO;本次提交记录合同与事实状态,不伪造 EA-DOC 写入;回滚路径:提交前发现阻断则保持 TODO 并不推进 EA-DOC 写入。" +ASSET1D-8,资产创建 metadata 校验 500 修复,"公网 ASSET1D-4 UI 试录保存资产时,POST /api/assets 因 Zod v4 record schema 写法不兼容返回 500,需要修复为参数校验可解析 metadata。","资产创建接口带 metadata 对象时不再 500;公网 UI 可继续试录 FA/EA-SYS/EA-REPO/EA-BKP;不改变权限模型或数据迁移。","受影响流程:管理员新建资产 -> 填写 metadata/保密说明 -> 保存 -> 列表回看;验证步骤:cd server && node --check src/routes/assets.js;cd server && npm run test -- tests/unit/routes/assets.validation.test.js;公网重跑 scripts/qa/run-asset1d-public-trial.sh。","node|vitest|playwright",DONE,DONE,DONE,"server/src/routes/assets.js;server/tests/unit/routes/assets.validation.test.js","[""ASSET1D-3""]","已将 metadata schema 修正为 Zod v4 兼容写法并部署;目标单测通过,公网 UI 试录复跑通过;初始失败 requestId 56d66b2c-02b5-4a99-8d8f-709c1d76b190;回滚路径:恢复 metadata schema 写法并将 ASSET1D-4 保持 blocked。" diff --git a/scripts/qa/run-asset1d-public-trial.sh b/scripts/qa/run-asset1d-public-trial.sh new file mode 100755 index 00000000..23b283d4 --- /dev/null +++ b/scripts/qa/run-asset1d-public-trial.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +set -euo 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="${ASSET1D_REPORT_DIR:-$ROOT_DIR/artifacts/qa-flow/asset1d-public-trial-$(date +%Y%m%d-%H%M%S)}" +ENV_FILE="${ASSET1D_ENV_FILE:-$HOME/.hyb-oa/live-smoke.env}" + +mkdir -p "$REPORT_DIR" + +if [[ "${ASSET1D_SKIP_ENV_FILE:-0}" != "1" && -f "$ENV_FILE" ]]; then + set -a + # shellcheck disable=SC1090 + source "$ENV_FILE" + set +a + PUBLIC_BASE_URL="${LIVE_SMOKE_BASE_URL:-$PUBLIC_BASE_URL}" + echo "[asset1d] loaded masked live smoke env file" +fi + +if [[ -z "${LIVE_SMOKE_ADMIN_IDENTIFIER:-}" || -z "${LIVE_SMOKE_ADMIN_PASSWORD:-}" ]]; then + echo "[asset1d][ERROR] missing LIVE_SMOKE_ADMIN_IDENTIFIER/PASSWORD" >&2 + exit 1 +fi + +echo "[asset1d] base=$PUBLIC_BASE_URL" +echo "[asset1d] report=$REPORT_DIR" + +( + cd "$CLIENT_DIR" + PLAYWRIGHT_MANUAL=1 \ + ASSET1D_PUBLIC_TRIAL=1 \ + ASSET1D_REPORT_DIR="$REPORT_DIR" \ + E2E_BASE_URL="$PUBLIC_BASE_URL" \ + npx playwright test tests/e2e/manual/asset1d-public-trial.spec.ts --workers=1 --reporter=list +) + +node - "$REPORT_DIR" <<'NODE' +const fs = require("node:fs"); +const path = require("node:path"); +const reportDir = process.argv[2]; +const summaryPath = path.join(reportDir, "summary.json"); +if (!fs.existsSync(summaryPath)) { + console.error(`[asset1d][ERROR] missing summary: ${summaryPath}`); + process.exit(1); +} +const summary = JSON.parse(fs.readFileSync(summaryPath, "utf8")); +console.log(`[asset1d] status=${summary.status} created=${summary.createdAssets?.length ?? 0}`); +if (summary.status !== "PASS_ASSET_UI_TRIAL_READY") process.exit(1); +NODE diff --git a/scripts/qa/run-oa-real-use-daily.sh b/scripts/qa/run-oa-real-use-daily.sh index 0cd2c9e8..1c3fd38a 100755 --- a/scripts/qa/run-oa-real-use-daily.sh +++ b/scripts/qa/run-oa-real-use-daily.sh @@ -101,9 +101,22 @@ run_client_spec_if_present() { return 0 fi + local cooldown="${OAOPS1_E2E_COOLDOWN_SECONDS:-2}" + if [[ "$cooldown" =~ ^[0-9]+$ && "$cooldown" -gt 0 ]]; then + sleep "$cooldown" + fi run_client_finding_step "$name" "$@" npx playwright test "$spec" --workers=1 --reporter=list } +cooldown_login_step() { + local reason="$1" + local cooldown="${OAOPS1_LOGIN_STEP_COOLDOWN_SECONDS:-65}" + if [[ "$cooldown" =~ ^[0-9]+$ && "$cooldown" -gt 0 ]]; then + log "[oaops1] login cooldown ${cooldown}s before ${reason}" + sleep "$cooldown" + fi +} + load_default_env() { local env_file="${OAOPS1_ENV_FILE:-$HOME/.hyb-oa/live-smoke.env}" if [[ "${OAOPS1_SKIP_ENV_FILE:-0}" == "1" || ! -f "$env_file" ]]; then @@ -123,6 +136,7 @@ run_ready_and_runtime() { run_finding_step "live-smoke-env" env LIVE_SMOKE_BASE_URL="$PUBLIC_BASE_URL" node "$ROOT_DIR/scripts/qa/check-live-smoke-env.mjs" --json run_finding_step "live-readiness" env QA_ISSUE_ID=OAOPS1 LIVE_SMOKE_BASE_URL="$PUBLIC_BASE_URL" LIVE_SMOKE_READINESS_DIR="$REPORT_DIR/live-smoke-readiness" bash "$ROOT_DIR/scripts/qa/run-live-smoke-readiness.sh" run_finding_step "oa-only-smoke-readiness" env LIVE_SMOKE_BASE_URL="$PUBLIC_BASE_URL" OA_ONLY_SMOKE_READINESS_REPORT_DIR="$REPORT_DIR/oa-only-smoke-readiness" node "$ROOT_DIR/scripts/qa/check-oa-only-smoke-readiness.mjs" + cooldown_login_step "live-feature-flags" run_finding_step "live-feature-flags" env LIVE_SMOKE_BASE_URL="$PUBLIC_BASE_URL" node "$ROOT_DIR/scripts/qa/check-live-feature-flags.mjs" \ --expect payment_batch_v1=force_on \ --expect erp_sync=force_off \ @@ -144,14 +158,6 @@ const credentials = { identifier: process.env.LIVE_SMOKE_ADMIN_IDENTIFIER?.trim(), password: process.env.LIVE_SMOKE_ADMIN_PASSWORD?.trim(), }, - employee: { - identifier: process.env.LIVE_SMOKE_EMPLOYEE_IDENTIFIER?.trim(), - password: process.env.LIVE_SMOKE_EMPLOYEE_PASSWORD?.trim(), - }, - finance: { - identifier: process.env.LIVE_SMOKE_FINANCE_IDENTIFIER?.trim(), - password: process.env.LIVE_SMOKE_FINANCE_PASSWORD?.trim(), - }, }; const probes = [ @@ -161,12 +167,9 @@ const probes = [ { id: "reimbursement-summary", role: "admin", path: "/api/reimbursements/stats/summary", domain: "reimbursement" }, { id: "invoice-pool", role: "admin", path: "/api/invoice-pool?page=1&pageSize=5", domain: "invoice_ocr" }, { id: "purchase-requests", role: "admin", path: "/api/purchase-requests?page=1&pageSize=5", domain: "procurement" }, - { id: "contracts-registry", role: "admin", path: "/api/contracts-registry?page=1&pageSize=5", domain: "contracts" }, + { id: "contracts-registry", role: "admin", path: "/api/contracts-registry", domain: "contracts" }, { id: "projects", role: "admin", path: "/api/projects?page=1&pageSize=5", domain: "projects" }, - { id: "payment-batches", role: "finance", path: "/api/payment-batches?page=1&pageSize=5", domain: "payment_batch" }, - { id: "ap-ledger", role: "finance", path: "/api/ap-operations/reimbursement-ledger?page=1&pageSize=5", domain: "accounting_handoff" }, { id: "audit-logs", role: "admin", path: "/api/audit-logs?page=1&pageSize=5", domain: "audit" }, - { id: "employee-reimbursements", role: "employee", path: "/api/reimbursements?page=1&pageSize=5&mine=1", domain: "employee_scope" }, ]; function maskIdentifier(value) { @@ -281,6 +284,7 @@ async function main() { baseUrl, checkedAt: new Date().toISOString(), readOnly: true, + duplicateRoleChecks: "employee/finance/payment/AP are covered by oa-only-smoke-readiness to avoid repeated login throttling", roles: Object.fromEntries(Object.entries(roles).map(([role, state]) => [role, { status: state.status, reason: state.reason || null, @@ -510,5 +514,6 @@ trap finish EXIT load_default_env run_ready_and_runtime +cooldown_login_step "authenticated-readonly-probe" run_finding_step "authenticated-readonly-probe" run_authenticated_readonly_probe run_mobile_and_entry_matrix diff --git a/server/src/routes/assets.js b/server/src/routes/assets.js index cf2c94b2..82c441c2 100644 --- a/server/src/routes/assets.js +++ b/server/src/routes/assets.js @@ -81,7 +81,7 @@ const ASSET_CREATE_SCHEMA = z sourceHash: z.string().trim().max(128).nullable().optional(), verificationNo: z.string().trim().regex(/^VER-\d{5,}$/).max(32).nullable().optional(), confidentialityNote: z.string().trim().max(1000).nullable().optional(), - metadata: z.record(z.unknown()).nullable().optional(), + metadata: z.record(z.string(), z.unknown()).nullable().optional(), lastVerifiedAt: DATE_ONLY_SCHEMA.nullable().optional(), expiresAt: DATE_ONLY_SCHEMA.nullable().optional(), activatedAt: DATE_ONLY_SCHEMA.nullable().optional(), @@ -1667,4 +1667,7 @@ async function assetsRoutes(fastify) { module.exports = assetsRoutes; module.exports.__testables = { handleAssetLedgerSummaryErpSync, + parseAssetCreateBodyForTest(body) { + return normalizeAssetPayload(parseCreateBody(ASSET_CREATE_SCHEMA, body)); + }, }; diff --git a/server/tests/unit/routes/assets.validation.test.js b/server/tests/unit/routes/assets.validation.test.js new file mode 100644 index 00000000..73eea60b --- /dev/null +++ b/server/tests/unit/routes/assets.validation.test.js @@ -0,0 +1,31 @@ +const assetsRoutes = require('../../../src/routes/assets'); + +describe('assets route validation', () => { + const validPayload = { + name: 'TEST SMOKE ASSET1D FA', + assetType: 'fixed_asset', + sensitivityLevel: 'internal', + categoryId: '11111111-1111-4111-8111-111111111111', + legalEntityId: '22222222-2222-4222-8222-222222222222', + costCenterId: '33333333-3333-4333-8333-333333333333', + ownerEmployeeId: '44444444-4444-4444-8444-444444444444', + keeperEmployeeId: '55555555-5555-4555-8555-555555555555', + metadata: { + testMarker: 'TEST-SMOKE-ASSET1D', + sourceOfTruth: 'git', + }, + }; + + it('accepts ASSET1D metadata objects on asset create payloads', () => { + expect(assetsRoutes.__testables.parseAssetCreateBodyForTest(validPayload)).toEqual( + expect.objectContaining({ + name: 'TEST SMOKE ASSET1D FA', + assetType: 'fixed_asset', + metadata: { + testMarker: 'TEST-SMOKE-ASSET1D', + sourceOfTruth: 'git', + }, + }), + ); + }); +});