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>
141 lines
4.4 KiB
TypeScript
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([]);
|
|
});
|
|
});
|