import request from 'supertest'; import { promises as fs } from 'fs'; 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; // The same 1x1 PNG the upload validation suite uses, so the accepted case // exercises the whole path rather than a buffer that merely starts right. const PNG = Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64' ); // 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); } async function issueLink(label = 'Sarah', maxSubmissions?: number | null): Promise { const res = await request(app) .post('/api/admin/upload-links') .send({ label, email: 'sarah@example.com', ...(maxSubmissions === undefined ? {} : { maxSubmissions }) }); expect(res.status).toBe(201); return res.body.token as string; } /** A submission with optional extra multipart fields beside the photo. */ function postPhoto(token: string, fields: Record = {}) { const req = request(app).post(`/api/intake/${token}`); for (const [name, value] of Object.entries(fields)) void req.field(name, value); return req.attach('images', PNG, 'a.png'); } describe('checking a link before showing the form', () => { it('names the link so the page can greet the sender', async () => { const token = await issueLink('Sarah'); const res = await request(app).get(`/api/intake/${token}`); expect(res.status).toBe(200); expect(res.body.label).toBe('Sarah'); }); // 404 rather than 403 throughout: whether a link exists is not something a // stranger needs to be able to distinguish. Same reasoning as uploads.ts. it('404s an unknown token', async () => { const res = await request(app).get('/api/intake/not-a-real-token'); expect(res.status).toBe(404); }); it('404s a revoked link', async () => { const token = await issueLink(); const { rows } = await pool.query<{ id: number }>(`SELECT id FROM upload_links`); await request(app).post(`/api/admin/upload-links/${rows[0]?.id}/revoke`); const res = await request(app).get(`/api/intake/${token}`); expect(res.status).toBe(404); }); }); describe('submitting an item', () => { it('creates a pending item with its images, note and provenance', async () => { const token = await issueLink('Sarah'); const res = await request(app) .post(`/api/intake/${token}`) .field('note', 'Hand-thrown stoneware, chip on the base') .attach('images', PNG, 'front.png') .attach('images', PNG, 'back.png'); expect(res.status).toBe(201); expect(res.body.ok).toBe(true); const { rows: items } = await pool.query<{ id: number; status: string; price_cents: number }>( `SELECT id, status, price_cents FROM items` ); expect(items).toHaveLength(1); expect(items[0]?.status).toBe('pending'); // The migration's default, not a price anyone chose. expect(items[0]?.price_cents).toBe(8000); const { rows: images } = await pool.query<{ image_path: string }>( `SELECT image_path FROM item_images WHERE item_id = $1 ORDER BY sort_order`, [items[0]?.id] ); expect(images).toHaveLength(2); expect(images[0]?.image_path).toMatch(/^\/uploads\/[a-f0-9-]+\.png$/); const { rows: drafts } = await pool.query( `SELECT submitter_note, state, price_source, upload_link_id FROM item_drafts WHERE item_id = $1`, [items[0]?.id] ); expect(drafts[0]?.submitter_note).toBe('Hand-thrown stoneware, chip on the base'); expect(drafts[0]?.state).toBe('queued'); expect(drafts[0]?.price_source).toBe('default'); expect(drafts[0]?.upload_link_id).not.toBeNull(); }); it('counts the submission against the link', async () => { const token = await issueLink(); await request(app).post(`/api/intake/${token}`).attach('images', PNG, 'a.png'); const { rows } = await pool.query<{ submission_count: number; last_used_at: string | null }>( `SELECT submission_count, last_used_at FROM upload_links` ); expect(rows[0]?.submission_count).toBe(1); expect(rows[0]?.last_used_at).not.toBeNull(); }); it('refuses a submission with no photos', async () => { const token = await issueLink(); const res = await request(app).post(`/api/intake/${token}`).field('note', 'nothing attached'); expect(res.status).toBe(400); const { rows } = await pool.query(`SELECT id FROM items`); expect(rows).toHaveLength(0); }); // The file is named .png and declared image/png, but the bytes are not. // This is the check that cannot happen before the write. it('refuses a file whose bytes disagree with its type', async () => { const token = await issueLink(); const res = await request(app) .post(`/api/intake/${token}`) .attach('images', Buffer.from('not an image'), { filename: 'evil.png', contentType: 'image/png' }); expect(res.status).toBe(400); const { rows } = await pool.query(`SELECT id FROM items`); expect(rows).toHaveLength(0); }); it('404s a revoked link without creating anything', async () => { const token = await issueLink(); const { rows: links } = await pool.query<{ id: number }>(`SELECT id FROM upload_links`); await request(app).post(`/api/admin/upload-links/${links[0]?.id}/revoke`); const res = await request(app).post(`/api/intake/${token}`).attach('images', PNG, 'a.png'); expect(res.status).toBe(404); const { rows } = await pool.query(`SELECT id FROM items`); expect(rows).toHaveLength(0); }); // The reason requireUsableLink is ordered ahead of uploadImages. Without that // ordering this still returns 404 and still creates no item — the bytes just // reach the disk first and are deleted afterwards. This asserts they never // arrive, so a future reordering fails here rather than quietly handing an // unauthenticated caller control of disk churn. it('writes nothing to the uploads volume for a token that does not work', async () => { const before = await storedFiles(); const res = await request(app) .post('/api/intake/not-a-real-token') .attach('images', PNG, 'a.png'); expect(res.status).toBe(404); expect(await storedFiles()).toEqual(before); }); it('stops accepting once the link hits its cap', async () => { const token = await issueLink('One shot', 1); const first = await request(app).post(`/api/intake/${token}`).attach('images', PNG, 'a.png'); expect(first.status).toBe(201); const second = await request(app).post(`/api/intake/${token}`).attach('images', PNG, 'b.png'); expect(second.status).toBe(404); const { rows } = await pool.query(`SELECT id FROM items`); expect(rows).toHaveLength(1); }); // #226 applies here too, and this route is exactly where it matters most: // the photo comes from a stranger's phone rather than the shop's own camera. it('strips metadata from a submitted photo', async () => { const token = await issueLink(); await request(app).post(`/api/intake/${token}`).attach('images', PNG, 'a.png'); const { rows } = await pool.query<{ image_path: string }>(`SELECT image_path FROM item_images`); const sharp = (await import('sharp')).default; const stored = await sharp( `${UPLOADS_DIR}/${rows[0]?.image_path.replace('/uploads/', '')}` ).metadata(); expect(stored.exif).toBeUndefined(); }); }); describe('a submitted item does not reach the storefront', () => { it('is absent from the public catalogue', async () => { const token = await issueLink(); await request(app).post(`/api/intake/${token}`).attach('images', PNG, 'a.png'); const res = await request(app).get('/api/items'); expect(res.status).toBe(200); expect(res.body).toHaveLength(0); }); }); describe('the background-removal intent', () => { // Ticked by default on the page, so absent means yes. An older client or a // curl call then behaves like the current default rather than silently // opting out of something every other submission gets. it('defaults to true when the field is not sent', async () => { const token = await issueLink(); await postPhoto(token); const { rows } = await pool.query<{ remove_background: boolean }>( `SELECT remove_background FROM item_drafts ORDER BY id DESC LIMIT 1` ); expect(rows[0]?.remove_background).toBe(true); }); it('records a submitter who unticked it', async () => { const token = await issueLink(); await postPhoto(token, { removeBackground: 'false' }); const { rows } = await pool.query<{ remove_background: boolean }>( `SELECT remove_background FROM item_drafts ORDER BY id DESC LIMIT 1` ); expect(rows[0]?.remove_background).toBe(false); }); // Only the exact string opts out. A stray value is not a considered "no", // and reading it as one would quietly deny somebody something they asked for. it('treats anything other than "false" as consent', async () => { const token = await issueLink(); await postPhoto(token, { removeBackground: 'no' }); const { rows } = await pool.query<{ remove_background: boolean }>( `SELECT remove_background FROM item_drafts ORDER BY id DESC LIMIT 1` ); expect(rows[0]?.remove_background).toBe(true); }); }); describe('what the submission page is told', () => { it('says the feature is off when there is no sidecar', async () => { delete process.env.REMBG_URL; const token = await issueLink(); const res = await request(app).get(`/api/intake/${token}`); expect(res.status).toBe(200); expect(res.body.backgroundRemoval).toBe(false); }); it('says it is on when there is one', async () => { process.env.REMBG_URL = 'http://rembg-syn:7000'; const token = await issueLink(); const res = await request(app).get(`/api/intake/${token}`); expect(res.body.backgroundRemoval).toBe(true); delete process.env.REMBG_URL; }); });