import { UsersService } from '../../backend/src/modules/users/users.service'; import { DataSource } from 'typeorm'; import { UserEntity } from '../../backend/src/database/entities/user.entity'; import { ApiError } from '../../backend/src/common/errors/api-error'; import { ERROR_CODES } from '../../backend/src/common/errors/error-codes'; describe('UsersService.enforceLastAdminInvariant', () => { let ds: DataSource; let svc: UsersService; beforeAll(async () => { ds = new DataSource({ type: 'better-sqlite3', database: ':memory:', entities: [UserEntity], synchronize: true, logging: false, }); await ds.initialize(); svc = new UsersService(ds.getRepository(UserEntity), ds); }); afterAll(async () => { await ds.destroy(); }); beforeEach(async () => { await ds.getRepository(UserEntity).clear(); }); it('throws LAST_ADMIN when demoting the only admin', async () => { const repo = ds.getRepository(UserEntity); const admin = repo.create({ id: 'a1', username: 'admin', passwordHash: 'x', role: 'admin', status: 'enabled', createdAt: new Date().toISOString(), }); await repo.save(admin); await expect( ds.transaction(async (manager) => svc.enforceLastAdminInvariant(manager, 'a1', 'player')), ).rejects.toMatchObject({ response: { code: ERROR_CODES.LAST_ADMIN }, }); }); it('throws LAST_ADMIN when deleting the only admin', async () => { const repo = ds.getRepository(UserEntity); const admin = repo.create({ id: 'a1', username: 'admin', passwordHash: 'x', role: 'admin', status: 'enabled', createdAt: new Date().toISOString(), }); await repo.save(admin); await expect( ds.transaction(async (manager) => svc.enforceLastAdminInvariant(manager, 'a1')), ).rejects.toMatchObject({ response: { code: ERROR_CODES.LAST_ADMIN }, }); }); it('does NOT throw when more than one admin exists', async () => { const repo = ds.getRepository(UserEntity); await repo.save([ repo.create({ id: 'a1', username: 'a1', passwordHash: 'x', role: 'admin', status: 'enabled', createdAt: new Date().toISOString() }), repo.create({ id: 'a2', username: 'a2', passwordHash: 'x', role: 'admin', status: 'enabled', createdAt: new Date().toISOString() }), ]); await expect( ds.transaction(async (manager) => svc.enforceLastAdminInvariant(manager, 'a1', 'player')), ).resolves.toBeUndefined(); }); it('does NOT throw when demoting a non-admin', async () => { const repo = ds.getRepository(UserEntity); await repo.save([ repo.create({ id: 'a1', username: 'a1', passwordHash: 'x', role: 'admin', status: 'enabled', createdAt: new Date().toISOString() }), repo.create({ id: 'p1', username: 'p1', passwordHash: 'x', role: 'player', status: 'enabled', createdAt: new Date().toISOString() }), ]); await expect( ds.transaction(async (manager) => svc.enforceLastAdminInvariant(manager, 'p1', 'admin')), ).resolves.toBeUndefined(); }); }); describe('UsersService.applyLastAdminSafeMutation', () => { let ds: DataSource; let svc: UsersService; beforeAll(async () => { ds = new DataSource({ type: 'better-sqlite3', database: ':memory:', entities: [UserEntity], synchronize: true, logging: false, }); await ds.initialize(); svc = new UsersService(ds.getRepository(UserEntity), ds); }); afterAll(async () => { await ds.destroy(); }); beforeEach(async () => { await ds.getRepository(UserEntity).clear(); }); it('demotes a non-last admin and persists the new role', async () => { const repo = ds.getRepository(UserEntity); await repo.save([ repo.create({ id: 'a1', username: 'a1', passwordHash: 'x', role: 'admin', status: 'enabled', createdAt: new Date().toISOString() }), repo.create({ id: 'a2', username: 'a2', passwordHash: 'x', role: 'admin', status: 'enabled', createdAt: new Date().toISOString() }), ]); await svc.applyLastAdminSafeMutation('a1', { demotesOrDelete: true, updatedRole: 'player', mutate: async (manager, target) => { target.role = 'player'; await manager.save(target); return target; }, }); const after = await repo.findOne({ where: { id: 'a1' } }); expect(after?.role).toBe('player'); }); it('deletes a non-last admin atomically', async () => { const repo = ds.getRepository(UserEntity); await repo.save([ repo.create({ id: 'a1', username: 'a1', passwordHash: 'x', role: 'admin', status: 'enabled', createdAt: new Date().toISOString() }), repo.create({ id: 'a2', username: 'a2', passwordHash: 'x', role: 'admin', status: 'enabled', createdAt: new Date().toISOString() }), ]); await svc.applyLastAdminSafeMutation('a1', { demotesOrDelete: true, mutate: async (manager) => { await manager.delete(UserEntity, { id: 'a1' }); return null; }, }); const all = await repo.find(); expect(all.map((u) => u.id)).toEqual(['a2']); }); it('rejects demoting the only admin with 409 LAST_ADMIN', async () => { const repo = ds.getRepository(UserEntity); await repo.save( repo.create({ id: 'a1', username: 'a1', passwordHash: 'x', role: 'admin', status: 'enabled', createdAt: new Date().toISOString() }), ); await expect( svc.applyLastAdminSafeMutation('a1', { demotesOrDelete: true, updatedRole: 'player', mutate: async (manager, target) => { target.role = 'player'; await manager.save(target); return target; }, }), ).rejects.toMatchObject({ response: { code: ERROR_CODES.LAST_ADMIN } }); const after = await repo.findOne({ where: { id: 'a1' } }); expect(after?.role).toBe('admin'); }); it('rejects deleting the only admin with 409 LAST_ADMIN', async () => { const repo = ds.getRepository(UserEntity); await repo.save( repo.create({ id: 'a1', username: 'a1', passwordHash: 'x', role: 'admin', status: 'enabled', createdAt: new Date().toISOString() }), ); await expect( svc.applyLastAdminSafeMutation('a1', { demotesOrDelete: true, mutate: async (manager) => { await manager.delete(UserEntity, { id: 'a1' }); return null; }, }), ).rejects.toMatchObject({ response: { code: ERROR_CODES.LAST_ADMIN } }); const after = await repo.find(); expect(after.length).toBe(1); }); it('returns NOT_FOUND for an unknown id', async () => { await expect( svc.applyLastAdminSafeMutation('00000000-0000-0000-0000-000000000000', { demotesOrDelete: false, mutate: async () => null, }), ).rejects.toMatchObject({ response: { code: ERROR_CODES.NOT_FOUND } }); }); it('retries on a transient SQLITE_BUSY lock conflict and succeeds', async () => { const repo = ds.getRepository(UserEntity); await repo.save([ repo.create({ id: 'a1', username: 'a1', passwordHash: 'x', role: 'admin', status: 'enabled', createdAt: new Date().toISOString() }), repo.create({ id: 'a2', username: 'a2', passwordHash: 'x', role: 'admin', status: 'enabled', createdAt: new Date().toISOString() }), ]); const realCreate = ds.createQueryRunner.bind(ds); let busyThrowCount = 0; const busy = Object.assign(new Error('SQLITE_BUSY: database is locked'), { code: 'SQLITE_BUSY' }); (ds as unknown as { createQueryRunner: () => unknown }).createQueryRunner = (() => { busyThrowCount += 1; if (busyThrowCount === 1) throw busy; return realCreate(); }) as typeof ds.createQueryRunner; try { const result = await svc.applyLastAdminSafeMutation('a1', { demotesOrDelete: true, updatedRole: 'player', mutate: async (manager, target) => { target.role = 'player'; await manager.save(target); return target; }, }); expect(busyThrowCount).toBeGreaterThanOrEqual(2); expect(result.role).toBe('player'); } finally { (ds as unknown as { createQueryRunner: typeof ds.createQueryRunner }).createQueryRunner = realCreate; } }); it('rejects with LAST_ADMIN when SQLITE_BUSY retries are exhausted', async () => { const repo = ds.getRepository(UserEntity); await repo.save([ repo.create({ id: 'a1', username: 'a1', passwordHash: 'x', role: 'admin', status: 'enabled', createdAt: new Date().toISOString() }), repo.create({ id: 'a2', username: 'a2', passwordHash: 'x', role: 'admin', status: 'enabled', createdAt: new Date().toISOString() }), ]); const busy = Object.assign(new Error('SQLITE_BUSY: database is locked'), { code: 'SQLITE_BUSY' }); const realCreate = ds.createQueryRunner.bind(ds); (ds as unknown as { createQueryRunner: () => unknown }).createQueryRunner = (() => { throw busy; }) as typeof ds.createQueryRunner; try { await expect( svc.applyLastAdminSafeMutation('a1', { demotesOrDelete: true, updatedRole: 'player', mutate: async () => null, }), ).rejects.toMatchObject({ response: { code: ERROR_CODES.LAST_ADMIN } }); } finally { (ds as unknown as { createQueryRunner: typeof ds.createQueryRunner }).createQueryRunner = realCreate; } }); it('two concurrent demote calls cannot both reduce the admin count below 1', async () => { const repo = ds.getRepository(UserEntity); await repo.save([ repo.create({ id: 'a1', username: 'a1', passwordHash: 'x', role: 'admin', status: 'enabled', createdAt: new Date().toISOString() }), repo.create({ id: 'a2', username: 'a2', passwordHash: 'x', role: 'admin', status: 'enabled', createdAt: new Date().toISOString() }), ]); // Run sequentially so the second call's precondition check observes // the first call's committed state. better-sqlite3 single-connection // concurrency is unreliable for racing two `dataSource.transaction()` // calls in the same Node process; the actual multi-instance // guarantee comes from the SQLite file lock + the BEFORE triggers // migration that this test does not install. The two-step sequential // exercise verifies that the second call's pre-check rejects when // the first has already demoted the second-to-last admin. await svc.applyLastAdminSafeMutation('a1', { demotesOrDelete: true, updatedRole: 'player', mutate: async (manager, target) => { target.role = 'player'; await manager.save(target); return target; }, }); await expect( svc.applyLastAdminSafeMutation('a2', { demotesOrDelete: true, updatedRole: 'player', mutate: async (manager, target) => { target.role = 'player'; await manager.save(target); return target; }, }), ).rejects.toMatchObject({ response: { code: ERROR_CODES.LAST_ADMIN } }); const afterCount = await repo.count({ where: { role: 'admin' } }); expect(afterCount).toBe(1); const after = await repo.find(); const a2 = after.find((u) => u.id === 'a2'); expect(a2?.role).toBe('admin'); }); it('concurrent role-toggle + delete cannot reduce the admin count below 1', async () => { const repo = ds.getRepository(UserEntity); await repo.save([ repo.create({ id: 'a1', username: 'a1', passwordHash: 'x', role: 'admin', status: 'enabled', createdAt: new Date().toISOString() }), repo.create({ id: 'a2', username: 'a2', passwordHash: 'x', role: 'admin', status: 'enabled', createdAt: new Date().toISOString() }), ]); // Sequential: first demote, then attempt to delete the last admin. // better-sqlite3 single-connection concurrency cannot reliably race // two top-level transactions in a single Node process; the // multi-instance guarantee is provided by the SQLite file lock plus // the BEFORE UPDATE/DELETE triggers (installed in production by the // AddUserLastAdminTriggers migration). await svc.applyLastAdminSafeMutation('a1', { demotesOrDelete: true, updatedRole: 'player', mutate: async (manager, target) => { target.role = 'player'; await manager.save(target); return target; }, }); await expect( svc.applyLastAdminSafeMutation('a2', { demotesOrDelete: true, mutate: async (manager) => { await manager.delete(UserEntity, { id: 'a2' }); return null; }, }), ).rejects.toMatchObject({ response: { code: ERROR_CODES.LAST_ADMIN } }); const afterCount = await repo.count({ where: { role: 'admin' } }); expect(afterCount).toBe(1); }); });