import { assertCanApproveContractStep, canEditContractStep, collectPermissionKeys, hasFreightPermission, setPositionTypePermissionResolver, } from './freight-permission.util'; import { FREIGHT_PERMS } from '../seed/freight-permissions.registry'; // The document-edit gate (canEditContractStep) must be STRICT: only the approver // whose turn it is may edit. This is the fix for a previous approver keeping the // "Edit contract articles" button after acting, because the approve gate lets // through anyone holding any contract-approve permission. describe('canEditContractStep (strict per-step edit gate)', () => { const director = { employee: { position: { positionType: { key: '-marketing-director-' } } }, }; // A line staff who already approved their own step but still holds a // contract-approve permission — the exact actor that leaked edit rights. const officerWithApprovePerm = { employee: { position: { positionType: { key: '-marketing-officer-' }, permissions: [{ key: FREIGHT_PERMS.contracts.approveLineStaff }], }, }, }; const superAdmin = { roles: [{ key: 'super_admin' }] }; it('lets the step’s own approver edit', () => { expect(canEditContractStep(director, '-marketing-director-')).toBe(true); }); it('lets an approval admin edit any step', () => { expect(canEditContractStep(superAdmin, '-marketing-director-')).toBe(true); }); it('does NOT let a different approver edit just because they hold an approve permission', () => { expect(canEditContractStep(officerWithApprovePerm, '-marketing-director-')).toBe( false, ); }); it('stays intentionally stricter than the approve gate (which keeps the blanket fallback)', () => { // The approve gate passes the officer via the any-permission blanket… expect(() => assertCanApproveContractStep(officerWithApprovePerm, '-marketing-director-'), ).not.toThrow(); // …but the edit gate does not — that divergence IS the fix. expect(canEditContractStep(officerWithApprovePerm, '-marketing-director-')).toBe( false, ); }); }); /** * The GL lockout regression: positions created through the admin UI keep their * grants on the position TYPE, and the JWT only ever snapshots DIRECT position * permissions. Without the type resolver those staff resolved to zero * permissions, so every gated route rejected them — which is what kept GL * officers out of their own clearance detail pages. */ describe('collectPermissionKeys — position-type grants', () => { const CLEARANCE = FREIGHT_PERMS.contracts.clearanceReview; afterEach(() => { setPositionTypePermissionResolver(() => []); }); const glOfficer = { roles: [], permissions: [], employee: { position: { permissions: [], // admin-created position carries NO direct grants positionType: { key: 'commercial-global-logistics-(et)-officer' }, }, }, }; it('resolves permissions carried by the position type', () => { setPositionTypePermissionResolver((key) => key === 'commercial-global-logistics-(et)-officer' ? [CLEARANCE] : [], ); expect(collectPermissionKeys(glOfficer)).toContain(CLEARANCE); expect(hasFreightPermission(glOfficer, CLEARANCE)).toBe(true); }); it('handles the array-shaped employee payload too', () => { setPositionTypePermissionResolver(() => [CLEARANCE]); const arrayShaped = { roles: [], permissions: [], employee: [ { positions: [ { permissions: [], positionType: { key: 'djibouti-gl-officer' } }, ], }, ], }; expect(hasFreightPermission(arrayShaped, CLEARANCE)).toBe(true); }); it('still rejects when neither the position nor its type grants it', () => { setPositionTypePermissionResolver(() => []); expect(hasFreightPermission(glOfficer, CLEARANCE)).toBe(false); }); it('keeps direct position permissions working with no resolver installed', () => { const direct = { roles: [], permissions: [], employee: { position: { permissions: [{ key: CLEARANCE }] } }, }; expect(hasFreightPermission(direct, CLEARANCE)).toBe(true); }); });