Files
HIPCTF2/tests/backend/last-admin-invariant.spec.ts
T

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);
});
});