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-". 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); }); });