Files
HIPCTF2/tests/backend/admin-players.spec.ts
T
2026-07-23 08:43:44 +00:00

510 lines
20 KiB
TypeScript

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();
});
it('two-admin concurrent role demotion: one succeeds, the other receives 409 LAST_ADMIN (Job 913)', async () => {
await request(app.getHttpServer())
.post('/api/v1/auth/register-first-admin')
.send({ username: 'original', password: 'Sup3rSecret!Pass' })
.expect(201);
const adminAuth = await loginAs(app, 'original', 'Sup3rSecret!Pass');
const adminAgent = request.agent(app.getHttpServer());
await adminAgent.get('/api/v1/auth/csrf');
const adminCsrfCookies: any = adminAgent.jar.getCookies(CookieAccessInfo.All);
const adminCsrf = adminCsrfCookies.find((c: any) => c.name === 'csrf')?.value;
await adminAgent
.post('/api/v1/admin/users')
.set('Authorization', `Bearer ${adminAuth.accessToken}`)
.set('X-CSRF-Token', adminCsrf)
.send({ username: 'bob', password: 'Sup3rSecret!Pass', role: 'admin' })
.expect(201);
const list = await request(app.getHttpServer())
.get('/api/v1/admin/players')
.set('Authorization', `Bearer ${adminAuth.accessToken}`)
.expect(200);
const originalRow = list.body.find((u: any) => u.username === 'original');
const bobRow = list.body.find((u: any) => u.username === 'bob');
expect(originalRow.role).toBe('admin');
expect(bobRow.role).toBe('admin');
const bobAuth = await loginAs(app, 'bob', 'Sup3rSecret!Pass');
const first = await adminAuth.agent
.patch(`/api/v1/admin/players/${originalRow.id}/role`)
.set('Authorization', `Bearer ${adminAuth.accessToken}`)
.set('X-CSRF-Token', adminAuth.csrf)
.send({ role: 'player' })
.expect(200)
.expect((r) => expect(r.body.role).toBe('player'));
const second = await bobAuth.agent
.patch(`/api/v1/admin/players/${bobRow.id}/role`)
.set('Authorization', `Bearer ${bobAuth.accessToken}`)
.set('X-CSRF-Token', bobAuth.csrf)
.send({ role: 'player' })
.expect(409)
.expect((r) => expect(r.body.code).toBe('LAST_ADMIN'));
const ds = app.get(DataSource);
const rowsAfter = await ds.getRepository(UserEntity).find();
const adminCount = rowsAfter.filter((u) => u.role === 'admin').length;
expect(adminCount).toBe(1);
const remainingAdmin = rowsAfter.find((u) => u.role === 'admin');
expect(remainingAdmin?.username).toBe('bob');
});
});