Files
edr-platform/apps/edr-passenger-api/src/common/passenger-permission.util.spec.ts

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,
);
});
});