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; accessToken: string; csrf: string; } async function loginAs(app: INestApplication, username: string, password: string): Promise { 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 { 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 { 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 }).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 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(); }); });