From 07d176c536f949554c597885c3811748930734ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8C=85=E5=91=A8=E6=B6=9B?= Date: Thu, 3 Sep 2026 22:56:56 -0700 Subject: [PATCH 1/2] =?UTF-8?q?fix(hooks):=20=E6=B5=81=E7=A8=8B=E6=8C=89?= =?UTF-8?q?=E9=92=AE=E6=8C=89=E5=8F=91=E8=B5=B7=E4=BA=BA=E6=A0=A1=E9=AA=8C?= =?UTF-8?q?=E5=B2=97=E4=BD=8D,=E7=B3=BB=E7=BB=9F=E5=85=8D=E6=A3=80?= =?UTF-8?q?=E6=94=B6=E7=AA=84=E5=88=B0=E6=97=A0=E5=8F=91=E8=B5=B7=E4=BA=BA?= =?UTF-8?q?=E7=9A=84=E7=BA=AF=E7=B3=BB=E7=BB=9F=E5=86=99=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 平台 17.2.0 执行动作体(按钮)用的上下文是「{ ...调用者上下文, isSystem: true }」, 发起人的 userId 原样保留(见 objectstack-ai/objectstack#2849)。于是「用户点了按钮」 和「系统自己写」在 isSystem 这一位上完全一样,以它为闸的岗位校验对按钮路径整体失效: 人力审核能越过领导审批节点通过,部门填报人员能审批和归档,加减分的岗位分离也一并落空。 经 REST 直接写入不受影响 —— 同一条规则在两条路径上是两个口径。 判据换成「有没有发起人」:isSystemWrite = 有系统标记且 actorId 为空。真正的系统写入 (种子、脚本、hook 内部自动推进)没有发起人,这一位把两者分得开。hasPosition 一处收口, sheet / check-task / bonus / adjustment 四个 hook 的岗位闸同时恢复。 hook 内部自发的两处写入(全部分公司确认后自动推进填报单、驳回时重置核对任务)改用 sysNoActor —— 在 sudo 基础上摘掉发起人、保留租户与事务信息。它们不是任何人点出来的, 按发起人校验既没有对象也没有意义;而且节点审核岗位按方案可改(《设计方案》10.1 第 9 条), 不能指望「最后一个确认的人恰好持有该节点岗位」。 非岗位类的 isSystem 闸中,按钮动作体够得到的(加减分与数据调整的归档锁、已批准锁、 填报单的直改 status 与归档锁)同口径收紧;够不到的(填报单新建/删除保护、明细锁定、 快照与结果不可变)按原样保留并在代码里逐处写明理由 —— 收紧会打断发布、调整落地与 归档快照这些带发起人的系统写入。 按钮可见性未按岗位收敛:动作 visible 的 current_user.positions 装的是 auth 层角色, 拿不到业务岗位,已上报 objectstack-ai/objectstack#15136,hook 拦截为唯一防线。 Co-Authored-By: Claude Fable 5.1 --- src/hooks/adjustment.hook.ts | 8 +- src/hooks/bonus.hook.ts | 8 +- src/hooks/check-task.hook.ts | 10 +- src/hooks/sheet.hook.ts | 31 +++- src/hooks/util.ts | 41 ++++- test/position-guard.test.ts | 304 +++++++++++++++++++++++++++++++++++ 6 files changed, 385 insertions(+), 17 deletions(-) create mode 100644 test/position-guard.test.ts diff --git a/src/hooks/adjustment.hook.ts b/src/hooks/adjustment.hook.ts index fc986c6..1c412da 100644 --- a/src/hooks/adjustment.hook.ts +++ b/src/hooks/adjustment.hook.ts @@ -1,5 +1,5 @@ import type { Hook, HookContext } from '@objectstack/spec/data'; -import { actorId, fail, findById, hasPosition, isSystem, merged, nowIso, recordId, sys, toNumber, writeReview } from './util.js'; +import { actorId, fail, findById, hasPosition, isSystemWrite, merged, nowIso, recordId, sys, toNumber, writeReview } from './util.js'; import { regenerateResults } from '../services/results-service.js'; import { provisionPlanSharing } from '../services/sharing-service.js'; @@ -43,7 +43,9 @@ export const AdjustmentHook: Hook = { fail('保存调整申请失败:所选指标明细不属于该填报单。请重新选择。', 'KPI_ADJ_SHEET_MISMATCH'); } const sheet = await findById(api, 'kpi_entry_sheet', line.sheet); - if (sheet?.status === 'archived' && !isSystem(ctx)) { + // 免检只给纯系统写入(无发起人):按钮的动作体带发起人以受信任身份写入, + // 只看 isSystem 会让「提交审批 / 批准并落地 / 否决」按钮绕过归档锁。 + if (sheet?.status === 'archived' && !isSystemWrite(ctx)) { fail('保存调整申请失败:填报单已归档,数据已锁定不可再改。', 'KPI_ADJ_ARCHIVED'); } @@ -56,7 +58,7 @@ export const AdjustmentHook: Hook = { return; } - if (prev.status === 'approved' && !isSystem(ctx)) { + if (prev.status === 'approved' && !isSystemWrite(ctx)) { const touched = Object.keys(input).filter((k) => k !== 'id' && input[k] !== prev[k]); if (touched.length) fail('修改调整申请失败:已批准并落地的调整不能再修改。如需再次更正,请新建调整申请。', 'KPI_ADJ_LOCKED'); return; diff --git a/src/hooks/bonus.hook.ts b/src/hooks/bonus.hook.ts index 82213ff..8f83ef7 100644 --- a/src/hooks/bonus.hook.ts +++ b/src/hooks/bonus.hook.ts @@ -1,5 +1,5 @@ import type { Hook, HookContext } from '@objectstack/spec/data'; -import { actorId, fail, findById, hasPosition, isSystem, merged, nowIso, sys, toNumber } from './util.js'; +import { actorId, fail, findById, hasPosition, isSystemWrite, merged, nowIso, sys, toNumber } from './util.js'; import { regenerateResults } from '../services/results-service.js'; import { provisionPlanSharing } from '../services/sharing-service.js'; @@ -66,7 +66,9 @@ export const BonusHook: Hook = { const api = sys(ctx); const sheet = await findById(api, 'kpi_entry_sheet', row.sheet); - if (sheet?.status === 'archived' && !isSystem(ctx)) { + // 免检只给纯系统写入(无发起人):按钮的动作体带发起人以受信任身份写入, + // 只看 isSystem 会让「批准 / 否决」按钮绕过归档锁。 + if (sheet?.status === 'archived' && !isSystemWrite(ctx)) { fail('修改加减分失败:填报单已归档,数据已锁定不可再改。', 'KPI_BONUS_ARCHIVED'); } const points = toNumber(row.points) ?? 0; @@ -79,7 +81,7 @@ export const BonusHook: Hook = { input.approved_by = actorId(ctx); input.approved_at = nowIso(); } - } else if (ctx.event === 'beforeUpdate' && prev.status === 'approved' && !isSystem(ctx)) { + } else if (ctx.event === 'beforeUpdate' && prev.status === 'approved' && !isSystemWrite(ctx)) { const touched = Object.keys(input).filter((k) => !['id', 'signed_points'].includes(k) && input[k] !== prev[k]); if (touched.length) fail('修改加减分失败:已批准的加减分不能再修改。如需更正,请否决后重新申请。', 'KPI_BONUS_LOCKED'); } diff --git a/src/hooks/check-task.hook.ts b/src/hooks/check-task.hook.ts index 8141a83..6095d7f 100644 --- a/src/hooks/check-task.hook.ts +++ b/src/hooks/check-task.hook.ts @@ -1,5 +1,5 @@ import type { Hook, HookContext } from '@objectstack/spec/data'; -import { actorId, fail, findById, hasPosition, isSystem, merged, nowIso, sys, writeReview } from './util.js'; +import { actorId, fail, findById, hasPosition, isSystemWrite, merged, nowIso, sys, sysNoActor, writeReview } from './util.js'; /** 核对任务:确认 / 争议时盖章、留痕;全部确认后自动推进填报单(蓝图 B-M4-04)。 */ export const CheckTaskDecideHook: Hook = { @@ -12,7 +12,9 @@ export const CheckTaskDecideHook: Hook = { const input = ctx.input as Record; const prev = (ctx.previous ?? {}) as Record; if (!('status' in input) || input.status === prev.status) return; - if (isSystem(ctx)) return; + // 免检只给纯系统写入(无发起人,例如驳回时的批量重置)。「确认无误 / 提出争议」两个 + // 按钮的动作体带着发起人以受信任身份写入,只看 isSystem 会让岗位闸对按钮整体失效。 + if (isSystemWrite(ctx)) return; if (input.status === 'pending') { fail('核对失败:已确认的核对不能撤回。如需重新核对,请由审核人驳回填报单。', 'KPI_CHECK_REVERT'); } @@ -56,7 +58,9 @@ export const CheckTaskAfterDecideHook: Hook = { if (pending === 0 && disputed === 0) { const sheet = await findById(api, 'kpi_entry_sheet', sheetId); if (sheet?.status === 'branch_checking') { - await api.object('kpi_entry_sheet').updateById(sheetId, { pending_action: 'approve', action_reason: '全部分公司已确认,系统自动推进' }); + // 自动推进是系统动作:节点审核岗位按方案可改(《设计方案》10.1 第 9 条), + // 不能指望「最后一个确认的人恰好持有该节点岗位」,必须以无发起人的系统上下文写入。 + await sysNoActor(ctx).object('kpi_entry_sheet').updateById(sheetId, { pending_action: 'approve', action_reason: '全部分公司已确认,系统自动推进' }); } } }, diff --git a/src/hooks/sheet.hook.ts b/src/hooks/sheet.hook.ts index d53862d..c67d7dd 100644 --- a/src/hooks/sheet.hook.ts +++ b/src/hooks/sheet.hook.ts @@ -1,5 +1,5 @@ import type { Hook, HookContext } from '@objectstack/spec/data'; -import { actorId, fail, findById, hasPosition, isSystem, merged, nowIso, recordId, sys, writeReview } from './util.js'; +import { actorId, fail, findById, hasPosition, isSystem, isSystemWrite, merged, nowIso, recordId, sys, sysNoActor, writeReview } from './util.js'; import { requiredPositionFor, STATUS_LABEL, transition, type PlanStepDef, type SheetAction, type SheetStatus } from '../lib/workflow.js'; import { regenerateResults } from '../services/results-service.js'; import { provisionPlanSharing } from '../services/sharing-service.js'; @@ -15,7 +15,16 @@ export async function loadPlanSteps(api: ReturnType, planId: string) })); } -/** 填报单由方案发布生成;非系统上下文不能手工新建。 */ +/** 流程暂存字段:按钮写进来、after 阶段清空,不承载业务数据。 */ +const SCRATCH_FIELDS = new Set(['pending_action', 'action_reason']); + +/** + * 填报单由方案发布生成;非系统上下文不能手工新建。 + * + * 这里的免检仍按 `isSystem` 而不是「无发起人」:发布是**用户点**「发布方案」触发的, + * 生成填报单由方案 hook 以带发起人的系统上下文完成,收紧会直接打断发布。填报单 + * 也没有任何 insert 型按钮,动作体到不了这条分支;REST 手工新建走非系统上下文,照拦。 + */ export const SheetInsertGuardHook: Hook = { name: 'kpi_sheet_insert_guard', label: '填报单新建保护', @@ -53,11 +62,15 @@ export const SheetTransitionHook: Hook = { const action = input.pending_action as SheetAction | null | undefined; if (!action) { - if ('status' in input && input.status !== prev.status && !isSystem(ctx)) { + // 免检只给纯系统写入(无发起人)。按钮的动作体带着发起人以「受信任」身份写入, + // 只看 isSystem 会把它当系统写入放行(objectstack#2849),这两条锁就等于没上。 + if ('status' in input && input.status !== prev.status && !isSystemWrite(ctx)) { fail('修改填报单状态失败:状态由流程推进,不能直接修改。请使用提交、审核通过、驳回或归档按钮。', 'KPI_SHEET_DIRECT_STATUS'); } - if (prev.status === 'archived' && !isSystem(ctx)) { - const touched = Object.keys(input).filter((k) => k !== 'id' && input[k] !== prev[k]); + if (prev.status === 'archived' && !isSystemWrite(ctx)) { + // pending_action / action_reason 是流程的暂存字段,after 阶段的清场写入只动这两个, + // 不算「改数据」;带真实动作的写入走不到这个分支(上面 `action` 为真时已分流)。 + const touched = Object.keys(input).filter((k) => !SCRATCH_FIELDS.has(k) && k !== 'id' && input[k] !== prev[k]); if (touched.length) fail('修改填报单失败:该填报单已归档,数据已锁定不可再改。', 'KPI_SHEET_ARCHIVED'); } return; @@ -69,6 +82,8 @@ export const SheetTransitionHook: Hook = { const result = transition(steps, fromStatus, action); if (!result.ok) fail(`操作失败:${result.message}`, 'KPI_SHEET_TRANSITION'); + // 岗位闸:按**发起人**校验,不看写入是否带系统标记 —— 按钮路径与 REST 路径同一口径 + // (hasPosition 的免检只留给无发起人的纯系统写入)。 const required = requiredPositionFor(steps, fromStatus, action); if (!(await hasPosition(ctx, required))) { fail(`操作失败:当前节点「${result.atStepDef?.label ?? STATUS_LABEL[fromStatus]}」需要由对应岗位处理,你没有该岗位。如需处理,请联系管理员分配岗位。`, 'KPI_SHEET_POSITION'); @@ -171,8 +186,11 @@ export const SheetAfterTransitionHook: Hook = { await api.object('kpi_check_task').insert({ name: `${sheetName} · ${bu?.name ?? b.subject} 核对`, sheet: id, plan: now.plan ?? prev.plan, branch: b.subject, status: 'pending' }); } if (action === 'reject') { + // 重置是系统动作,不是「谁把核对撤回了」:用无发起人的系统上下文写, + // 否则会被核对 hook 的「已确认不能撤回」按发起人拦下。 + const sysApi = sysNoActor(ctx); for (const t of existing) { - if (t.status !== 'pending') await api.object('kpi_check_task').updateById(String(t.id), { status: 'pending', comment: null, decided_by: null, decided_at: null }); + if (t.status !== 'pending') await sysApi.object('kpi_check_task').updateById(String(t.id), { status: 'pending', comment: null, decided_by: null, decided_at: null }); } } } @@ -209,6 +227,7 @@ export const SheetDeleteGuardHook: Hook = { events: ['beforeDelete'], priority: 100, handler: async (ctx: HookContext) => { + // 没有删除型按钮,动作体到不了这条分支;删除一律走非系统上下文,免检维持 isSystem。 if (isSystem(ctx)) return; const prev = (ctx.previous ?? {}) as Record; if (prev.status && prev.status !== 'draft') { diff --git a/src/hooks/util.ts b/src/hooks/util.ts index c02d755..10def4c 100644 --- a/src/hooks/util.ts +++ b/src/hooks/util.ts @@ -36,6 +36,25 @@ export function sys(ctx: HookContext): Api { return api.sudo ? api.sudo() : api; } +/** + * 无发起人的系统上下文:在 `sudo()` 的基础上摘掉发起用户,租户与事务信息原样保留。 + * + * 给 hook **内部**自发的写入用(核对全部完成后自动推进填报单、驳回时重置核对任务)。 + * 这类写入不是任何人「点」出来的,按发起人校验岗位既没有对象也没有意义 —— 摘掉发起人 + * 让它落在 {@link isSystemWrite} 这一侧,规则明确免检,而不是靠「恰好这个人有那个岗位」 + * 蒙混过关(节点审核岗位是按方案配置的,见《设计方案》10.1 第 9 条)。 + */ +export function sysNoActor(ctx: HookContext): Api { + const api = ctx.api as unknown as (Api & { sudo?: () => Api }) | undefined; + if (!api) fail('系统内部错误:数据访问上下文不可用,请稍后重试或联系管理员。', 'KPI_NO_API'); + if (!api.sudo) return api; + const elevated = api.sudo() as Api & { executionContext?: Record }; + if (elevated && typeof elevated === 'object' && elevated.executionContext) { + elevated.executionContext = { ...elevated.executionContext, isSystem: true, userId: undefined }; + } + return elevated; +} + /** 以当前用户上下文访问(受数据范围约束)。 */ export function user(ctx: HookContext): Api { const api = ctx.api as unknown as Api | undefined; @@ -51,6 +70,20 @@ export function actorId(ctx: HookContext): string | null { return (ctx.user?.id as string | undefined) ?? (ctx.session?.userId as string | undefined) ?? null; } +/** + * 是否是**纯系统写入** —— 带系统标记**并且**没有发起用户。 + * + * 为什么不能只看 `isSystem`:平台执行动作体(按钮)时用的上下文是 + * `{ ...调用者上下文, isSystem: true }`,发起人的 `userId` 原样保留。也就是说 + * 「用户点了按钮」和「系统自己写」在 `isSystem` 这一位上完全一样,只用它做闸, + * 任何岗位的人点按钮都会被当成系统写入放行(objectstack-ai/objectstack#2849)。 + * 真正的系统写入 —— 种子、脚本、hook 内部自动推进 —— 是**没有发起人**的, + * 这一位才把两者分得开。 + */ +export function isSystemWrite(ctx: HookContext): boolean { + return isSystem(ctx) && actorId(ctx) === null; +} + /** 当前记录的合并视图(更新时 = 旧记录 + 本次改动)。 */ export function merged>(ctx: HookContext): T { return { ...((ctx.previous ?? {}) as Record), ...((ctx.input ?? {}) as Record) } as T; @@ -63,10 +96,14 @@ export function recordId(ctx: HookContext): string | null { /** * 是否持有岗位(业务规则,经 ctx.api 通道查询 sys_user_position;平台明示 - * session.positions 只作描述、不作授权输入)。系统上下文放行;`kpi_admin` 通行。 + * session.positions 只作描述、不作授权输入)。`kpi_admin` 通行。 + * + * 免检只留给**纯系统写入**({@link isSystemWrite}:有系统标记且没有发起人)。 + * 带发起人的写入一律按发起人校验 —— 无论它是从 REST 直接进来的,还是经按钮的 + * 动作体以「受信任」身份进来的,两条路径同一口径。 */ export async function hasPosition(ctx: HookContext, position: string | null): Promise { - if (isSystem(ctx) || !position) return true; + if (!position || isSystemWrite(ctx)) return true; const uid = actorId(ctx); if (!uid) return false; const api = sys(ctx); diff --git a/test/position-guard.test.ts b/test/position-guard.test.ts new file mode 100644 index 0000000..fde2213 --- /dev/null +++ b/test/position-guard.test.ts @@ -0,0 +1,304 @@ +import { describe, expect, it } from 'vitest'; +import type { Hook, HookContext } from '@objectstack/spec/data'; +import { SheetTransitionHook } from '../src/hooks/sheet.hook.js'; +import { CheckTaskDecideHook } from '../src/hooks/check-task.hook.js'; +import { BonusHook } from '../src/hooks/bonus.hook.js'; +import { hasPosition, isSystemWrite, type KpiError } from '../src/hooks/util.js'; + +/** + * 流程按钮的岗位闸(工作项 #28)。 + * + * 平台执行动作体(按钮)时的上下文是「{ ...调用者上下文, isSystem: true }」—— + * 系统标记为真,发起人的 userId 原样保留。所以这里的三类用例分别锁住: + * 1. 按钮路径(isSystem + 发起人):非本节点岗位必须被拒; + * 2. 按钮路径(isSystem + 发起人):本节点岗位必须放行; + * 3. 纯系统写入(isSystem,无发起人):必须放行 —— 发布、重算、hook 内部自动推进 + * 不能被岗位闸误伤。 + * 另外锁住第 4 类:REST 路径(非系统 + 发起人)与按钮路径结论一致,同一口径。 + */ + +const STEPS = [ + { id: 's1', plan: 'plan1', seq: 1, step_type: 'dept_submit', label: '部门填报', approver_position: 'kpi_dept_reporter' }, + { id: 's2', plan: 'plan1', seq: 2, step_type: 'branch_check', label: '分公司核对', approver_position: 'kpi_branch_checker' }, + { id: 's3', plan: 'plan1', seq: 3, step_type: 'hr_review', label: '人力审核', approver_position: 'kpi_hr_reviewer' }, + { id: 's4', plan: 'plan1', seq: 4, step_type: 'leader_approve', label: '领导审批', approver_position: 'kpi_exec_leader' }, +]; + +interface StoreShape { + [object: string]: Array>; +} + +function matches(row: Record, where: Record | undefined): boolean { + if (!where) return true; + return Object.entries(where).every(([k, v]) => { + if (v && typeof v === 'object' && '$in' in v) return (v.$in as unknown[]).map(String).includes(String(row[k])); + if (v === null) return row[k] === null || row[k] === undefined; + return String(row[k]) === String(v); + }); +} + +/** 最小 ctx.api 替身:只实现 hook 真正会用到的读写。没有 sudo,所以 sys()/sysNoActor() 退化为它自己。 */ +function fakeApi(store: StoreShape) { + const writes: Array<{ object: string; id: string; data: Record }> = []; + const api = { + object(name: string) { + const rows = () => store[name] ?? []; + return { + async find(q?: Record) { + return rows().filter((r) => matches(r, q?.where)); + }, + async findOne(q?: Record) { + return rows().find((r) => matches(r, q?.where)) ?? null; + }, + async count(q?: Record) { + return rows().filter((r) => matches(r, q?.where)).length; + }, + async insert(data: Record) { + const row = { id: `new_${(store[name] ?? []).length + 1}`, ...data }; + (store[name] ??= []).push(row); + return row; + }, + async update(data: Record) { + return data; + }, + async updateById(id: string, data: Record) { + writes.push({ object: name, id, data }); + const row = rows().find((r) => String(r.id) === String(id)); + if (row) Object.assign(row, data); + return row; + }, + async delete() { + return null; + }, + }; + }, + }; + return { api, writes }; +} + +type Session = { isSystem?: true; userId?: string }; + +/** 按钮路径:平台以「受信任」身份跑动作体,系统标记为真、发起人仍在。 */ +const viaButton = (userId: string): Session => ({ isSystem: true, userId }); +/** REST 路径:调用者身份原样进入。 */ +const viaRest = (userId: string): Session => ({ userId }); +/** 纯系统写入:有系统标记,没有发起人(种子、脚本、hook 内部自动推进)。 */ +const viaSystem = (): Session => ({ isSystem: true }); + +function baseStore(overrides: Partial = {}): StoreShape { + return { + kpi_plan_step: STEPS, + kpi_plan_subject: [{ id: 'ps1', plan: 'plan1', subject: 'bu_a', subject_type: 'dept' }], + kpi_check_task: [], + kpi_entry_line: [], + sys_member: [], + sys_user_position: [ + { id: 'p1', user_id: 'u_reporter', position: 'kpi_dept_reporter' }, + { id: 'p2', user_id: 'u_checker', position: 'kpi_branch_checker' }, + { id: 'p3', user_id: 'u_hr', position: 'kpi_hr_reviewer' }, + { id: 'p4', user_id: 'u_hrhead', position: 'kpi_hr_head' }, + { id: 'p5', user_id: 'u_leader', position: 'kpi_exec_leader' }, + ], + ...overrides, + }; +} + +function sheetCtx(opts: { + session: Session; + status: string; + input: Record; + store?: StoreShape; +}): { ctx: HookContext; input: Record } { + const store = opts.store ?? baseStore(); + const { api } = fakeApi(store); + const input = { id: 'sheet1', ...opts.input }; + const ctx = { + object: 'kpi_entry_sheet', + event: 'beforeUpdate', + input, + previous: { id: 'sheet1', plan: 'plan1', subject: 'bu_a', status: opts.status }, + session: opts.session, + user: opts.session.userId ? { id: opts.session.userId } : undefined, + api, + } as unknown as HookContext; + return { ctx, input }; +} + +/** hook 的 `handler` 在规格里是「函数或字符串」的联合类型;单测只跑函数形态。 */ +const run = (hook: Hook) => hook.handler as (ctx: HookContext) => Promise; + +async function codeOf(fn: () => Promise): Promise { + try { + await fn(); + return null; + } catch (err) { + return (err as KpiError).code ?? null; + } +} + +describe('isSystemWrite —— 系统标记不等于系统写入', () => { + const ctxWith = (session: Session, user?: { id: string }) => + ({ session, user } as unknown as HookContext); + + it('按钮路径带发起人:不是系统写入', () => { + expect(isSystemWrite(ctxWith(viaButton('u_hr')))).toBe(false); + }); + it('无发起人的系统上下文:是系统写入', () => { + expect(isSystemWrite(ctxWith(viaSystem()))).toBe(true); + }); + it('普通用户写入:不是系统写入', () => { + expect(isSystemWrite(ctxWith(viaRest('u_hr')))).toBe(false); + }); + it('ctx.user 存在而 session 没有 userId 时也认得出发起人', () => { + expect(isSystemWrite(ctxWith({ isSystem: true }, { id: 'u_hr' }))).toBe(false); + }); +}); + +describe('hasPosition —— 免检只留给纯系统写入', () => { + it('按钮路径的发起人没有该岗位:不放行', async () => { + const { api } = fakeApi(baseStore()); + const ctx = { session: viaButton('u_hr'), user: { id: 'u_hr' }, api } as unknown as HookContext; + expect(await hasPosition(ctx, 'kpi_exec_leader')).toBe(false); + }); + it('按钮路径的发起人持有该岗位:放行', async () => { + const { api } = fakeApi(baseStore()); + const ctx = { session: viaButton('u_leader'), user: { id: 'u_leader' }, api } as unknown as HookContext; + expect(await hasPosition(ctx, 'kpi_exec_leader')).toBe(true); + }); + it('无发起人的系统写入:放行', async () => { + const { api } = fakeApi(baseStore()); + const ctx = { session: viaSystem(), api } as unknown as HookContext; + expect(await hasPosition(ctx, 'kpi_exec_leader')).toBe(true); + }); +}); + +describe('填报单流程推进 —— 按钮路径按发起人校验岗位', () => { + it('人力审核对「领导审批中」的单点「审核通过」被拒(越节点)', async () => { + const { ctx } = sheetCtx({ session: viaButton('u_hr'), status: 'leader_approving', input: { pending_action: 'approve' } }); + expect(await codeOf(() => run(SheetTransitionHook)(ctx))).toBe('KPI_SHEET_POSITION'); + }); + + it('部门填报人员对「领导审批中」的单点「审核通过」被拒', async () => { + const { ctx } = sheetCtx({ session: viaButton('u_reporter'), status: 'leader_approving', input: { pending_action: 'approve' } }); + expect(await codeOf(() => run(SheetTransitionHook)(ctx))).toBe('KPI_SHEET_POSITION'); + }); + + it('部门填报人员对「已通过」的单点「归档」被拒(归档要人力审核岗位)', async () => { + const { ctx } = sheetCtx({ session: viaButton('u_reporter'), status: 'approved', input: { pending_action: 'archive' } }); + expect(await codeOf(() => run(SheetTransitionHook)(ctx))).toBe('KPI_SHEET_POSITION'); + }); + + it('部门填报人员对「领导审批中」的单点「驳回」被拒', async () => { + const { ctx } = sheetCtx({ session: viaButton('u_reporter'), status: 'leader_approving', input: { pending_action: 'reject', action_reason: '不同意' } }); + expect(await codeOf(() => run(SheetTransitionHook)(ctx))).toBe('KPI_SHEET_POSITION'); + }); + + it('分管领导对「领导审批中」的单点「审核通过」放行,状态推进到已通过', async () => { + const { ctx, input } = sheetCtx({ session: viaButton('u_leader'), status: 'leader_approving', input: { pending_action: 'approve' } }); + await run(SheetTransitionHook)(ctx); + expect(input.status).toBe('approved'); + expect(input.approved_by).toBe('u_leader'); + }); + + it('人力审核对「人力审核中」的单点「审核通过」放行', async () => { + const { ctx, input } = sheetCtx({ session: viaButton('u_hr'), status: 'hr_reviewing', input: { pending_action: 'approve' } }); + await run(SheetTransitionHook)(ctx); + expect(input.status).toBe('leader_approving'); + }); + + it('人力审核对「已通过」的单点「归档」放行', async () => { + const { ctx, input } = sheetCtx({ session: viaButton('u_hr'), status: 'approved', input: { pending_action: 'archive' } }); + await run(SheetTransitionHook)(ctx); + expect(input.status).toBe('archived'); + }); + + it('无发起用户的系统写入放行(hook 内部自动推进 / 脚本建链)', async () => { + const { ctx, input } = sheetCtx({ session: viaSystem(), status: 'branch_checking', input: { pending_action: 'approve', action_reason: '全部分公司已确认,系统自动推进' } }); + await run(SheetTransitionHook)(ctx); + expect(input.status).toBe('hr_reviewing'); + }); + + it('REST 路径与按钮路径同一结论:人力审核越节点仍被拒、分管领导仍放行', async () => { + const denied = sheetCtx({ session: viaRest('u_hr'), status: 'leader_approving', input: { pending_action: 'approve' } }); + expect(await codeOf(() => run(SheetTransitionHook)(denied.ctx))).toBe('KPI_SHEET_POSITION'); + const allowed = sheetCtx({ session: viaRest('u_leader'), status: 'leader_approving', input: { pending_action: 'approve' } }); + await run(SheetTransitionHook)(allowed.ctx); + expect(allowed.input.status).toBe('approved'); + }); + + it('按钮路径不能直改 status(不带流程动作的写入)', async () => { + const { ctx } = sheetCtx({ session: viaButton('u_hr'), status: 'hr_reviewing', input: { status: 'approved' } }); + expect(await codeOf(() => run(SheetTransitionHook)(ctx))).toBe('KPI_SHEET_DIRECT_STATUS'); + }); + + it('已归档的填报单:按钮路径改业务字段被拒,after 阶段清空流程暂存字段放行', async () => { + const locked = sheetCtx({ session: viaButton('u_hr'), status: 'archived', input: { total_score: 99 } }); + expect(await codeOf(() => run(SheetTransitionHook)(locked.ctx))).toBe('KPI_SHEET_ARCHIVED'); + const scratch = sheetCtx({ session: viaSystem(), status: 'archived', input: { pending_action: null, action_reason: null } }); + expect(await codeOf(() => run(SheetTransitionHook)(scratch.ctx))).toBeNull(); + }); +}); + +describe('分公司核对 —— 按钮路径按发起人校验岗位', () => { + function checkCtx(session: Session, input: Record) { + const store = baseStore({ kpi_entry_sheet: [{ id: 'sheet1', status: 'branch_checking' }] }); + const { api } = fakeApi(store); + const ctx = { + object: 'kpi_check_task', + event: 'beforeUpdate', + input: { id: 'task1', ...input }, + previous: { id: 'task1', sheet: 'sheet1', branch: 'bu_b', status: 'pending' }, + session, + user: session.userId ? { id: session.userId } : undefined, + api, + } as unknown as HookContext; + return ctx; + } + + it('部门填报人员经动作体点「确认无误」被拒', async () => { + expect(await codeOf(() => run(CheckTaskDecideHook)(checkCtx(viaButton('u_reporter'), { status: 'confirmed' })))).toBe('KPI_CHECK_POSITION'); + }); + it('分公司核对人员经动作体点「确认无误」放行', async () => { + expect(await codeOf(() => run(CheckTaskDecideHook)(checkCtx(viaButton('u_checker'), { status: 'confirmed' })))).toBeNull(); + }); + it('无发起用户的系统写入(驳回时重置为待核对)放行', async () => { + const ctx = { + object: 'kpi_check_task', + event: 'beforeUpdate', + input: { id: 'task1', status: 'pending' }, + previous: { id: 'task1', sheet: 'sheet1', branch: 'bu_b', status: 'confirmed' }, + session: viaSystem(), + api: fakeApi(baseStore()).api, + } as unknown as HookContext; + expect(await codeOf(() => run(CheckTaskDecideHook)(ctx))).toBeNull(); + }); +}); + +describe('加减分 —— 按钮路径按发起人校验岗位分离', () => { + function bonusCtx(session: Session, input: Record, prev: Record) { + const store = baseStore({ + kpi_entry_sheet: [{ id: 'sheet1', status: 'approved', plan: 'plan1' }], + kpi_bonus: [{ id: 'b1', ...prev }], + }); + const { api } = fakeApi(store); + return { + object: 'kpi_bonus', + event: 'beforeUpdate', + input: { id: 'b1', ...input }, + previous: { id: 'b1', ...prev }, + session, + user: session.userId ? { id: session.userId } : undefined, + api, + } as unknown as HookContext; + } + + it('人力审核经动作体点「批准」被拒(审批归人力负责人)', async () => { + const ctx = bonusCtx(viaButton('u_hr'), { status: 'approved' }, { sheet: 'sheet1', status: 'draft', bonus_type: 'add', points: 3 }); + expect(await codeOf(() => run(BonusHook)(ctx))).toBe('KPI_BONUS_APPROVE_POSITION'); + }); + it('人力负责人经动作体点「批准」放行并盖审批人', async () => { + const ctx = bonusCtx(viaButton('u_hrhead'), { status: 'approved' }, { sheet: 'sheet1', status: 'draft', bonus_type: 'add', points: 3 }); + expect(await codeOf(() => run(BonusHook)(ctx))).toBeNull(); + expect((ctx.input as Record).approved_by).toBe('u_hrhead'); + }); +}); From cba7628c4e6b432af45926a41055dfa1e4cfa5d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8C=85=E5=91=A8=E6=B6=9B?= Date: Thu, 3 Sep 2026 23:37:29 -0700 Subject: [PATCH 2/2] =?UTF-8?q?fix(sheet.hook):=20=E5=BD=92=E6=A1=A3=20aft?= =?UTF-8?q?er=20=E9=98=B6=E6=AE=B5=E7=9A=84=E6=B8=85=E5=9C=BA=E5=86=99?= =?UTF-8?q?=E5=85=A5=E4=B8=8D=E5=86=8D=E8=A2=AB=E8=87=AA=E5=B7=B1=E7=9A=84?= =?UTF-8?q?=E5=BD=92=E6=A1=A3=E9=94=81=E6=8B=A6=E4=B8=8B(=E8=AF=84?= =?UTF-8?q?=E5=AE=A1=20F1/F2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F1 —— 归档偶发半途失败,填报单永久卡在「已归档但无快照」: after 阶段第一步 `updateById(id, {pending_action:null, action_reason:null})` 原本走 带发起人的 sys(ctx),isSystemWrite 为假,于是落进归档锁分支;而平台内建的 sys_stamp_audit_update(object '*',priority 10)在本 hook(100)之前把 updated_at / updated_by 写进同一个 ctx.input,这两个字段不在 SCRATCH_FIELDS 白名单里,touched 非空 → 抛 KPI_SHEET_ARCHIVED,其后的 writeReview / createSnapshot / regenerateResults 全不 执行,422 返回操作人;归档只能从「已通过」发起,重试被状态机拒绝,该单无法自愈。 两处一起改,缺一条都还会漏: 1. 清场写入改用 sysNoActor —— 它是系统动作,不是任何人点出来的,应当落在免检那一侧; 2. 归档锁的 touched 排除平台审计戳(created_at / created_by / updated_at / updated_by) —— 平台盖的戳不是用户改数据,不该被算成「改动」。 F2 —— 单测替身没有 sudo,sys() / sysNoActor() 双双退化成它自己,after 阶段与 sysNoActor 的真实行为零覆盖,F1 才得以在 23 条全绿的情况下逃逸。替身重写: - FakeScopedContext 实现 sudo() 语义,executionContext 是可写实例属性(与平台一致), 仓库在 object() 调用的一刻捕获上下文快照; - FakeEngine 复刻平台写入时序:审计戳先于业务 hook 写同一个 input,且 hook 内部经 ctx.api 发起的写入会再穿一遍 hook 链; - 新增「归档 after 阶段清场 + 快照 + 审核记录」与「清场写入确实无发起人」两组用例, 外加归档锁只锁业务字段的三条。两半修复各被一条用例单独钉住:只回退 sysNoActor 挂 1 条,只回退审计戳白名单挂 1 条,两条都回退挂 3 条(已逐一实测)。 Co-Authored-By: Claude Fable 5.1 --- src/hooks/sheet.hook.ts | 27 +++- test/position-guard.test.ts | 282 ++++++++++++++++++++++++++++++------ 2 files changed, 261 insertions(+), 48 deletions(-) diff --git a/src/hooks/sheet.hook.ts b/src/hooks/sheet.hook.ts index c67d7dd..4e4ad63 100644 --- a/src/hooks/sheet.hook.ts +++ b/src/hooks/sheet.hook.ts @@ -18,6 +18,17 @@ export async function loadPlanSteps(api: ReturnType, planId: string) /** 流程暂存字段:按钮写进来、after 阶段清空,不承载业务数据。 */ const SCRATCH_FIELDS = new Set(['pending_action', 'action_reason']); +/** + * 平台审计戳字段 —— 不是任何人「改」出来的,是平台内建 hook 盖上去的。 + * + * 平台的 `sys_stamp_audit_update`(object `'*'`,priority 10)在本 hook(priority 100) + * **之前**跑,把 `updated_at` / `updated_by` 直接写进同一个 `ctx.input`。所以到本 hook + * 手里时,一次「只清空 pending_action」的写入,`input` 里已经多出两个字段。归档锁若照 + * `input` 的字面内容判「有没有改动」,就会把平台自己盖的戳算成用户改数据 —— 归档流程 + * 在 after 阶段的清场写入上被自己拦下,快照与审核记录都不会执行。 + */ +const PLATFORM_STAMP_FIELDS = new Set(['created_at', 'created_by', 'updated_at', 'updated_by']); + /** * 填报单由方案发布生成;非系统上下文不能手工新建。 * @@ -68,9 +79,12 @@ export const SheetTransitionHook: Hook = { fail('修改填报单状态失败:状态由流程推进,不能直接修改。请使用提交、审核通过、驳回或归档按钮。', 'KPI_SHEET_DIRECT_STATUS'); } if (prev.status === 'archived' && !isSystemWrite(ctx)) { - // pending_action / action_reason 是流程的暂存字段,after 阶段的清场写入只动这两个, - // 不算「改数据」;带真实动作的写入走不到这个分支(上面 `action` 为真时已分流)。 - const touched = Object.keys(input).filter((k) => !SCRATCH_FIELDS.has(k) && k !== 'id' && input[k] !== prev[k]); + // 「改动」只算业务字段:流程暂存字段(after 阶段清场写的就是这两个)与平台审计戳 + // (由更早的平台 hook 盖进同一个 input)都不是用户改数据。带真实动作的写入走不到 + // 这个分支(上面 `action` 为真时已分流)。 + const touched = Object.keys(input).filter( + (k) => !SCRATCH_FIELDS.has(k) && !PLATFORM_STAMP_FIELDS.has(k) && k !== 'id' && input[k] !== prev[k], + ); if (touched.length) fail('修改填报单失败:该填报单已归档,数据已锁定不可再改。', 'KPI_SHEET_ARCHIVED'); } return; @@ -161,8 +175,11 @@ export const SheetAfterTransitionHook: Hook = { if (!id) return; const api = sys(ctx); const actor = actorId(ctx); - // 先清空动作,再做副作用:副作用失败也不会让动作残留而被反复触发 - await api.object('kpi_entry_sheet').updateById(id, { pending_action: null, action_reason: null }); + // 先清空动作,再做副作用:副作用失败也不会让动作残留而被反复触发。 + // 清场是系统动作,不是任何人点出来的 —— 用无发起人的系统上下文写,让它落在 + // `isSystemWrite` 免检那一侧;否则归档后的这一次清场会被自己的归档锁拦下, + // 快照与审核记录都不会执行,填报单永久停在「已归档但没有快照」。 + await sysNoActor(ctx).object('kpi_entry_sheet').updateById(id, { pending_action: null, action_reason: null }); const fromStatus = prev.status as SheetStatus; const toStatus = now.status as SheetStatus; const steps = await loadPlanSteps(api, String(now.plan ?? prev.plan)); diff --git a/test/position-guard.test.ts b/test/position-guard.test.ts index fde2213..9f39f36 100644 --- a/test/position-guard.test.ts +++ b/test/position-guard.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import type { Hook, HookContext } from '@objectstack/spec/data'; -import { SheetTransitionHook } from '../src/hooks/sheet.hook.js'; +import { SheetAfterTransitionHook, SheetTransitionHook } from '../src/hooks/sheet.hook.js'; import { CheckTaskDecideHook } from '../src/hooks/check-task.hook.js'; import { BonusHook } from '../src/hooks/bonus.hook.js'; import { hasPosition, isSystemWrite, type KpiError } from '../src/hooks/util.js'; @@ -15,6 +15,10 @@ import { hasPosition, isSystemWrite, type KpiError } from '../src/hooks/util.js' * 3. 纯系统写入(isSystem,无发起人):必须放行 —— 发布、重算、hook 内部自动推进 * 不能被岗位闸误伤。 * 另外锁住第 4 类:REST 路径(非系统 + 发起人)与按钮路径结论一致,同一口径。 + * + * 第 5 类是本文件的替身必须**真实**的地方:hook 自己发起的写入会再次穿过 hook 链。 + * 替身因此实现了 `sudo()` 语义与「平台审计戳 hook 先于业务 hook 写同一个 input」的时序, + * 否则 after 阶段的清场写入根本没被测到 —— 归档半途失败的缺陷就是从这个缝里漏过去的。 */ const STEPS = [ @@ -28,6 +32,13 @@ interface StoreShape { [object: string]: Array>; } +/** 平台 ExecutionContext 的最小形状(替身只用到这三项)。 */ +interface ExecCtx { + userId?: string; + isSystem?: true; + tenantId?: string; +} + function matches(row: Record, where: Record | undefined): boolean { if (!where) return true; return Object.entries(where).every(([k, v]) => { @@ -37,43 +48,112 @@ function matches(row: Record, where: Record | undefine }); } -/** 最小 ctx.api 替身:只实现 hook 真正会用到的读写。没有 sudo,所以 sys()/sysNoActor() 退化为它自己。 */ -function fakeApi(store: StoreShape) { - const writes: Array<{ object: string; id: string; data: Record }> = []; - const api = { - object(name: string) { - const rows = () => store[name] ?? []; - return { - async find(q?: Record) { - return rows().filter((r) => matches(r, q?.where)); - }, - async findOne(q?: Record) { - return rows().find((r) => matches(r, q?.where)) ?? null; - }, - async count(q?: Record) { - return rows().filter((r) => matches(r, q?.where)).length; - }, - async insert(data: Record) { - const row = { id: `new_${(store[name] ?? []).length + 1}`, ...data }; - (store[name] ??= []).push(row); - return row; - }, - async update(data: Record) { - return data; - }, - async updateById(id: string, data: Record) { - writes.push({ object: name, id, data }); - const row = rows().find((r) => String(r.id) === String(id)); - if (row) Object.assign(row, data); - return row; - }, - async delete() { - return null; - }, - }; - }, - }; - return { api, writes }; +/** 平台 `ObjectQL.buildSession` 的口径:没有身份信封时返回 undefined。 */ +function buildSession(ec: ExecCtx): Record | undefined { + const session = { userId: ec.userId, organizationId: ec.tenantId, ...(ec.isSystem ? { isSystem: true as const } : {}) }; + return Object.values(session).some((v) => v !== undefined) ? session : undefined; +} + +/** 平台 `ObjectQL.buildUser` 的口径:`userId` 为空时没有「当前用户」。 */ +function buildUser(ec: ExecCtx): { id: string } | undefined { + return ec.userId == null ? undefined : { id: String(ec.userId) }; +} + +/** + * 替身引擎:存数据 + 按平台的时序派发 hook。 + * + * 写入管线刻意复刻两件真实行为,因为缺陷正是从它们的交界处冒出来的: + * 1. **平台内建的审计戳 hook 先跑**(`sys_stamp_audit_update`,object `'*'`,priority 10), + * 把 `updated_at` / `updated_by` 写进业务 hook 将要看到的**同一个** `ctx.input`; + * 2. hook 内部经 `ctx.api` 发起的写入**会再穿一遍 hook 链**,并带着那次写入自己的上下文。 + */ +class FakeEngine { + constructor( + readonly store: StoreShape, + readonly hooks: Hook[] = [], + ) {} + + readonly writes: Array<{ object: string; id: string; data: Record; ec: ExecCtx }> = []; + + rows(object: string): Array> { + return (this.store[object] ??= []); + } + + private hooksFor(object: string, event: string): Hook[] { + return this.hooks + .filter((h) => h.object === object && (h.events as string[]).includes(event)) + .sort((a, b) => (a.priority ?? 100) - (b.priority ?? 100)); + } + + async update(object: string, id: string, data: Record, ec: ExecCtx): Promise | null> { + const previous = this.rows(object).find((r) => String(r.id) === String(id)); + const input: Record = { id, ...data }; + // ① 平台审计戳 hook(priority 10)先于业务 hook 写同一个 input + input.updated_at = new Date().toISOString(); + input.updated_by = ec.userId ?? null; + const ctx = { + object, + event: 'beforeUpdate', + input, + previous: previous ? { ...previous } : undefined, + session: buildSession(ec), + user: buildUser(ec), + api: new FakeScopedContext(ec, this), + } as unknown as HookContext; + for (const hook of this.hooksFor(object, 'beforeUpdate')) { + await (hook.handler as (c: HookContext) => Promise)(ctx); + } + this.writes.push({ object, id, data: { ...input }, ec }); + if (previous) Object.assign(previous, input); + for (const hook of this.hooksFor(object, 'afterUpdate')) { + await (hook.handler as (c: HookContext) => Promise)(ctx); + } + return previous ?? null; + } +} + +/** 替身 ScopedContext:`executionContext` 是可写的实例属性,`sudo()` 派生一个新的(与平台一致)。 */ +class FakeScopedContext { + constructor( + public executionContext: ExecCtx, + private readonly engine: FakeEngine, + ) {} + + sudo(): FakeScopedContext { + return new FakeScopedContext({ ...this.executionContext, isSystem: true }, this.engine); + } + + object(name: string) { + // 与平台一致:仓库在 `object()` 调用的这一刻捕获上下文快照 + const ec: ExecCtx = { ...this.executionContext }; + const engine = this.engine; + const rows = () => engine.rows(name); + return { + async find(q?: Record) { + return rows().filter((r) => matches(r, q?.where)); + }, + async findOne(q?: Record) { + return rows().find((r) => matches(r, q?.where)) ?? null; + }, + async count(q?: Record) { + return rows().filter((r) => matches(r, q?.where)).length; + }, + async insert(data: Record) { + const row = { id: `${name}_${rows().length + 1}`, ...data }; + rows().push(row); + return row; + }, + async update(data: Record) { + return data; + }, + async updateById(id: string, data: Record) { + return engine.update(name, id, data, ec); + }, + async delete() { + return null; + }, + }; + } } type Session = { isSystem?: true; userId?: string }; @@ -103,6 +183,12 @@ function baseStore(overrides: Partial = {}): StoreShape { }; } +/** 最小 ctx.api 替身(带 `sudo()` 语义)。默认不挂 hook —— 单跑一个 handler 的用例用它。 */ +function fakeApi(store: StoreShape, hooks: Hook[] = []) { + const engine = new FakeEngine(store, hooks); + return { api: new FakeScopedContext({}, engine), engine, writes: engine.writes }; +} + function sheetCtx(opts: { session: Session; status: string; @@ -154,6 +240,31 @@ describe('isSystemWrite —— 系统标记不等于系统写入', () => { }); }); +describe('sysNoActor —— 摘掉发起人的系统上下文', () => { + it('经 sysNoActor 发起的写入,到达下游 hook 时没有发起人', async () => { + const seen: Array<{ isSystemWrite: boolean; updatedBy: unknown }> = []; + const probe: Hook = { + name: 'probe', label: 'probe', object: 'kpi_entry_sheet', events: ['beforeUpdate'], priority: 100, + handler: async (c: HookContext) => { + seen.push({ isSystemWrite: isSystemWrite(c), updatedBy: (c.input as Record).updated_by }); + }, + }; + const store = baseStore({ kpi_entry_sheet: [{ id: 'sheet1', plan: 'plan1', status: 'approved' }] }); + const engine = new FakeEngine(store, [probe]); + const caller = new FakeScopedContext({ userId: 'u_hr', isSystem: true, tenantId: 'org1' }, engine); + const ctx = { api: caller, session: viaButton('u_hr'), user: { id: 'u_hr' } } as unknown as HookContext; + + const { sys, sysNoActor } = await import('../src/hooks/util.js'); + await sys(ctx).object('kpi_entry_sheet').updateById('sheet1', { remark: 'a' }); + await sysNoActor(ctx).object('kpi_entry_sheet').updateById('sheet1', { remark: 'b' }); + + expect(seen[0]).toEqual({ isSystemWrite: false, updatedBy: 'u_hr' }); + expect(seen[1]).toEqual({ isSystemWrite: true, updatedBy: null }); + // 租户信息不能被一并摘掉 + expect((sysNoActor(ctx) as unknown as FakeScopedContext).executionContext.tenantId).toBe('org1'); + }); +}); + describe('hasPosition —— 免检只留给纯系统写入', () => { it('按钮路径的发起人没有该岗位:不放行', async () => { const { api } = fakeApi(baseStore()); @@ -230,12 +341,97 @@ describe('填报单流程推进 —— 按钮路径按发起人校验岗位', () const { ctx } = sheetCtx({ session: viaButton('u_hr'), status: 'hr_reviewing', input: { status: 'approved' } }); expect(await codeOf(() => run(SheetTransitionHook)(ctx))).toBe('KPI_SHEET_DIRECT_STATUS'); }); +}); + +describe('归档锁 —— 只锁业务字段,不锁平台盖的戳', () => { + it('按钮路径改业务字段被拒', async () => { + const { ctx } = sheetCtx({ session: viaButton('u_hr'), status: 'archived', input: { total_score: 99 } }); + expect(await codeOf(() => run(SheetTransitionHook)(ctx))).toBe('KPI_SHEET_ARCHIVED'); + }); + + it('只带平台审计戳与流程暂存字段的写入放行(戳不是「改动」)', async () => { + const { ctx } = sheetCtx({ + session: viaButton('u_hr'), + status: 'archived', + input: { pending_action: null, action_reason: null, updated_at: '2026-09-04T00:00:00.000Z', updated_by: 'u_hr' }, + }); + expect(await codeOf(() => run(SheetTransitionHook)(ctx))).toBeNull(); + }); + + it('审计戳与业务字段混在一起时,业务字段照拦', async () => { + const { ctx } = sheetCtx({ + session: viaButton('u_hr'), + status: 'archived', + input: { updated_at: '2026-09-04T00:00:00.000Z', updated_by: 'u_hr', remark: '偷改' }, + }); + expect(await codeOf(() => run(SheetTransitionHook)(ctx))).toBe('KPI_SHEET_ARCHIVED'); + }); + + it('无发起人的系统写入不受归档锁约束', async () => { + const { ctx } = sheetCtx({ session: viaSystem(), status: 'archived', input: { total_score: 99 } }); + expect(await codeOf(() => run(SheetTransitionHook)(ctx))).toBeNull(); + }); +}); + +describe('归档 after 阶段 —— 清场写入不能被自己的归档锁拦下', () => { + /** 归档刚落库的现场:填报单已是 archived,after 阶段即将清场并生成快照。 */ + function archivedCtx() { + const sheetRow = { + id: 'sheet1', name: '2026 年第 3 季度考核 · 销售部', plan: 'plan1', subject: 'bu_a', + status: 'archived', current_step: 4, pending_action: 'archive', action_reason: null, + total_score: 95, indicator_score: 95, archived_at: '2026-09-04T00:00:00.000Z', + }; + const store = baseStore({ + kpi_entry_sheet: [sheetRow], + kpi_entry_line: [{ id: 'l1', sheet: 'sheet1', indicator_name: '签约金额', final_score: 95 }], + kpi_bonus: [], + kpi_adjustment: [], + kpi_review_record: [], + kpi_snapshot: [], + kpi_result: [], + }); + const engine = new FakeEngine(store, [SheetTransitionHook]); + const ctx = { + object: 'kpi_entry_sheet', + event: 'afterUpdate', + input: { id: 'sheet1', status: 'archived', current_step: 4, pending_action: 'archive', action_reason: null, archived_at: sheetRow.archived_at }, + previous: { id: 'sheet1', name: sheetRow.name, plan: 'plan1', subject: 'bu_a', status: 'approved', current_step: 4 }, + session: viaButton('u_hr'), + user: { id: 'u_hr' }, + api: new FakeScopedContext({ userId: 'u_hr', isSystem: true, tenantId: 'org1' }, engine), + } as unknown as HookContext; + return { ctx, store, engine }; + } + + it('人力审核归档:清场写入通过,审核记录与快照都落库', async () => { + const { ctx, store } = archivedCtx(); + await run(SheetAfterTransitionHook)(ctx); + + const sheet = store.kpi_entry_sheet![0]!; + expect(sheet.pending_action).toBeNull(); + expect(sheet.action_reason).toBeNull(); + + const reviews = store.kpi_review_record ?? []; + expect(reviews.map((r) => r.action)).toContain('archive'); + expect(reviews.find((r) => r.action === 'archive')?.to_status).toBe('archived'); + + const snapshots = store.kpi_snapshot ?? []; + expect(snapshots).toHaveLength(1); + expect(snapshots[0]!.sheet).toBe('sheet1'); + expect(snapshots[0]!.checksum).toMatch(/^[0-9a-f]{64}$/); + expect(snapshots[0]!.archived_by).toBe('u_hr'); + }); - it('已归档的填报单:按钮路径改业务字段被拒,after 阶段清空流程暂存字段放行', async () => { - const locked = sheetCtx({ session: viaButton('u_hr'), status: 'archived', input: { total_score: 99 } }); - expect(await codeOf(() => run(SheetTransitionHook)(locked.ctx))).toBe('KPI_SHEET_ARCHIVED'); - const scratch = sheetCtx({ session: viaSystem(), status: 'archived', input: { pending_action: null, action_reason: null } }); - expect(await codeOf(() => run(SheetTransitionHook)(scratch.ctx))).toBeNull(); + it('清场写入到达 beforeUpdate 时确实是「无发起人的系统写入」', async () => { + const seen: boolean[] = []; + const probe: Hook = { + name: 'probe', label: 'probe', object: 'kpi_entry_sheet', events: ['beforeUpdate'], priority: 90, + handler: async (c: HookContext) => { seen.push(isSystemWrite(c)); }, + }; + const { ctx, engine } = archivedCtx(); + engine.hooks.push(probe); + await run(SheetAfterTransitionHook)(ctx); + expect(seen[0]).toBe(true); }); });