ASSET1D/OAOPS1 完成资产公网试录与巡检降噪
这个提交包含在:
@@ -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<string, string>;
|
||||
}> = [
|
||||
{
|
||||
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");
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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 脚本稳定性,不改变业务权限、不写企业微信、不上传真实业务附件。"
|
||||
|
||||
|
@@ -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。"
|
||||
|
||||
|
+50
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户