Files
API/test/admin/admin.schema.test.ts
T
PaddyandClaude Opus 5.5 bfb8f2ff9c Add a read-only role per app for calendar, feedback and tickets
A grant can now be `read` instead of `access`: requireAppAccess lets it
through for GET/HEAD/OPTIONS and answers 403 to every other method, so
new write routes are covered without being marked. `read` and `access`
on the same app are rejected together; user management has no read role.
/tickets/admin/me and /feedback/admin/me report `readOnly`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 23:24:28 +01:00

141 lines
4.4 KiB
TypeScript

import {describe, expect, it} from 'vitest';
import {
ACCESS_ROLE,
READ_ROLE,
appsOf,
canWrite,
isAppPermission,
isAppRole,
isReadOnly,
toPermissions
} from '../../src/models/admin/admin.schema.js';
/**
* The permission model is (app, role). These tests pin the two properties the
* rest of the module leans on: that the older `['tickets']` shape still means
* "tickets at the access role", and that nothing outside APP_ROLES gets in.
*/
describe('toPermissions', () => {
it('reads the full (app, role) form', () => {
expect(toPermissions([{app: 'tickets', role: 'access'}])).toEqual([
{app: 'tickets', role: 'access'}
]);
});
it('reads a plain app list as that app at the access role', () => {
expect(toPermissions(['feedback', 'admin'])).toEqual([
{app: 'feedback', role: ACCESS_ROLE},
{app: 'admin', role: ACCESS_ROLE}
]);
});
it('accepts the two forms mixed, which is what a half-migrated caller sends', () => {
expect(toPermissions(['feedback', {app: 'tickets', role: 'access'}])).toEqual([
{app: 'feedback', role: ACCESS_ROLE},
{app: 'tickets', role: ACCESS_ROLE}
]);
});
it('drops duplicates of the same (app, role)', () => {
expect(toPermissions(['tickets', {app: 'tickets', role: 'access'}])).toEqual([
{app: 'tickets', role: ACCESS_ROLE}
]);
});
it('rejects rather than silently dropping an unknown app', () => {
// Silently ignoring it would let "grant calendar + nonsense" look like a
// success while granting less than the caller asked for.
expect(toPermissions(['calendar', 'nonsense'])).toBeNull();
});
it('rejects an unknown role', () => {
expect(toPermissions([{app: 'tickets', role: 'refund'}])).toBeNull();
});
it('rejects anything that is not a list', () => {
expect(toPermissions('admin')).toBeNull();
expect(toPermissions(null)).toBeNull();
expect(toPermissions({app: 'admin', role: 'access'})).toBeNull();
});
it('reads an empty list as "no permissions", not as invalid', () => {
expect(toPermissions([])).toEqual([]);
});
it('reads the read-only role', () => {
expect(toPermissions([{app: 'tickets', role: READ_ROLE}])).toEqual([
{app: 'tickets', role: READ_ROLE}
]);
});
it('rejects full and read-only access to the same app together', () => {
expect(toPermissions(['tickets', {app: 'tickets', role: READ_ROLE}])).toBeNull();
});
it('allows full access to one app next to read-only access to another', () => {
expect(toPermissions(['tickets', {app: 'feedback', role: READ_ROLE}])).toEqual([
{app: 'tickets', role: ACCESS_ROLE},
{app: 'feedback', role: READ_ROLE}
]);
});
it('rejects read-only access to user management', () => {
expect(toPermissions([{app: 'admin', role: READ_ROLE}])).toBeNull();
});
});
describe('canWrite / isReadOnly', () => {
it('lets only the access role write', () => {
const permissions = [
{app: 'tickets' as const, role: ACCESS_ROLE},
{app: 'feedback' as const, role: READ_ROLE}
];
expect(canWrite(permissions, 'tickets')).toBe(true);
expect(canWrite(permissions, 'feedback')).toBe(false);
expect(isReadOnly(permissions, 'tickets')).toBe(false);
expect(isReadOnly(permissions, 'feedback')).toBe(true);
});
it('is neither for an app without any grant', () => {
expect(canWrite([], 'calendar')).toBe(false);
expect(isReadOnly([], 'calendar')).toBe(false);
});
});
describe('isAppRole', () => {
it('accepts the access role for every app', () => {
expect(isAppRole('admin', ACCESS_ROLE)).toBe(true);
expect(isAppRole('calendar', ACCESS_ROLE)).toBe(true);
});
it('rejects a role that does not exist yet', () => {
expect(isAppRole('tickets', 'refund')).toBe(false);
});
});
describe('isAppPermission', () => {
it('needs both halves to be valid', () => {
expect(isAppPermission({app: 'tickets', role: ACCESS_ROLE})).toBe(true);
expect(isAppPermission({app: 'tickets'})).toBe(false);
expect(isAppPermission({role: ACCESS_ROLE})).toBe(false);
expect(isAppPermission(null)).toBe(false);
});
});
describe('appsOf', () => {
it('collapses several roles on one app to a single entry', () => {
// The point of the derived list: a user with two roles on tickets has
// access to tickets once, not twice.
const apps = appsOf([
{app: 'tickets', role: ACCESS_ROLE},
{app: 'tickets', role: 'future-role'},
{app: 'admin', role: ACCESS_ROLE}
]);
expect(apps).toEqual(['tickets', 'admin']);
});
it('is empty for no permissions', () => {
expect(appsOf([])).toEqual([]);
});
});