import { ForbiddenException } from '@nestjs/common'; import { assertPassengerPermission, collectPermissionKeys, hasPassengerPermission, hasPassengerPermissionStrict, isOrganizationAdmin, isSuperAdmin, } from './passenger-permission.util'; const TICKETS_MANAGE = 'edr_passenger_app:tickets:manage'; const TICKETS_VIEW = 'edr_passenger_app:tickets:view'; const BOOKINGS_VIEW = 'edr_passenger_app:bookings:view'; const FLEET_MANAGE = 'edr_passenger_app:fleet:manage'; /** IAM shape: position types nest the key one level deeper, under `.permission`. */ const positionType = (keys: string[]) => ({ positionTypePermissions: keys.map((key) => ({ permission: { key } })), }); /** Mirrors the ticket-officer position IAM returns: own grant + two position types. */ const ticketOfficerPosition = () => ({ id: 'pos-ticket-officer', permissions: [{ key: BOOKINGS_VIEW }], positionType: positionType([TICKETS_VIEW, TICKETS_MANAGE]), positionTypes: [ positionType(['can:view:expectation']), positionType([TICKETS_VIEW, TICKETS_MANAGE]), ], }); const hrPosition = () => ({ id: 'pos-hr', permissions: [{ key: 'hr_app:employees:view' }], positionType: positionType(['hr_app:leave:approve']), positionTypes: [positionType(['hr_app:leave:approve'])], }); /** `/v1/auth/me` — `employee` is an array of employees, each with `positions[]`. */ const meShape = (positions: any[]) => ({ roles: [], permissions: [], employee: [{ id: 'emp-1', positions }], }); /** * `request.user` — `JwtGuard.parseToken` spreads the employee, then overwrites * `position` with the one it selected and adds `delegatedPositions[]`. The full * `positions[]` survives the spread. */ const requestShape = (positions: any[], selectedIndex = 0) => ({ roles: [], permissions: [], employee: { id: 'emp-1', positions, position: positions[selectedIndex], delegatedPositions: positions.filter((p: any) => p.isDelegate), }, }); describe('collectPermissionKeys', () => { it('returns nothing for a missing user', () => { expect(collectPermissionKeys(null)).toEqual([]); expect(collectPermissionKeys(undefined)).toEqual([]); expect(collectPermissionKeys({})).toEqual([]); }); it('collects role permissions when there is no employee record', () => { expect(collectPermissionKeys({ permissions: [{ key: TICKETS_VIEW }] })).toEqual([TICKETS_VIEW]); expect(collectPermissionKeys({ permissions: [{ key: TICKETS_VIEW }], employee: null })).toEqual([ TICKETS_VIEW, ]); }); describe('backward compatibility — positions with no position types', () => { it('still reads a position own permissions[] in the /v1/auth/me shape', () => { const user = meShape([{ id: 'p1', permissions: [{ key: BOOKINGS_VIEW }] }]); expect(collectPermissionKeys(user)).toEqual([BOOKINGS_VIEW]); }); it('still reads a position own permissions[] in the request.user shape', () => { const user = requestShape([{ id: 'p1', permissions: [{ key: BOOKINGS_VIEW }] }]); expect(collectPermissionKeys(user)).toEqual([BOOKINGS_VIEW]); }); it('merges role permissions with position permissions', () => { const user = { permissions: [{ key: 'role:key' }], employee: [{ positions: [{ permissions: [{ key: BOOKINGS_VIEW }] }] }], }; expect(collectPermissionKeys(user).sort()).toEqual([BOOKINGS_VIEW, 'role:key'].sort()); }); }); describe('position types', () => { it('collects from the legacy singular positionType', () => { const user = meShape([{ permissions: [], positionType: positionType([TICKETS_MANAGE]) }]); expect(collectPermissionKeys(user)).toEqual([TICKETS_MANAGE]); }); it('collects from the new positionTypes[] array', () => { const user = meShape([{ permissions: [], positionTypes: [positionType([TICKETS_MANAGE])] }]); expect(collectPermissionKeys(user)).toEqual([TICKETS_MANAGE]); }); it('collects from every entry of positionTypes[], not just the first', () => { const user = meShape([ { permissions: [], positionTypes: [positionType(['a']), positionType(['b']), positionType(['c'])], }, ]); expect(collectPermissionKeys(user).sort()).toEqual(['a', 'b', 'c']); }); it('unions permissions[], positionType and positionTypes[] without duplicates', () => { // ticket-officer appears as BOTH the singular positionType and inside positionTypes[] const keys = collectPermissionKeys(meShape([ticketOfficerPosition()])); expect(keys.sort()).toEqual( [BOOKINGS_VIEW, TICKETS_VIEW, TICKETS_MANAGE, 'can:view:expectation'].sort(), ); expect(keys).toHaveLength(new Set(keys).size); }); it('works identically in the request.user shape', () => { expect(collectPermissionKeys(requestShape([ticketOfficerPosition()])).sort()).toEqual( collectPermissionKeys(meShape([ticketOfficerPosition()])).sort(), ); }); }); describe('all positions count, not only the selected one', () => { it('grants a permission held by a position that is not positions[0]', () => { // parseToken selected positions[0] (HR) because no x-current-position-id was sent const user = requestShape([hrPosition(), ticketOfficerPosition()], 0); expect(collectPermissionKeys(user)).toContain(TICKETS_MANAGE); }); it('matches what the backoffice computes from the same payload', () => { const positions = [hrPosition(), ticketOfficerPosition()]; expect(collectPermissionKeys(requestShape(positions, 0)).sort()).toEqual( collectPermissionKeys(meShape(positions)).sort(), ); }); it('spans multiple employee records', () => { const user = { employee: [{ positions: [hrPosition()] }, { positions: [ticketOfficerPosition()] }], }; expect(collectPermissionKeys(user)).toContain(TICKETS_MANAGE); expect(collectPermissionKeys(user)).toContain('hr_app:leave:approve'); }); it('includes delegated positions', () => { const delegated = { ...ticketOfficerPosition(), isDelegate: true }; const user = { employee: { positions: undefined, position: hrPosition(), delegatedPositions: [delegated] }, }; expect(collectPermissionKeys(user)).toContain(TICKETS_MANAGE); }); it('does not invent permissions nobody was granted', () => { const user = requestShape([hrPosition(), ticketOfficerPosition()]); expect(collectPermissionKeys(user)).not.toContain(FLEET_MANAGE); }); }); describe('malformed payloads', () => { it('survives nulls, missing keys and non-array fields', () => { const user: any = { permissions: [{}, { key: 'kept' }], employee: { // parseToken passes `positions` through untouched when it is not an array positions: { id: 'not-an-array' }, position: { permissions: null, positionType: null, positionTypes: [null, { positionTypePermissions: null }, positionType([TICKETS_VIEW])], }, delegatedPositions: null, }, }; expect(collectPermissionKeys(user).sort()).toEqual(['kept', TICKETS_VIEW].sort()); }); it('ignores positionTypePermissions entries with no permission object', () => { const user: any = { employee: [ { positions: [ { positionTypes: [ { positionTypePermissions: [ {}, { permission: null }, { permission: { key: 'ok' } }, ], }, ], }, ], }, ], }; expect(collectPermissionKeys(user)).toEqual(['ok']); }); }); }); describe('role helpers', () => { it('detects super admin and org admin', () => { expect(isSuperAdmin({ roles: [{ key: 'super_admin' }] })).toBe(true); expect(isOrganizationAdmin({ roles: [{ key: 'organization_admin' }] })).toBe(true); expect(isSuperAdmin({ roles: [{ key: 'ticket_officer' }] })).toBe(false); expect(isOrganizationAdmin({})).toBe(false); }); }); describe('hasPassengerPermission', () => { it('is true for a permission granted only through a position type', () => { expect(hasPassengerPermission(requestShape([ticketOfficerPosition()]), TICKETS_MANAGE)).toBe( true, ); }); it('is true when the granting position is not the selected one', () => { const user = requestShape([hrPosition(), ticketOfficerPosition()], 0); expect(hasPassengerPermission(user, TICKETS_MANAGE)).toBe(true); }); it('is false for a permission nobody granted', () => { expect(hasPassengerPermission(requestShape([ticketOfficerPosition()]), FLEET_MANAGE)).toBe( false, ); }); it('is false without a user', () => { expect(hasPassengerPermission(null, TICKETS_MANAGE)).toBe(false); }); it('lets super admins and org admins bypass', () => { expect(hasPassengerPermission({ roles: [{ key: 'super_admin' }] }, FLEET_MANAGE)).toBe(true); expect(hasPassengerPermission({ roles: [{ key: 'organization_admin' }] }, FLEET_MANAGE)).toBe( true, ); }); }); describe('hasPassengerPermissionStrict', () => { it('does not let super admins bypass', () => { expect(hasPassengerPermissionStrict({ roles: [{ key: 'super_admin' }] }, FLEET_MANAGE)).toBe( false, ); }); it('honours a position-type grant', () => { expect(hasPassengerPermissionStrict(requestShape([ticketOfficerPosition()]), TICKETS_MANAGE)).toBe( true, ); }); }); describe('assertPassengerPermission', () => { it('passes silently when granted through a position type', () => { expect(() => assertPassengerPermission(requestShape([ticketOfficerPosition()]), TICKETS_MANAGE), ).not.toThrow(); }); it('throws ForbiddenException naming the missing key', () => { expect(() => assertPassengerPermission(requestShape([hrPosition()]), TICKETS_MANAGE)).toThrow( ForbiddenException, ); expect(() => assertPassengerPermission(requestShape([hrPosition()]), TICKETS_MANAGE)).toThrow( TICKETS_MANAGE, ); }); });