|
11 | 11 |
|
12 | 12 | import { describe, it, expect, beforeEach, vi } from 'vitest'; |
13 | 13 | import { APPROVAL_REVISE_NODE_TYPE, templateSlotSource } from '@objectstack/spec/automation'; |
14 | | -import { ApprovalService, REMIND_COOLDOWN_MS, ESCALATION_ENABLED_FLIP_CUTOFF_MS, SLA_ACTOR_ID } from './approval-service.js'; |
| 14 | +import { |
| 15 | + ApprovalService, ApproverDirectoryUnreadableError, REMIND_COOLDOWN_MS, ESCALATION_ENABLED_FLIP_CUTOFF_MS, SLA_ACTOR_ID, |
| 16 | +} from './approval-service.js'; |
15 | 17 | import { bindApprovalLockHook, bindDelegationWriteGuard, unbindAllHooks } from './lifecycle-hooks.js'; |
16 | 18 | import { celEngine } from '@objectstack/formula'; |
17 | 19 | import { SysApprovalRequest } from './sys-approval-request.object.js'; |
@@ -4207,6 +4209,138 @@ describe('ApprovalService — a queue approver routes to the queue\'s members (A |
4207 | 4209 | }); |
4208 | 4210 | }); |
4209 | 4211 |
|
| 4212 | +// [ADR-0142 D6 as amended; ruling record 6599731698 on #29604, letter B] A |
| 4213 | +// directory read behind a `team`, `position` or `queue` approver that FAILS is |
| 4214 | +// not an empty slate: the node refuses to open under every `onEmptyApprovers` |
| 4215 | +// policy, on its own retryable error class, naming the read — and writes |
| 4216 | +// nothing. Before, the slot fell back to its literal and the policy acted on a |
| 4217 | +// slate missing the people who could not be read. |
| 4218 | +describe('ApprovalService — a team, position or queue approver whose directory read fails refuses the node (ADR-0142 D6)', () => { |
| 4219 | + const POLICIES = ['admin_rescue', 'auto_approve', 'fail', 'fallback'] as const; |
| 4220 | + |
| 4221 | + /** A service over `engine` whose reads of `failing` throw, as a driver outage does. */ |
| 4222 | + const outage = (failing: string[], queues?: () => any) => { |
| 4223 | + const engine = makeFakeEngine(); |
| 4224 | + engine._tables['sys_team_member'] = [{ id: 'tm1', team_id: 't_ops', user_id: 'u5' }]; |
| 4225 | + engine._tables['sys_user_position'] = [{ id: 'up1', position: 'cfo', user_id: 'u9', organization_id: 't1' }]; |
| 4226 | + const real = engine.find.bind(engine); |
| 4227 | + engine.find = async (object: string, options?: any) => { |
| 4228 | + if (failing.includes(object)) throw new Error(`SQLITE_IOERR: disk I/O error reading ${object}`); |
| 4229 | + return real(object, options); |
| 4230 | + }; |
| 4231 | + const warnings: any[] = []; |
| 4232 | + let n = 0; |
| 4233 | + const svc = new ApprovalService({ |
| 4234 | + engine, |
| 4235 | + clock: { now: () => new Date(1757000000000 + (n++) * 1000) }, |
| 4236 | + logger: { warn: (msg: any, meta: any) => warnings.push([msg, meta]) }, |
| 4237 | + ...(queues ? { ownershipQueues: queues } : {}), |
| 4238 | + }); |
| 4239 | + return { engine, svc, warnings }; |
| 4240 | + }; |
| 4241 | + const node = (approvers: any[], configExtra: Record<string, any> = {}) => ({ |
| 4242 | + ...openInput([]), |
| 4243 | + config: { approvers, behavior: 'first_response' as const, lockRecord: false, ...configExtra }, |
| 4244 | + }); |
| 4245 | + const policy = (p: (typeof POLICIES)[number]) => ( |
| 4246 | + p === 'fallback' ? { onEmptyApprovers: p, fallbackApprovers: [{ type: 'user', value: 'u_backstop' }] } : { onEmptyApprovers: p } |
| 4247 | + ); |
| 4248 | + const refusalOf = (p: Promise<unknown>) => p.then(() => null, (e: unknown) => e); |
| 4249 | + const nothingWritten = (engine: ReturnType<typeof makeFakeEngine>) => { |
| 4250 | + expect(engine._tables['sys_approval_request'] ?? []).toEqual([]); |
| 4251 | + expect(engine._tables['sys_approval_approver'] ?? []).toEqual([]); |
| 4252 | + expect(engine._tables['sys_approval_action'] ?? []).toEqual([]); |
| 4253 | + }; |
| 4254 | + |
| 4255 | + for (const [type, value, table] of [['team', 't_ops', 'sys_team_member'], ['position', 'cfo', 'sys_user_position']] as const) { |
| 4256 | + for (const p of POLICIES) { |
| 4257 | + it(`a ${type} approver whose ${table} read fails: onEmptyApprovers '${p}' opens nothing and approves nothing`, async () => { |
| 4258 | + const { engine, svc } = outage([table]); |
| 4259 | + const refusal = await refusalOf(svc.openNodeRequest(node([{ type, value }], policy(p)), CTX)); |
| 4260 | + |
| 4261 | + expect(refusal).toBeInstanceOf(ApproverDirectoryUnreadableError); |
| 4262 | + expect(refusal).toMatchObject({ approverType: type, approverValue: value, retryable: true }); |
| 4263 | + expect((refusal as Error).message).toMatch(new RegExp(`^APPROVER_DIRECTORY_UNREADABLE: approver '${type}:${value}'`)); |
| 4264 | + expect((refusal as ApproverDirectoryUnreadableError).failedRead).toMatch(/SQLITE_IOERR/); |
| 4265 | + expect((refusal as Error).message).not.toMatch(/NO_APPROVERS|expanded to nobody/); |
| 4266 | + nothingWritten(engine); |
| 4267 | + }); |
| 4268 | + } |
| 4269 | + } |
| 4270 | + |
| 4271 | + it('beside a staffed person, the node still refuses: no slate shortened to the people who could be read', async () => { |
| 4272 | + const { engine, svc } = outage(['sys_team_member']); |
| 4273 | + const refusal = await refusalOf(svc.openNodeRequest( |
| 4274 | + node([{ type: 'user', value: 'u2' }, { type: 'team', value: 't_ops' }]), CTX, |
| 4275 | + )); |
| 4276 | + |
| 4277 | + expect(refusal).toBeInstanceOf(ApproverDirectoryUnreadableError); |
| 4278 | + nothingWritten(engine); |
| 4279 | + }); |
| 4280 | + |
| 4281 | + it('a fallback slate whose directory read fails refuses too, rather than degrade to the administrators', async () => { |
| 4282 | + // The primary slate reads fine and is empty (an unstaffed position); the |
| 4283 | + // declared fallback names a team whose read fails. |
| 4284 | + const { engine, svc } = outage(['sys_team_member']); |
| 4285 | + const refusal = await refusalOf(svc.openNodeRequest(node([{ type: 'position', value: 'nobody_holds_this' }], { |
| 4286 | + onEmptyApprovers: 'fallback', fallbackApprovers: [{ type: 'team', value: 't_ops' }], |
| 4287 | + }), CTX)); |
| 4288 | + |
| 4289 | + expect(refusal).toBeInstanceOf(ApproverDirectoryUnreadableError); |
| 4290 | + expect(refusal).toMatchObject({ approverType: 'team', approverValue: 't_ops' }); |
| 4291 | + nothingWritten(engine); |
| 4292 | + }); |
| 4293 | + |
| 4294 | + it('a queue whose expansion fails refuses before its "expanded to nobody" answer', async () => { |
| 4295 | + const { engine, svc, warnings } = outage([], () => ({ |
| 4296 | + expandMembers: async () => { throw new Error('ownership-queue read failed'); }, |
| 4297 | + })); |
| 4298 | + const refusal = await refusalOf(svc.openNodeRequest(node([{ type: 'queue', value: 'billing' }]), CTX)); |
| 4299 | + |
| 4300 | + expect(refusal).toBeInstanceOf(ApproverDirectoryUnreadableError); |
| 4301 | + expect(refusal).toMatchObject({ approverType: 'queue', approverValue: 'billing' }); |
| 4302 | + expect(warnings.some(([msg]) => String(msg).includes("approver 'queue:billing' expanded to nobody"))).toBe(false); |
| 4303 | + nothingWritten(engine); |
| 4304 | + }); |
| 4305 | + |
| 4306 | + it('control — the readable slate opens as before', async () => { |
| 4307 | + const { svc } = outage([]); |
| 4308 | + const req = await svc.openNodeRequest(node([{ type: 'team', value: 't_ops' }, { type: 'position', value: 'cfo' }]), CTX) as any; |
| 4309 | + |
| 4310 | + expect(req.status).toBe('pending'); |
| 4311 | + expect([...req.pending_approvers].sort()).toEqual(['u5', 'u9']); |
| 4312 | + }); |
| 4313 | + |
| 4314 | + it('control — a department read that fails is not named by the ruling: the node still opens, as before', async () => { |
| 4315 | + const { engine, svc } = outage(['sys_business_unit', 'sys_business_unit_member']); |
| 4316 | + const outcome = await refusalOf(svc.openNodeRequest( |
| 4317 | + node([{ type: 'department', value: 'bu_x' }], { onEmptyApprovers: 'fallback', fallbackApprovers: [{ type: 'user', value: 'u_backstop' }] }), |
| 4318 | + CTX, |
| 4319 | + )); |
| 4320 | + |
| 4321 | + expect(outcome).toBeNull(); |
| 4322 | + expect(engine._tables['sys_approval_request']).toHaveLength(1); |
| 4323 | + }); |
| 4324 | + |
| 4325 | + it("decide's pre-snapshot re-resolution keeps the literal: a recorded decision is never failed by the outage", async () => { |
| 4326 | + // A unanimous request opened while the directory read, then stripped of |
| 4327 | + // its open-time snapshot as a request opened before the snapshot existed. |
| 4328 | + const failing: string[] = []; |
| 4329 | + const { engine, svc } = outage(failing); |
| 4330 | + const req = await svc.openNodeRequest( |
| 4331 | + node([{ type: 'user', value: 'u2' }, { type: 'team', value: 't_ops' }], { behavior: 'unanimous' }), CTX, |
| 4332 | + ) as any; |
| 4333 | + const row = engine._tables['sys_approval_request'].find((r: any) => r.id === req.id); |
| 4334 | + const config = JSON.parse(row.node_config_json); |
| 4335 | + delete config.__approverGroups; |
| 4336 | + row.node_config_json = JSON.stringify(config); |
| 4337 | + |
| 4338 | + failing.push('sys_team_member'); |
| 4339 | + const decided = await svc.decideNode(req.id, { decision: 'approve', actorId: 'u2' }, SYS); |
| 4340 | + expect(decided.decision).toBe('approve'); |
| 4341 | + }); |
| 4342 | +}); |
| 4343 | + |
4210 | 4344 | // #3807 follow-up: `queue` was not the only way to end up with a slot nobody |
4211 | 4345 | // can act on — every GRAPH approver type falls back to the same literal when |
4212 | 4346 | // its lookup finds no one, and that fallback used to happen in total silence. |
|
0 commit comments