fix: validate permissions in create, RoleConflictError, rollback safety, cache consistency

- Add permissions type/array validation in createRoleHandler
- Introduce RoleConflictError class replacing fragile string-prefix matching
- Wrap rollback in !role null path with try/catch for correct 404 response
- Wrap deleteRoleByName cache.set in try/catch matching createRoleByName
- Narrow updateRoleHandler body type to { name?, description? }
- Add tests: non-string description in create, rollback failure logging,
  permissions array rejection, description max-length assertion fix
This commit is contained in:
Dustin Healy 2026-03-26 17:05:01 -07:00
parent 16678c0ece
commit b9e0fa48c6
5 changed files with 95 additions and 21 deletions

View file

@ -7,6 +7,7 @@ export * from './utils';
export { createModels } from './models';
export {
createMethods,
RoleConflictError,
DEFAULT_REFRESH_TOKEN_EXPIRY,
DEFAULT_SESSION_EXPIRY,
tokenValues,

View file

@ -1,6 +1,7 @@
import { createSessionMethods, DEFAULT_REFRESH_TOKEN_EXPIRY, type SessionMethods } from './session';
import { createTokenMethods, type TokenMethods } from './token';
import { createRoleMethods, type RoleMethods, type RoleDeps } from './role';
import { createRoleMethods, RoleConflictError, type RoleMethods, type RoleDeps } from './role';
export { RoleConflictError };
import { createUserMethods, DEFAULT_SESSION_EXPIRY, type UserMethods } from './user';
export { DEFAULT_REFRESH_TOKEN_EXPIRY, DEFAULT_SESSION_EXPIRY };

View file

@ -9,6 +9,13 @@ import type { Model } from 'mongoose';
import type { IRole, IUser } from '~/types';
import logger from '~/config/winston';
export class RoleConflictError extends Error {
constructor(message: string) {
super(message);
this.name = 'RoleConflictError';
}
}
export interface RoleDeps {
/** Returns a cache store for the given key. Injected from getLogStores. */
getCache?: (key: string) => {
@ -350,12 +357,12 @@ export function createRoleMethods(mongoose: typeof import('mongoose'), deps: Rol
throw new Error('Role name is required');
}
if (SystemRoles[name.trim() as keyof typeof SystemRoles]) {
throw new Error(`Cannot create role with reserved system name: ${name}`);
throw new RoleConflictError(`Cannot create role with reserved system name: ${name}`);
}
const Role = mongoose.models.Role;
const existing = await Role.findOne({ name: name.trim() }).lean();
if (existing) {
throw new Error(`Role "${name.trim()}" already exists`);
throw new RoleConflictError(`Role "${name.trim()}" already exists`);
}
let role;
try {
@ -371,7 +378,7 @@ export function createRoleMethods(mongoose: typeof import('mongoose'), deps: Rol
* the same user-facing message as the application-level duplicate check.
*/
if (err && typeof err === 'object' && 'code' in err && err.code === 11000) {
throw new Error(`Role "${name.trim()}" already exists`);
throw new RoleConflictError(`Role "${name.trim()}" already exists`);
}
throw err;
}
@ -400,9 +407,13 @@ export function createRoleMethods(mongoose: typeof import('mongoose'), deps: Rol
}
await getUserModel().updateMany({ role: roleName }, { $set: { role: SystemRoles.USER } });
const deleted = await Role.findOneAndDelete({ name: roleName }).lean();
const cache = deps.getCache?.(CacheKeys.ROLES);
if (cache) {
await cache.set(roleName, null);
try {
const cache = deps.getCache?.(CacheKeys.ROLES);
if (cache) {
await cache.set(roleName, null);
}
} catch (cacheError) {
logger.error(`[deleteRoleByName] cache invalidation failed for "${roleName}":`, cacheError);
}
return deleted as IRole | null;
}