AI Implementation feature(867): Admin Area Players Management (#56)
This commit was merged in pull request #56.
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user