|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | +// |
| 3 | +// ADR-0091 D3 — delegation of duty (职务代理), proven end-to-end against a real |
| 4 | +// booted stack: |
| 5 | +// |
| 6 | +// • the WRITE path: a non-admin holder of a `delegatable` position may POST a |
| 7 | +// time-boxed, reasoned delegation row over the HTTP API — the D12 gate's |
| 8 | +// self-service branch approves it, stamping `granted_by`; the same holder is |
| 9 | +// rejected when the delegation is malformed (no reason) or the position is |
| 10 | +// not delegatable. |
| 11 | +// • the RESOLUTION path: the delegate RESOLVES the delegated position while |
| 12 | +// inside the validity window and STOPS resolving it at `valid_until` — the |
| 13 | +// L1 resolution-time filter, on the real persisted row, via the real |
| 14 | +// `resolveAuthzContext`. "Access dies at valid_until" with no cleanup job. |
| 15 | +// |
| 16 | +// @proof: delegation-of-duty |
| 17 | + |
| 18 | +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; |
| 19 | +import showcaseStack from '@objectstack/example-showcase'; |
| 20 | +import { bootStack, type VerifyStack } from '@objectstack/verify'; |
| 21 | +import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; |
| 22 | +import { PermissionSetSchema } from '@objectstack/spec/security'; |
| 23 | +import { resolveAuthzContext } from '@objectstack/core'; |
| 24 | + |
| 25 | +const SYS = { context: { isSystem: true } } as const; |
| 26 | +const DELEGATOR = 'deleg-boss@verify.test'; |
| 27 | +const DELEGATE = 'deleg-standin@verify.test'; |
| 28 | +const DAY = 24 * 60 * 60 * 1000; |
| 29 | + |
| 30 | +// The "may delegate my duties" population set: plain CRUD on the assignment |
| 31 | +// table (the D12 gate is the real boundary, NOT this grant) and NO adminScope, |
| 32 | +// so the writer is neither a tenant admin nor a delegated administrator — the |
| 33 | +// self-service delegation branch is the ONLY path that can approve their write. |
| 34 | +const delegMember = PermissionSetSchema.parse({ |
| 35 | + name: 'deleg_member', |
| 36 | + label: 'Delegation Member', |
| 37 | + objects: { |
| 38 | + sys_user_position: { allowRead: true, allowCreate: true }, |
| 39 | + }, |
| 40 | +}); |
| 41 | + |
| 42 | +describe('delegation of duty (ADR-0091 D3) — end to end', () => { |
| 43 | + let stack: VerifyStack; |
| 44 | + let ql: any; |
| 45 | + let delegatorToken: string; |
| 46 | + let delegatorId: string; |
| 47 | + let delegateId: string; |
| 48 | + const validUntil = new Date(Date.now() + 10 * DAY).toISOString(); |
| 49 | + |
| 50 | + const idOf = async (email: string): Promise<string> => { |
| 51 | + const u = await ql.findOne('sys_user', { where: { email }, context: SYS.context }); |
| 52 | + return String(u?.id ?? ''); |
| 53 | + }; |
| 54 | + const sessionFor = (userId: string) => async () => ({ user: { id: userId }, session: {} }); |
| 55 | + |
| 56 | + beforeAll(async () => { |
| 57 | + stack = await bootStack(showcaseStack, { |
| 58 | + security: new SecurityPlugin({ |
| 59 | + defaultPermissionSets: [...securityDefaultPermissionSets, delegMember], |
| 60 | + fallbackPermissionSet: 'deleg_member', |
| 61 | + }), |
| 62 | + }); |
| 63 | + await stack.signIn(); |
| 64 | + delegatorToken = await stack.signUp(DELEGATOR); |
| 65 | + await stack.signUp(DELEGATE); |
| 66 | + ql = await stack.kernel.getServiceAsync('objectql'); |
| 67 | + |
| 68 | + delegatorId = await idOf(DELEGATOR); |
| 69 | + delegateId = await idOf(DELEGATE); |
| 70 | + |
| 71 | + // A delegatable position + a non-delegatable one (system inserts sidestep |
| 72 | + // authoring rules). The delegator DIRECTLY holds the delegatable one. |
| 73 | + await ql.insert('sys_position', { id: 'pos_vac_appr', name: 'vacation_approver', label: 'Vacation Approver', delegatable: true, active: true }, SYS); |
| 74 | + await ql.insert('sys_position', { id: 'pos_locked', name: 'locked_duty', label: 'Locked Duty', delegatable: false, active: true }, SYS); |
| 75 | + await ql.insert('sys_user_position', { id: 'hold_boss', user_id: delegatorId, position: 'vacation_approver' }, SYS); |
| 76 | + await ql.insert('sys_user_position', { id: 'hold_boss_locked', user_id: delegatorId, position: 'locked_duty' }, SYS); |
| 77 | + }, 90_000); |
| 78 | + |
| 79 | + afterAll(async () => { await stack?.stop(); }); |
| 80 | + |
| 81 | + // ── WRITE path (D12 self-service gate branch) ────────────────────────────── |
| 82 | + it('a direct holder delegates a delegatable position over the API; granted_by is stamped', async () => { |
| 83 | + const r = await stack.apiAs(delegatorToken, 'POST', '/data/sys_user_position', { |
| 84 | + user_id: delegateId, position: 'vacation_approver', delegated_from: delegatorId, |
| 85 | + valid_until: validUntil, reason: 'covering approvals during PTO', |
| 86 | + }); |
| 87 | + expect(r.status, await r.text().catch(() => '')).toBeLessThan(300); |
| 88 | + const row = await ql.findOne('sys_user_position', { where: { user_id: delegateId, position: 'vacation_approver' }, context: SYS.context }); |
| 89 | + expect(row, 'delegation row persisted').toBeTruthy(); |
| 90 | + expect(row.granted_by, 'dual audit: writer stamped as granted_by').toBe(delegatorId); |
| 91 | + expect(row.delegated_from, 'authority source recorded').toBe(delegatorId); |
| 92 | + }); |
| 93 | + |
| 94 | + it('a delegation with no reason is rejected (dual audit is mandatory)', async () => { |
| 95 | + const r = await stack.apiAs(delegatorToken, 'POST', '/data/sys_user_position', { |
| 96 | + user_id: delegateId, position: 'vacation_approver', delegated_from: delegatorId, valid_until: validUntil, |
| 97 | + }); |
| 98 | + expect(r.status, 'missing reason → denied').toBeGreaterThanOrEqual(400); |
| 99 | + }); |
| 100 | + |
| 101 | + it('a non-delegatable position cannot be self-delegated even by a direct holder', async () => { |
| 102 | + const r = await stack.apiAs(delegatorToken, 'POST', '/data/sys_user_position', { |
| 103 | + user_id: delegateId, position: 'locked_duty', delegated_from: delegatorId, |
| 104 | + valid_until: validUntil, reason: 'nope', |
| 105 | + }); |
| 106 | + expect(r.status, 'position not delegatable → denied').toBeGreaterThanOrEqual(400); |
| 107 | + }); |
| 108 | + |
| 109 | + // ── RESOLUTION path (L1 validity filter on the delegated grant) ──────────── |
| 110 | + it('the delegate RESOLVES the delegated position while inside the window', async () => { |
| 111 | + const ctx = await resolveAuthzContext({ |
| 112 | + ql, headers: {}, getSession: sessionFor(delegateId), nowMs: Date.now(), |
| 113 | + }); |
| 114 | + expect(ctx.positions, 'delegate holds the delegated position during the window').toContain('vacation_approver'); |
| 115 | + }); |
| 116 | + |
| 117 | + it('the delegated position STOPS resolving at valid_until — access dies, no cleanup job', async () => { |
| 118 | + const afterExpiry = Date.parse(validUntil) + 1000; |
| 119 | + const ctx = await resolveAuthzContext({ |
| 120 | + ql, headers: {}, getSession: sessionFor(delegateId), nowMs: afterExpiry, |
| 121 | + }); |
| 122 | + expect(ctx.positions, 'the delegated grant is gone the instant its window closes').not.toContain('vacation_approver'); |
| 123 | + }); |
| 124 | +}); |
0 commit comments