import request from 'supertest'; import { promises as fs } from 'fs'; import path from 'path'; import app from '../../src/app'; import { pool } from '../../src/db'; import { resetDb, closeDb } from './setup/testDb'; const UPLOADS_DIR = process.env.UPLOADS_DIR as string; // A genuine 1x1 PNG, so the accepted case exercises the whole path rather than // a buffer that merely starts with the right bytes. const REAL_PNG = Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64' ); const HTML_BYTES = Buffer.from('', 'utf8'); const PDF_BYTES = Buffer.from('%PDF-1.4\n%����\n', 'binary'); // No test has ever attached a file before this one, so the uploads directory // has never had to exist. multer.diskStorage does not create its destination. beforeAll(async () => { await fs.mkdir(UPLOADS_DIR, { recursive: true }); }); beforeEach(async () => { await resetDb(); }); afterAll(async () => { await pool.end(); await closeDb(); }); async function storedFiles(): Promise { return fs.readdir(UPLOADS_DIR); } function createItem() { return request(app) .post('/api/admin/items') .field('name', 'Photographed thing') .field('description', '') .field('price', '30'); } describe('what the inventory upload accepts', () => { it('accepts a real PNG and stores it', async () => { const res = await createItem().attach('images', REAL_PNG, { filename: 'photo.png', contentType: 'image/png' }); expect(res.status).toBe(200); expect(res.body.images).toHaveLength(1); expect(res.body.images[0].image_path).toMatch(/^\/uploads\/[0-9a-f-]+\.png$/); }); // The stored name is derived from the validated type, so a caller cannot put // a .html path onto a volume that express.static serves from the app's own // origin just by naming their file that way. See #103. it('names the stored file from its type, not from the submitted filename', async () => { const res = await createItem().attach('images', REAL_PNG, { filename: 'evil.html', contentType: 'image/png' }); expect(res.status).toBe(200); expect(res.body.images[0].image_path).toMatch(/\.png$/); expect(res.body.images[0].image_path).not.toContain('html'); }); }); describe('what it refuses', () => { it('refuses a type that is not an accepted image, naming what is', async () => { const res = await createItem().attach('images', PDF_BYTES, { filename: 'brochure.pdf', contentType: 'application/pdf' }); expect(res.status).toBe(400); expect(res.body.error).toContain('application/pdf'); expect(res.body.error).toContain('image/png'); }); // SVG is an image and is deliberately not accepted, because it executes // script when navigated to directly. it('refuses SVG even though it is an image', async () => { const res = await createItem().attach('images', Buffer.from(''), { filename: 'logo.svg', contentType: 'image/svg+xml' }); expect(res.status).toBe(400); expect(res.body.error).toContain('image/svg+xml'); }); // The case an allowlist on the declared type alone waves straight through. it('refuses a document wearing an image content type', async () => { const res = await createItem().attach('images', HTML_BYTES, { filename: 'photo.jpg', contentType: 'image/jpeg' }); expect(res.status).toBe(400); expect(res.body.error).toContain('does not contain'); }); it('leaves nothing on the volume when it refuses the content', async () => { const before = await storedFiles(); await createItem().attach('images', HTML_BYTES, { filename: 'photo.jpg', contentType: 'image/jpeg' }); expect(await storedFiles()).toHaveLength(before.length); }); // A rejection must take the whole request with it. Accepting the good file // from a refused upload would leave a file behind that nothing references. it('discards the valid files from a request that also carried an invalid one', async () => { const before = await storedFiles(); const res = await createItem() .attach('images', REAL_PNG, { filename: 'good.png', contentType: 'image/png' }) .attach('images', HTML_BYTES, { filename: 'bad.jpg', contentType: 'image/jpeg' }); expect(res.status).toBe(400); expect(await storedFiles()).toHaveLength(before.length); }); it('does not create the item when the upload is refused', async () => { await createItem().attach('images', HTML_BYTES, { filename: 'photo.jpg', contentType: 'image/jpeg' }); const { rows } = await pool.query(`SELECT COUNT(*)::int AS n FROM items`); expect(rows[0].n).toBe(0); }); }); describe('the uploads directory', () => { it('only ever gains files with an extension the allowlist produced', async () => { await createItem().attach('images', REAL_PNG, { filename: 'whatever.jpeg', contentType: 'image/png' }); const files = await storedFiles(); for (const file of files) { expect(['.jpg', '.png', '.webp']).toContain(path.extname(file)); } }); });