mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 19:30:57 +00:00
281 lines
10 KiB
TypeScript
281 lines
10 KiB
TypeScript
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,
|
|
);
|
|
});
|
|
});
|