330 lines
12 KiB
TypeScript
330 lines
12 KiB
TypeScript
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);
|
|
});
|
|
});
|