457 lines
18 KiB
TypeScript
457 lines
18 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();
|
|
});
|
|
});
|