Files
HIPCTF2/tests/backend/uploads-logo.spec.ts
T

211 lines
8.7 KiB
TypeScript

process.env.DATABASE_PATH = ':memory:';
process.env.THEMES_DIR = './themes';
process.env.FRONTEND_DIST = './frontend/dist';
process.env.UPLOAD_DIR = '/tmp/hipctf-uploads-logo-test';
process.env.UPLOAD_SIZE_LIMIT = '1mb';
import * as fs from 'fs';
import * as path from 'path';
import sharp from 'sharp';
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';
describe('Uploads endpoint - /uploads/logo (admin only)', () => {
let app: INestApplication;
let adminToken: string;
let playerToken: string;
beforeAll(async () => {
if (fs.existsSync(process.env.UPLOAD_DIR!)) {
fs.rmSync(process.env.UPLOAD_DIR!, { recursive: true, force: true });
}
fs.mkdirSync(process.env.UPLOAD_DIR!, { recursive: true });
const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile();
app = moduleRef.createNestApplication();
app.use(cookieParser());
app.use(express.json({ limit: '1mb' }));
// Mirror production: serve /uploads static so we can verify public fetch.
app.use('/uploads', express.static(process.env.UPLOAD_DIR!));
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 initDb(app);
const server = app.getHttpServer();
await request(server).post('/api/v1/auth/register-first-admin')
.send({ username: 'admin', password: 'Sup3rSecret!Pass' })
.expect(201);
const csrfPrime = await request(server).get('/api/v1/auth/csrf');
const csrfCookie = (csrfPrime.headers['set-cookie'] as unknown as string[] | undefined)?.find((c) => c.startsWith('csrf='));
const csrfToken = csrfCookie ? decodeURIComponent(csrfCookie.split(';')[0].split('=')[1]) : '';
const login = await request(server).post('/api/v1/auth/login')
.set('Cookie', `csrf=${csrfToken}`)
.set('X-CSRF-Token', csrfToken)
.send({ username: 'admin', password: 'Sup3rSecret!Pass' })
.expect(201);
adminToken = login.body.accessToken;
// Create a player to test 403.
{
const agent = request.agent(server);
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: 'logo_player', password: 'Sup3rSecret!Pass', role: 'player' })
.expect(201);
const playerLogin = await agent.post('/api/v1/auth/login')
.set('X-CSRF-Token', csrf)
.send({ username: 'logo_player', password: 'Sup3rSecret!Pass' })
.expect(201);
playerToken = playerLogin.body.accessToken;
}
});
afterAll(async () => {
await app.close();
if (fs.existsSync(process.env.UPLOAD_DIR!)) {
fs.rmSync(process.env.UPLOAD_DIR!, { recursive: true, force: true });
}
});
async function primeCsrf(): Promise<{ agent: any; csrf: string }> {
const server = app.getHttpServer();
const agent = request.agent(server);
await agent.get('/api/v1/auth/csrf');
const cookies: any = agent.jar.getCookies(CookieAccessInfo.All);
return { agent, csrf: cookies.find((c: any) => c.name === 'csrf').value };
}
it('rejects /uploads/logo without auth (or CSRF)', async () => {
// The CsrfMiddleware short-circuits with 403 before the JWT guard runs, so
// a fully unauthenticated request without a CSRF cookie returns 403 here.
// A properly authenticated-but-unsigned request still returns 403 as well.
await request(app.getHttpServer())
.post('/api/v1/uploads/logo')
.attach('file', Buffer.from([0x89, 0x50, 0x4e, 0x47]), 'logo.png')
.expect(403);
});
it('rejects /uploads/logo with a player JWT (403)', async () => {
const { agent, csrf } = await primeCsrf();
await agent
.post('/api/v1/uploads/logo')
.set('Authorization', `Bearer ${playerToken}`)
.set('X-CSRF-Token', csrf)
.attach('file', Buffer.from([0x89, 0x50, 0x4e, 0x47]), 'logo.png')
.expect(403);
});
it('uploads a small image and returns publicUrl + originalFilename', async () => {
const { agent, csrf } = await primeCsrf();
const originalName = `My Logo ${Date.now()}.PNG`;
// Generate a real, decodable PNG with sharp so the upload path
// exercises its full image-validation policy.
const png = await sharp({
create: { width: 32, height: 32, channels: 3, background: { r: 10, g: 20, b: 30 } },
})
.png()
.toBuffer();
const res = await agent
.post('/api/v1/uploads/logo')
.set('Authorization', `Bearer ${adminToken}`)
.set('X-CSRF-Token', csrf)
.attach('file', png, originalName)
.expect(201);
expect(res.body).toHaveProperty('publicUrl');
expect(res.body).toHaveProperty('originalFilename');
expect(res.body.originalFilename).toBe(originalName);
// safeFilename() lowercases + cleans the stem and appends a short hex suffix,
// so the stored filename is derived from the original — not always literally
// "logo-<hex>". We only assert the publicUrl shape and that it is fetchable.
expect(res.body.publicUrl).toMatch(/^\/uploads\/[a-z0-9._-]+\.png$/);
// The file was actually written under UPLOAD_DIR.
const fileName = res.body.publicUrl.replace('/uploads/', '');
const finalPath = path.join(process.env.UPLOAD_DIR!, fileName);
expect(fs.existsSync(finalPath)).toBe(true);
expect(fs.readFileSync(finalPath).length).toBeGreaterThan(0);
// And it is publicly fetchable via the static middleware.
await request(app.getHttpServer()).get(res.body.publicUrl).expect(200);
// Clean up the file we created so the suite remains hermetic.
fs.unlinkSync(finalPath);
});
it('rejects oversize payloads (limit=1mb, body=2mb)', async () => {
const { agent, csrf } = await primeCsrf();
const big = Buffer.alloc(2 * 1024 * 1024, 0x61); // 2 MB of 'a'
await agent
.post('/api/v1/uploads/logo')
.set('Authorization', `Bearer ${adminToken}`)
.set('X-CSRF-Token', csrf)
.attach('file', big, 'big.png')
.expect(400);
});
it('rejects a plain-text payload labeled as an image', async () => {
const { agent, csrf } = await primeCsrf();
const res = await agent
.post('/api/v1/uploads/logo')
.set('Authorization', `Bearer ${adminToken}`)
.set('X-CSRF-Token', csrf)
.attach('file', Buffer.from('not an image'), { filename: 'logo.png', contentType: 'image/png' });
expect(res.status).toBe(400);
expect(res.body?.message).toMatch(/valid PNG|JPEG|GIF|WebP/i);
const after = fs.existsSync(process.env.UPLOAD_DIR!)
? fs.readdirSync(process.env.UPLOAD_DIR!)
: [];
expect(after.some((f) => f.endsWith('.png') && f.includes('logo'))).toBe(false);
});
it('rejects a corrupt GIF payload', async () => {
const { agent, csrf } = await primeCsrf();
// GIF89a header followed by a truncated/invalid body.
const corruptGif = Buffer.concat([
Buffer.from('GIF89a', 'ascii'),
Buffer.alloc(8, 0x00),
Buffer.from([0xff, 0xff, 0xff]),
]);
const res = await agent
.post('/api/v1/uploads/logo')
.set('Authorization', `Bearer ${adminToken}`)
.set('X-CSRF-Token', csrf)
.attach('file', corruptGif, { filename: 'malformed.gif', contentType: 'image/gif' });
expect(res.status).toBe(400);
expect(res.body?.message).toMatch(/valid PNG|JPEG|GIF|WebP/i);
});
it('rejects an 8-byte corrupt PNG payload', async () => {
const { agent, csrf } = await primeCsrf();
const res = await agent
.post('/api/v1/uploads/logo')
.set('Authorization', `Bearer ${adminToken}`)
.set('X-CSRF-Token', csrf)
.attach('file', Buffer.alloc(8, 0x00), { filename: 'bad.png', contentType: 'image/png' });
expect(res.status).toBe(400);
expect(res.body?.message).toMatch(/valid PNG|JPEG|GIF|WebP/i);
});
});