修复生产回填审计标识(07-28-ceo-finance-fallback)
这个提交包含在:
@@ -8,6 +8,7 @@
|
||||
- [x] 更新工作流、规则 API、报销提交回归测试,覆盖 CEO 三档唯一 `finance`、普通员工链、自审、无持有人和 legacy `finance_mgr`。
|
||||
- [x] 修正 `seed:config` 对既有审批规则的删除重建副作用;仅空库创建默认规则,生产既有规则保留到独立原子回填阶段。
|
||||
- [x] 新增默认 dry-run、双确认 apply 的生产规则回填脚本;事务内锁定并校验三档规则、CEO/财务/admin/binding 基线,更新三条规则、写三条脱敏审计并复核受保护计数。
|
||||
- [x] 生产 dry-run 发现原生 `audit_logs` 插入未携带 Prisma 客户端侧 UUID;事务已回滚。脚本已改为显式写入审计 UUID,并新增执行级回归覆盖三条审计 SQL 与 dry-run 回滚。
|
||||
- [x] 更新当前业务事实源,区分目标、代码部署和生产规则回填状态。
|
||||
|
||||
## 2. Local Validation
|
||||
@@ -21,6 +22,7 @@
|
||||
- [x] `python3 ./.trellis/scripts/task.py validate 07-28-ceo-finance-fallback`
|
||||
- [x] `node scripts/qa/check-trellis-governance.mjs && git diff --check`
|
||||
- [x] `bash scripts/check-minimal.sh`(167 个测试文件、1053 项后端测试、前端 build 与矩阵门禁通过)
|
||||
- [x] dry-run 缺陷修复后复跑 Prisma validate 与 5 个聚焦测试文件(59 项通过)。
|
||||
|
||||
## 3. Review Gate
|
||||
|
||||
@@ -36,6 +38,8 @@
|
||||
- [ ] 只读解析 CEO 小/中/大额快照均为唯一 `finance`,普通员工链符合目标;不创建真实报销。
|
||||
- [ ] 复核无角色、主管、binding、历史快照或企微写入变化。
|
||||
|
||||
部署执行备注:`3594872` 已通过标准部署、migration、runtime/external-deps smoke、独立公网构建号和前端 bundle 核验;同时将附件从同步树内迁至独立数据目录并保留校验一致的回退副本,自动备份/WARCH3 timer 已刷新运行身份。首次生产 dry-run 因审计表 UUID 缺失失败并完整回滚;需部署后续修复提交后重新完成本节全部勾选。
|
||||
|
||||
## 5. Stop Conditions
|
||||
|
||||
- 生产 active `finance` 不是唯一非 CEO 持有人,或目标人员 inactive/unbound。
|
||||
|
||||
@@ -215,12 +215,12 @@ async function execute(options, pool = new Pool({ connectionString: process.env.
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO audit_logs
|
||||
(request_id, actor_id, action, target_type, target_id, ip_address, user_agent, extra)
|
||||
(id, request_id, actor_id, action, target_type, target_id, ip_address, user_agent, extra)
|
||||
VALUES
|
||||
($1, $2::uuid, 'APPROVAL_RULE_UPDATED', 'approval_rule', $3::uuid, NULL,
|
||||
'controlled-script/07-28-ceo-finance-fallback', $4::jsonb)
|
||||
($1::uuid, $2, $3::uuid, 'APPROVAL_RULE_UPDATED', 'approval_rule', $4::uuid, NULL,
|
||||
'controlled-script/07-28-ceo-finance-fallback', $5::jsonb)
|
||||
`,
|
||||
[requestId, admins[0].id, item.id, JSON.stringify({
|
||||
[crypto.randomUUID(), requestId, admins[0].id, item.id, JSON.stringify({
|
||||
task: "07-28-ceo-finance-fallback",
|
||||
band: item.band,
|
||||
before: item.before,
|
||||
@@ -298,6 +298,7 @@ module.exports = {
|
||||
TARGET_BY_BAND,
|
||||
assertPeopleBaseline,
|
||||
buildRulePlan,
|
||||
execute,
|
||||
getBand,
|
||||
parseArgs,
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const {
|
||||
assertPeopleBaseline,
|
||||
buildRulePlan,
|
||||
execute,
|
||||
parseArgs,
|
||||
} = require("../../../src/scripts/apply-ceo-finance-fallback");
|
||||
|
||||
@@ -100,4 +101,83 @@ describe("CEO finance fallback production apply contract", () => {
|
||||
expect(parseArgs(["--apply", "--format=json"])).toEqual({ apply: true, format: "json" });
|
||||
expect(() => parseArgs(["--force"])).toThrow("Unsupported argument");
|
||||
});
|
||||
|
||||
it("supplies an explicit UUID for each raw audit-log insert", async () => {
|
||||
const ruleIds = [
|
||||
"11111111-1111-4111-8111-111111111111",
|
||||
"22222222-2222-4222-8222-222222222222",
|
||||
"33333333-3333-4333-8333-333333333333",
|
||||
];
|
||||
const sourceRows = currentRows().map((row, index) => ({ ...row, id: ruleIds[index] }));
|
||||
const targetRows = sourceRows.map((row) => ({
|
||||
...row,
|
||||
level2_role: row.id === ruleIds[2] ? "ceo" : "finance",
|
||||
level3_role: row.id === ruleIds[2] ? "finance" : null,
|
||||
owner_self_fallback_role: "finance",
|
||||
}));
|
||||
const adminId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
|
||||
const auditCalls = [];
|
||||
const transactionCommands = [];
|
||||
let ruleSelectCount = 0;
|
||||
let invariantCount = 0;
|
||||
const client = {
|
||||
query: vi.fn(async (sql, params = []) => {
|
||||
const normalized = String(sql).replace(/\s+/g, " ").trim();
|
||||
if (normalized.startsWith("BEGIN ") || normalized.startsWith("ROLLBACK")) {
|
||||
transactionCommands.push(normalized);
|
||||
return {};
|
||||
}
|
||||
if (normalized.includes("FROM approval_rules")) {
|
||||
ruleSelectCount += 1;
|
||||
return { rows: ruleSelectCount === 1 ? sourceRows : targetRows };
|
||||
}
|
||||
if (normalized.includes("JOIN employee_roles selected_role")) {
|
||||
if (params[0] === "ceo") {
|
||||
return { rows: [{ id: "ceo-id", direct_manager_id: null, has_wecom_binding: true, roles: ["ceo"] }] };
|
||||
}
|
||||
if (params[0] === "finance") {
|
||||
return { rows: [{ id: "finance-id", direct_manager_id: null, has_wecom_binding: true, roles: ["finance"] }] };
|
||||
}
|
||||
return { rows: [{ id: adminId, direct_manager_id: null, has_wecom_binding: false, roles: ["admin"] }] };
|
||||
}
|
||||
if (normalized.includes("AS active_employees")) {
|
||||
invariantCount += 1;
|
||||
return { rows: [{ active_employees: 6, active_role_rows: 8, active_bindings: 6, workflow_snapshots: 0 }] };
|
||||
}
|
||||
if (normalized.startsWith("UPDATE approval_rules")) return { rowCount: 1, rows: [{ id: params[0] }] };
|
||||
if (normalized.startsWith("INSERT INTO audit_logs")) {
|
||||
auditCalls.push({ sql: normalized, params });
|
||||
return { rowCount: 1 };
|
||||
}
|
||||
throw new Error(`unexpected query: ${normalized}`);
|
||||
}),
|
||||
release: vi.fn(),
|
||||
};
|
||||
const pool = { connect: vi.fn(async () => client), end: vi.fn(async () => {}) };
|
||||
|
||||
await expect(execute({ apply: false, format: "json" }, pool)).resolves.toMatchObject({
|
||||
status: "DRY_RUN_PASS",
|
||||
rules: 3,
|
||||
auditRows: 0,
|
||||
});
|
||||
expect(invariantCount).toBe(2);
|
||||
expect(transactionCommands).toEqual([
|
||||
"BEGIN ISOLATION LEVEL SERIALIZABLE",
|
||||
"ROLLBACK",
|
||||
]);
|
||||
expect(auditCalls).toHaveLength(3);
|
||||
for (const [index, call] of auditCalls.entries()) {
|
||||
expect(call.sql).toContain("(id, request_id, actor_id");
|
||||
expect(call.sql).toContain("VALUES ($1::uuid, $2, $3::uuid");
|
||||
expect(call.params[0]).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i);
|
||||
expect(call.params[1]).toMatch(/^ceo-finance-fallback-[0-9a-f-]+$/i);
|
||||
expect(call.params[2]).toBe(adminId);
|
||||
expect(call.params[3]).toBe(ruleIds[index]);
|
||||
expect(JSON.parse(call.params[4])).toMatchObject({
|
||||
task: "07-28-ceo-finance-fallback",
|
||||
band: ["small", "medium", "large"][index],
|
||||
});
|
||||
expect(call.params).toHaveLength(5);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
在新工单中引用
屏蔽一个用户