feat: Admin Area Players Management

This commit is contained in:
OpenVelo Agent
2026-07-23 08:08:47 +00:00
parent 705530e71f
commit c9c82e2c60
27 changed files with 2039 additions and 218 deletions
+456
View File
@@ -0,0 +1,456 @@
process.env.DATABASE_PATH = ':memory:';
process.env.THEMES_DIR = './themes';
process.env.FRONTEND_DIST = './frontend/dist';
import { Test } from '@nestjs/testing';
import { INestApplication, ValidationPipe } from '@nestjs/common';
import { HttpAdapterHost } from '@nestjs/core';
import cookieParser from 'cookie-parser';
import * as express from 'express';
import request from 'supertest';
import { CookieAccessInfo } from 'cookiejar';
import { AppModule } from '../../backend/src/app.module';
import { GlobalExceptionFilter } from '../../backend/src/common/filters/global-exception.filter';
import { CsrfMiddleware } from '../../backend/src/common/middleware/csrf.middleware';
import { ConfigService } from '@nestjs/config';
import { initDb } from './db-helper';
import { DatabaseInitService } from '../../backend/src/database/database-init.service';
import { DataSource } from 'typeorm';
import { UserEntity } from '../../backend/src/database/entities/user.entity';
import { SolveEntity } from '../../backend/src/database/entities/solve.entity';
import { RefreshTokenEntity } from '../../backend/src/database/entities/refresh-token.entity';
import { ChallengeEntity } from '../../backend/src/database/entities/challenge.entity';
import { CategoryEntity } from '../../backend/src/database/entities/category.entity';
import { v4 as uuid } from 'uuid';
import { RegistrationRateLimitService } from '../../backend/src/common/services/registration-rate-limit.service';
interface AdminAgent {
agent: ReturnType<typeof request.agent>;
accessToken: string;
csrf: string;
}
async function loginAs(app: INestApplication, username: string, password: string): Promise<AdminAgent> {
const agent = request.agent(app.getHttpServer());
await agent.get('/api/v1/auth/csrf');
const cookies: any = agent.jar.getCookies(CookieAccessInfo.All);
const csrf = cookies.find((c: any) => c.name === 'csrf')?.value;
const res = await agent
.post('/api/v1/auth/login')
.set('X-CSRF-Token', csrf)
.send({ username, password })
.expect(201);
return { agent, accessToken: res.body.accessToken as string, csrf };
}
async function createPlayer(
app: INestApplication,
adminToken: string,
username: string,
password: string,
): Promise<void> {
const agent = request.agent(app.getHttpServer());
await agent.get('/api/v1/auth/csrf');
const cookies: any = agent.jar.getCookies(CookieAccessInfo.All);
const csrf = cookies.find((c: any) => c.name === 'csrf')?.value;
await agent
.post('/api/v1/admin/users')
.set('Authorization', `Bearer ${adminToken}`)
.set('X-CSRF-Token', csrf)
.send({ username, password, role: 'player' })
.expect(201);
}
async function clearUsers(app: INestApplication): Promise<void> {
const ds = app.get(DataSource);
// The deployment-wide last-admin triggers reject the deletion of the
// last admin row, which is exactly what `clear()` would attempt when
// a prior test left a sole admin in the table. Disable the triggers
// for the duration of the cleanup, then re-enable them.
await ds.query(`DROP TRIGGER IF EXISTS "trg_user_last_admin_update"`);
await ds.query(`DROP TRIGGER IF EXISTS "trg_user_last_admin_delete"`);
try {
await ds.getRepository(UserEntity).clear();
} finally {
await ds.query(`
CREATE TRIGGER IF NOT EXISTS "trg_user_last_admin_update"
BEFORE UPDATE OF "role" ON "user"
FOR EACH ROW
WHEN OLD."role" = 'admin' AND NEW."role" <> 'admin'
AND NOT EXISTS (
SELECT 1 FROM "user" AS "other"
WHERE "other"."role" = 'admin' AND "other"."id" <> OLD."id"
)
BEGIN
SELECT RAISE(ABORT, 'LAST_ADMIN');
END;
`);
await ds.query(`
CREATE TRIGGER IF NOT EXISTS "trg_user_last_admin_delete"
BEFORE DELETE ON "user"
FOR EACH ROW
WHEN OLD."role" = 'admin'
AND NOT EXISTS (
SELECT 1 FROM "user" AS "other"
WHERE "other"."role" = 'admin' AND "other"."id" <> OLD."id"
)
BEGIN
SELECT RAISE(ABORT, 'LAST_ADMIN');
END;
`);
}
}
describe('Admin /players endpoints (Job 867)', () => {
let app: INestApplication;
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile();
app = moduleRef.createNestApplication();
app.use(cookieParser());
app.use(express.json({ limit: '1mb' }));
const csrfMw = new CsrfMiddleware(app.get(ConfigService));
app.use((req: any, res: any, next: any) => csrfMw.use(req, res, next));
app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: false, transform: true }));
const httpAdapterHost = app.get(HttpAdapterHost);
app.useGlobalFilters(new GlobalExceptionFilter(httpAdapterHost));
await app.init();
await app.get(DatabaseInitService).init();
});
afterAll(async () => {
await app.close();
});
beforeEach(async () => {
await clearUsers(app);
// Reset the per-IP registration rate limit so we can register a
// fresh first-admin in each test without hitting the 10-per-minute
// ceiling.
app.get(RegistrationRateLimitService).isAllowed('::ffff:127.0.0.1');
(app.get(RegistrationRateLimitService) as unknown as { hits: Map<string, number[]> }).hits.clear();
});
it('GET /api/v1/admin/players returns safe views (no passwordHash)', async () => {
await request(app.getHttpServer())
.post('/api/v1/auth/register-first-admin')
.send({ username: 'admin', password: 'Sup3rSecret!Pass' })
.expect(201);
const { accessToken } = await loginAs(app, 'admin', 'Sup3rSecret!Pass');
await createPlayer(app, accessToken, 'player1', 'Sup3rSecret!Pass');
const res = await request(app.getHttpServer())
.get('/api/v1/admin/players')
.set('Authorization', `Bearer ${accessToken}`)
.expect(200);
expect(Array.isArray(res.body)).toBe(true);
for (const u of res.body) {
expect(u).not.toHaveProperty('passwordHash');
expect(u).toHaveProperty('id');
expect(u).toHaveProperty('username');
expect(u).toHaveProperty('role');
expect(u).toHaveProperty('status');
expect(u).toHaveProperty('createdAt');
}
});
it('PATCH /api/v1/admin/players/:id/role toggles between admin and player', async () => {
await request(app.getHttpServer())
.post('/api/v1/auth/register-first-admin')
.send({ username: 'admin', password: 'Sup3rSecret!Pass' })
.expect(201);
const { agent, accessToken, csrf } = await loginAs(app, 'admin', 'Sup3rSecret!Pass');
await createPlayer(app, accessToken, 'p-toggle', 'Sup3rSecret!Pass');
const list = await request(app.getHttpServer())
.get('/api/v1/admin/players')
.set('Authorization', `Bearer ${accessToken}`)
.expect(200);
const target = list.body.find((u: any) => u.username === 'p-toggle');
await agent
.patch(`/api/v1/admin/players/${target.id}/role`)
.set('Authorization', `Bearer ${accessToken}`)
.set('X-CSRF-Token', csrf)
.send({ role: 'admin' })
.expect(200)
.expect((r) => expect(r.body.role).toBe('admin'));
await agent
.patch(`/api/v1/admin/players/${target.id}/role`)
.set('Authorization', `Bearer ${accessToken}`)
.set('X-CSRF-Token', csrf)
.send({ role: 'player' })
.expect(200)
.expect((r) => expect(r.body.role).toBe('player'));
});
it('PATCH /api/v1/admin/players/:id/role returns 409 LAST_ADMIN on the only admin', async () => {
await request(app.getHttpServer())
.post('/api/v1/auth/register-first-admin')
.send({ username: 'solo', password: 'Sup3rSecret!Pass' })
.expect(201);
const { agent, accessToken, csrf } = await loginAs(app, 'solo', 'Sup3rSecret!Pass');
const list = await request(app.getHttpServer())
.get('/api/v1/admin/players')
.set('Authorization', `Bearer ${accessToken}`)
.expect(200);
const admin = list.body.find((u: any) => u.username === 'solo');
await agent
.patch(`/api/v1/admin/players/${admin.id}/role`)
.set('Authorization', `Bearer ${accessToken}`)
.set('X-CSRF-Token', csrf)
.send({ role: 'player' })
.expect(409)
.expect((r) => expect(r.body.code).toBe('LAST_ADMIN'));
});
it('PATCH /api/v1/admin/players/:id/status toggles enabled/disabled and prevents login', async () => {
await request(app.getHttpServer())
.post('/api/v1/auth/register-first-admin')
.send({ username: 'admin2', password: 'Sup3rSecret!Pass' })
.expect(201);
const { agent, accessToken, csrf } = await loginAs(app, 'admin2', 'Sup3rSecret!Pass');
await createPlayer(app, accessToken, 'p-disable', 'Sup3rSecret!Pass');
const list = await request(app.getHttpServer())
.get('/api/v1/admin/players')
.set('Authorization', `Bearer ${accessToken}`)
.expect(200);
const target = list.body.find((u: any) => u.username === 'p-disable');
await agent
.patch(`/api/v1/admin/players/${target.id}/status`)
.set('Authorization', `Bearer ${accessToken}`)
.set('X-CSRF-Token', csrf)
.send({ enabled: false })
.expect(200)
.expect((r) => expect(r.body.status).toBe('disabled'));
const loginAgent = request.agent(app.getHttpServer());
await loginAgent.get('/api/v1/auth/csrf');
const loginCookies: any = loginAgent.jar.getCookies(CookieAccessInfo.All);
const loginCsrf = loginCookies.find((c: any) => c.name === 'csrf')?.value;
const login = await loginAgent
.post('/api/v1/auth/login')
.set('X-CSRF-Token', loginCsrf)
.send({ username: 'p-disable', password: 'Sup3rSecret!Pass' });
expect(login.status).toBe(401);
expect(login.body.code).toBe('INVALID_CREDENTIALS');
});
it('PATCH /api/v1/admin/players/:id/password resets without old password and allows login with new', async () => {
await request(app.getHttpServer())
.post('/api/v1/auth/register-first-admin')
.send({ username: 'admin3', password: 'Sup3rSecret!Pass' })
.expect(201);
const { agent, accessToken, csrf } = await loginAs(app, 'admin3', 'Sup3rSecret!Pass');
await createPlayer(app, accessToken, 'p-reset', 'OldPassword!Aa1');
const list = await request(app.getHttpServer())
.get('/api/v1/admin/players')
.set('Authorization', `Bearer ${accessToken}`)
.expect(200);
const target = list.body.find((u: any) => u.username === 'p-reset');
await agent
.patch(`/api/v1/admin/players/${target.id}/password`)
.set('Authorization', `Bearer ${accessToken}`)
.set('X-CSRF-Token', csrf)
.send({ newPassword: 'BrandNew!Pass1', confirmPassword: 'BrandNew!Pass1' })
.expect(204);
const oldAgent = request.agent(app.getHttpServer());
await oldAgent.get('/api/v1/auth/csrf');
const oldCookies: any = oldAgent.jar.getCookies(CookieAccessInfo.All);
const oldCsrf = oldCookies.find((c: any) => c.name === 'csrf')?.value;
const oldLogin = await oldAgent
.post('/api/v1/auth/login')
.set('X-CSRF-Token', oldCsrf)
.send({ username: 'p-reset', password: 'OldPassword!Aa1' });
expect(oldLogin.status).toBe(401);
const newAgent = request.agent(app.getHttpServer());
await newAgent.get('/api/v1/auth/csrf');
const newCookies: any = newAgent.jar.getCookies(CookieAccessInfo.All);
const newCsrf = newCookies.find((c: any) => c.name === 'csrf')?.value;
const newLogin = await newAgent
.post('/api/v1/auth/login')
.set('X-CSRF-Token', newCsrf)
.send({ username: 'p-reset', password: 'BrandNew!Pass1' });
expect(newLogin.status).toBe(201);
});
it('PATCH /api/v1/admin/players/:id/password rejects too-short password with 400', async () => {
await request(app.getHttpServer())
.post('/api/v1/auth/register-first-admin')
.send({ username: 'admin4', password: 'Sup3rSecret!Pass' })
.expect(201);
const { agent, accessToken, csrf } = await loginAs(app, 'admin4', 'Sup3rSecret!Pass');
await createPlayer(app, accessToken, 'p-weak', 'Sup3rSecret!Pass');
const list = await request(app.getHttpServer())
.get('/api/v1/admin/players')
.set('Authorization', `Bearer ${accessToken}`)
.expect(200);
const target = list.body.find((u: any) => u.username === 'p-weak');
await agent
.patch(`/api/v1/admin/players/${target.id}/password`)
.set('Authorization', `Bearer ${accessToken}`)
.set('X-CSRF-Token', csrf)
.send({ newPassword: 'short', confirmPassword: 'short' })
.expect(400);
});
it('DELETE /api/v1/admin/players/:id returns 409 LAST_ADMIN on the only admin', async () => {
await request(app.getHttpServer())
.post('/api/v1/auth/register-first-admin')
.send({ username: 'sole', password: 'Sup3rSecret!Pass' })
.expect(201);
const { agent, accessToken, csrf } = await loginAs(app, 'sole', 'Sup3rSecret!Pass');
const list = await request(app.getHttpServer())
.get('/api/v1/admin/players')
.set('Authorization', `Bearer ${accessToken}`)
.expect(200);
const admin = list.body.find((u: any) => u.username === 'sole');
await agent
.delete(`/api/v1/admin/players/${admin.id}`)
.set('Authorization', `Bearer ${accessToken}`)
.set('X-CSRF-Token', csrf)
.expect(409)
.expect((r) => expect(r.body.code).toBe('LAST_ADMIN'));
});
it('DELETE /api/v1/admin/players/:id succeeds for a non-last admin and removes them', async () => {
await request(app.getHttpServer())
.post('/api/v1/auth/register-first-admin')
.send({ username: 'admin5', password: 'Sup3rSecret!Pass' })
.expect(201);
const { agent, accessToken, csrf } = await loginAs(app, 'admin5', 'Sup3rSecret!Pass');
await createPlayer(app, accessToken, 'p-del', 'Sup3rSecret!Pass');
const list = await request(app.getHttpServer())
.get('/api/v1/admin/players')
.set('Authorization', `Bearer ${accessToken}`)
.expect(200);
const target = list.body.find((u: any) => u.username === 'p-del');
await agent
.delete(`/api/v1/admin/players/${target.id}`)
.set('Authorization', `Bearer ${accessToken}`)
.set('X-CSRF-Token', csrf)
.expect(204);
const after = await request(app.getHttpServer())
.get('/api/v1/admin/players')
.set('Authorization', `Bearer ${accessToken}`)
.expect(200);
expect(after.body.find((u: any) => u.id === target.id)).toBeUndefined();
});
it('DELETE /api/v1/admin/players/:id returns 404 for unknown id', async () => {
await request(app.getHttpServer())
.post('/api/v1/auth/register-first-admin')
.send({ username: 'admin6', password: 'Sup3rSecret!Pass' })
.expect(201);
const { agent, accessToken, csrf } = await loginAs(app, 'admin6', 'Sup3rSecret!Pass');
await agent
.delete('/api/v1/admin/players/00000000-0000-0000-0000-000000000000')
.set('Authorization', `Bearer ${accessToken}`)
.set('X-CSRF-Token', csrf)
.expect(404);
});
it('DELETE cleans up dependent solve + refresh_token rows for the deleted user', async () => {
await request(app.getHttpServer())
.post('/api/v1/auth/register-first-admin')
.send({ username: 'adminC', password: 'Sup3rSecret!Pass' })
.expect(201);
const { agent, accessToken, csrf } = await loginAs(app, 'adminC', 'Sup3rSecret!Pass');
await createPlayer(app, accessToken, 'p-cascade', 'Sup3rSecret!Pass');
const list = await request(app.getHttpServer())
.get('/api/v1/admin/players')
.set('Authorization', `Bearer ${accessToken}`)
.expect(200);
const target = list.body.find((u: any) => u.username === 'p-cascade');
// Seed a challenge, a solve, and a refresh_token tied to the target
// player so we can verify they are removed by the delete.
const ds = app.get(DataSource);
const categories = await ds.getRepository(CategoryEntity).find();
const challengeId = uuid();
const challenge = await ds.getRepository(ChallengeEntity).save({
id: challengeId,
name: `cascade-${target.id}`,
descriptionMd: '',
categoryId: categories[0].id,
difficulty: 'LOW',
initialPoints: 100,
minimumPoints: 50,
decaySolves: 1,
flag: 'flag{cascade}',
protocol: 'WEB',
ipAddress: '',
enabled: true,
} as Partial<ChallengeEntity> as ChallengeEntity);
await ds.getRepository(SolveEntity).save({
id: uuid(),
challengeId: challengeId,
userId: target.id,
solvedAt: new Date().toISOString(),
});
await ds.getRepository(RefreshTokenEntity).save({
id: uuid(),
userId: target.id,
tokenHash: `cascade-${target.id}`,
issuedAt: new Date().toISOString(),
expiresAt: new Date(Date.now() + 60_000).toISOString(),
revokedAt: null,
});
const solvesBefore = await ds.getRepository(SolveEntity).count({ where: { userId: target.id } });
const tokensBefore = await ds.getRepository(RefreshTokenEntity).count({ where: { userId: target.id } });
expect(solvesBefore).toBe(1);
expect(tokensBefore).toBe(1);
await agent
.delete(`/api/v1/admin/players/${target.id}`)
.set('Authorization', `Bearer ${accessToken}`)
.set('X-CSRF-Token', csrf)
.expect(204);
const solvesAfter = await ds.getRepository(SolveEntity).count({ where: { userId: target.id } });
const tokensAfter = await ds.getRepository(RefreshTokenEntity).count({ where: { userId: target.id } });
expect(solvesAfter).toBe(0);
expect(tokensAfter).toBe(0);
});
it('sole-admin DELETE returns 409 LAST_ADMIN and leaves the user row intact', async () => {
await request(app.getHttpServer())
.post('/api/v1/auth/register-first-admin')
.send({ username: 'sole', password: 'Sup3rSecret!Pass' })
.expect(201);
const { agent, accessToken, csrf } = await loginAs(app, 'sole', 'Sup3rSecret!Pass');
const list = await request(app.getHttpServer())
.get('/api/v1/admin/players')
.set('Authorization', `Bearer ${accessToken}`)
.expect(200);
const admin = list.body.find((u: any) => u.username === 'sole');
await agent
.delete(`/api/v1/admin/players/${admin.id}`)
.set('Authorization', `Bearer ${accessToken}`)
.set('X-CSRF-Token', csrf)
.expect(409)
.expect((r) => expect(r.body.code).toBe('LAST_ADMIN'));
const ds = app.get(DataSource);
const stillThere = await ds.getRepository(UserEntity).findOne({ where: { id: admin.id } });
expect(stillThere).not.toBeNull();
});
});
+250 -2
View File
@@ -17,7 +17,7 @@ describe('UsersService.enforceLastAdminInvariant', () => {
logging: false,
});
await ds.initialize();
svc = new UsersService(ds.getRepository(UserEntity));
svc = new UsersService(ds.getRepository(UserEntity), ds);
});
afterAll(async () => {
@@ -78,4 +78,252 @@ describe('UsersService.enforceLastAdminInvariant', () => {
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);
});
});
+39 -3
View File
@@ -5,7 +5,7 @@ process.env.FRONTEND_DIST = './frontend/dist';
import { Test } from '@nestjs/testing';
import { INestApplication, ValidationPipe } from '@nestjs/common';
import { HttpAdapterHost } from '@nestjs/core';
import { Repository } from 'typeorm';
import { DataSource, Repository } from 'typeorm';
import { getRepositoryToken } from '@nestjs/typeorm';
import { AppModule } from '../../backend/src/app.module';
import { GlobalExceptionFilter } from '../../backend/src/common/filters/global-exception.filter';
@@ -40,8 +40,44 @@ describe('Setup: POST /api/v1/setup/create-admin', () => {
});
beforeEach(async () => {
await refreshTokens.clear();
await users.clear();
// The deployment-wide last-admin triggers reject the deletion of
// the last admin row, which is exactly what `clear()` would attempt
// when a prior test left a sole admin in the table. Disable the
// triggers for the duration of the cleanup, then re-enable them.
const ds = app.get(DataSource);
await ds.query(`DROP TRIGGER IF EXISTS "trg_user_last_admin_update"`);
await ds.query(`DROP TRIGGER IF EXISTS "trg_user_last_admin_delete"`);
try {
await refreshTokens.clear();
await users.clear();
} finally {
await ds.query(`
CREATE TRIGGER IF NOT EXISTS "trg_user_last_admin_update"
BEFORE UPDATE OF "role" ON "user"
FOR EACH ROW
WHEN OLD."role" = 'admin' AND NEW."role" <> 'admin'
AND NOT EXISTS (
SELECT 1 FROM "user" AS "other"
WHERE "other"."role" = 'admin' AND "other"."id" <> OLD."id"
)
BEGIN
SELECT RAISE(ABORT, 'LAST_ADMIN');
END;
`);
await ds.query(`
CREATE TRIGGER IF NOT EXISTS "trg_user_last_admin_delete"
BEFORE DELETE ON "user"
FOR EACH ROW
WHEN OLD."role" = 'admin'
AND NOT EXISTS (
SELECT 1 FROM "user" AS "other"
WHERE "other"."role" = 'admin' AND "other"."id" <> OLD."id"
)
BEGIN
SELECT RAISE(ABORT, 'LAST_ADMIN');
END;
`);
}
});
it('creates the very first admin and exposes a session + cookie', async () => {
+99
View File
@@ -0,0 +1,99 @@
import {
adminPlayerApiErrorMessage,
extractFieldErrors,
isLastAdminError,
lastAdminMessage,
removePlayer,
replacePlayer,
toggleEnabled,
toggleRole,
} from '../../frontend/src/app/features/admin/admin-players.pure';
import { AdminUser } from '../../frontend/src/app/core/services/admin.service';
describe('admin-players.pure', () => {
describe('replacePlayer', () => {
it('replaces a row by id immutably', () => {
const rows: AdminUser[] = [
{ id: 'a', username: 'alice', role: 'admin', status: 'enabled' },
{ id: 'b', username: 'bob', role: 'player', status: 'enabled' },
];
const updated: AdminUser = { id: 'b', username: 'bob', role: 'admin', status: 'enabled' };
const out = replacePlayer(rows, updated);
expect(out[0]).toEqual(rows[0]);
expect(out[1]).toEqual(updated);
expect(out).not.toBe(rows);
});
});
describe('removePlayer', () => {
it('removes a row by id', () => {
const rows: AdminUser[] = [
{ id: 'a', username: 'alice', role: 'admin', status: 'enabled' },
{ id: 'b', username: 'bob', role: 'player', status: 'enabled' },
];
expect(removePlayer(rows, 'a').map((u) => u.id)).toEqual(['b']);
});
});
describe('toggleRole', () => {
it('flips admin to player and vice versa', () => {
expect(toggleRole('admin')).toBe('player');
expect(toggleRole('player')).toBe('admin');
});
});
describe('toggleEnabled', () => {
it('returns true for disabled (enables), false for enabled (disables)', () => {
expect(toggleEnabled('disabled')).toBe(true);
expect(toggleEnabled('enabled')).toBe(false);
});
});
describe('isLastAdminError', () => {
it('detects LAST_ADMIN code on the error object', () => {
expect(isLastAdminError({ code: 'LAST_ADMIN' })).toBe(true);
expect(isLastAdminError({ error: { code: 'LAST_ADMIN' } })).toBe(true);
expect(isLastAdminError({ response: { data: { code: 'LAST_ADMIN' } } })).toBe(true);
expect(isLastAdminError({ code: 'NOT_FOUND' })).toBe(false);
expect(isLastAdminError(null)).toBe(false);
});
});
describe('lastAdminMessage', () => {
it('returns a stable explanatory message', () => {
expect(lastAdminMessage()).toContain('At least one admin');
});
});
describe('extractFieldErrors', () => {
it('returns an empty object when details are missing', () => {
expect(extractFieldErrors({ error: {} })).toEqual({});
expect(extractFieldErrors(null)).toEqual({});
});
it('maps a details array into a path→message record', () => {
const err = { error: { details: [{ path: 'newPassword', message: 'too short' }] } };
expect(extractFieldErrors(err)).toEqual({ newPassword: 'too short' });
});
});
describe('adminPlayerApiErrorMessage', () => {
it('uses the LAST_ADMIN message when applicable', () => {
expect(adminPlayerApiErrorMessage({ code: 'LAST_ADMIN' }, 'fallback')).toBe(lastAdminMessage());
});
it('falls back to a default when there is no error detail', () => {
expect(adminPlayerApiErrorMessage(null, 'oops')).toBe('oops');
});
it('returns the server message for a generic validation error', () => {
expect(
adminPlayerApiErrorMessage({ error: { code: 'VALIDATION_FAILED', message: 'Bad input' } }, 'fallback'),
).toBe('Bad input');
});
it('returns a friendly NOT_FOUND message when the server omitted one', () => {
expect(adminPlayerApiErrorMessage({ code: 'NOT_FOUND' }, 'fallback')).toBe('User not found.');
});
});
});
@@ -57,3 +57,15 @@ describe('formatChangePasswordError', () => {
expect(formatChangePasswordError({ code: null, message: null })).toBe('Failed to change password');
});
});
describe('ChangePasswordModalComponent admin-reset field errors', () => {
it('maps a fieldErrors record to per-path messages', () => {
const errors: Record<string, string> = {
newPassword: 'Password too short',
confirmPassword: 'Confirmation does not match',
};
expect(errors['newPassword']).toBe('Password too short');
expect(errors['confirmPassword']).toBe('Confirmation does not match');
expect(errors['confirmNewPassword']).toBeUndefined();
});
});