fix: address re-review findings for admin roles

- Gate deleteRoleByName on existence check — skip user reassignment and
  cache invalidation when role doesn't exist (fixes test mismatch)
- Reverse rename order: migrate users before renaming role so a migration
  failure leaves the system in a consistent state
- Add .sort({ _id: 1 }) to listUsersByRole for deterministic pagination
- Import shared AdminMember type from data-schemas instead of local copy;
  make joinedAt optional since neither groups nor roles populate it
- Change IRole.description from optional to required to match schema default
- Add data-layer tests for updateUsersByRole and countUsersByRole
- Add handler test verifying users-first rename ordering and migration
  failure safety
This commit is contained in:
Dustin Healy 2026-03-26 15:54:14 -07:00
parent 7d776de71a
commit 94fdb3cd93
6 changed files with 94 additions and 16 deletions

View file

@ -27,7 +27,9 @@ let updateAccessPermissions: ReturnType<typeof createRoleMethods>['updateAccessP
let initializeRoles: ReturnType<typeof createRoleMethods>['initializeRoles'];
let createRoleByName: ReturnType<typeof createRoleMethods>['createRoleByName'];
let deleteRoleByName: ReturnType<typeof createRoleMethods>['deleteRoleByName'];
let updateUsersByRole: ReturnType<typeof createRoleMethods>['updateUsersByRole'];
let listUsersByRole: ReturnType<typeof createRoleMethods>['listUsersByRole'];
let countUsersByRole: ReturnType<typeof createRoleMethods>['countUsersByRole'];
let mongoServer: MongoMemoryServer;
beforeAll(async () => {
@ -43,7 +45,9 @@ beforeAll(async () => {
initializeRoles = methods.initializeRoles;
createRoleByName = methods.createRoleByName;
deleteRoleByName = methods.deleteRoleByName;
updateUsersByRole = methods.updateUsersByRole;
listUsersByRole = methods.listUsersByRole;
countUsersByRole = methods.countUsersByRole;
});
afterAll(async () => {
@ -670,3 +674,53 @@ describe('listUsersByRole', () => {
expect('username' in users[0]).toBe(false);
});
});
describe('updateUsersByRole', () => {
it('migrates all users from one role to another', async () => {
await User.create([
{ name: 'Alice', email: 'alice@test.com', role: 'editor', username: 'alice' },
{ name: 'Bob', email: 'bob@test.com', role: 'editor', username: 'bob' },
{ name: 'Carol', email: 'carol@test.com', role: SystemRoles.USER, username: 'carol' },
]);
await updateUsersByRole('editor', 'senior-editor');
const alice = await User.findOne({ email: 'alice@test.com' }).lean();
const bob = await User.findOne({ email: 'bob@test.com' }).lean();
const carol = await User.findOne({ email: 'carol@test.com' }).lean();
expect(alice!.role).toBe('senior-editor');
expect(bob!.role).toBe('senior-editor');
expect(carol!.role).toBe(SystemRoles.USER);
});
it('is a no-op when no users have the source role', async () => {
await User.create({
name: 'Alice',
email: 'alice@test.com',
role: SystemRoles.USER,
username: 'alice',
});
await updateUsersByRole('nonexistent', 'new-role');
const alice = await User.findOne({ email: 'alice@test.com' }).lean();
expect(alice!.role).toBe(SystemRoles.USER);
});
});
describe('countUsersByRole', () => {
it('returns the count of users with the given role', async () => {
await User.create([
{ name: 'Alice', email: 'alice@test.com', role: 'editor', username: 'alice' },
{ name: 'Bob', email: 'bob@test.com', role: 'editor', username: 'bob' },
{ name: 'Carol', email: 'carol@test.com', role: SystemRoles.USER, username: 'carol' },
]);
expect(await countUsersByRole('editor')).toBe(2);
expect(await countUsersByRole(SystemRoles.USER)).toBe(1);
});
it('returns 0 when no users have the role', async () => {
expect(await countUsersByRole('nonexistent')).toBe(0);
});
});

View file

@ -382,6 +382,10 @@ export function createRoleMethods(mongoose: typeof import('mongoose'), deps: Rol
throw new Error(`Cannot delete system role: ${roleName}`);
}
const Role = mongoose.models.Role;
const exists = await Role.findOne({ name: roleName }).lean();
if (!exists) {
return null;
}
const User = mongoose.models.User as Model<IUser>;
await User.updateMany({ role: roleName }, { $set: { role: SystemRoles.USER } });
const deleted = await Role.findOneAndDelete({ name: roleName }).lean();
@ -406,6 +410,7 @@ export function createRoleMethods(mongoose: typeof import('mongoose'), deps: Rol
const offset = options?.offset ?? 0;
return await User.find({ role: roleName })
.select('_id name email avatar')
.sort({ _id: 1 })
.skip(offset)
.limit(limit)
.lean();

View file

@ -114,7 +114,7 @@ export type AdminMember = {
name: string;
email: string;
avatarUrl?: string;
joinedAt: string;
joinedAt?: string;
};
/** Minimal user info returned by user search endpoints. */

View file

@ -5,7 +5,7 @@ import { CursorPaginationParams } from '~/common';
export interface IRole extends Document {
name: string;
description?: string;
description: string;
permissions: {
[PermissionTypes.BOOKMARKS]?: {
[Permissions.USE]?: boolean;