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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user