Files
redefined-designs/backend/tests/integration/intake.integration.test.ts
T
bermudalamb 1fc632598a feat(intake): accept photo submissions through a shared link (#222)
The public way in. Photos of one item plus a free-text note, from someone with no account, landing as an `items` row at status 'pending' — already invisible to every public and storefront query since #90, so nothing is live by accident.

Every refusal is a 404. Unknown, revoked and exhausted links are indistinguishable from outside, because whether a link exists is not something a stranger needs to be able to learn — the same reasoning uploads.ts applies to files.

The link is resolved *before* multer runs, and that ordering is the point rather than an implementation detail. discardUnlessAccepted would delete the files afterwards, but "written then deleted" is materially worse than "never written" on an endpoint the whole internet can reach: it is disk churn an unauthenticated caller controls, and it leans on an unlink that a crash between write and delete would skip. A test asserts the volume is untouched for a bad token, so a future reordering fails loudly instead of quietly handing that control away.

The link counter is incremented inside the transaction and guarded on the same conditions as the lookup, so two submissions racing for the last slot of a capped link cannot both succeed. The response carries no item id: the sender has no business knowing about the catalogue and nothing they could do with it.

The AI is deliberately not called here. A slow or failing model request must not turn into a failed upload for someone who did nothing wrong, and the photos may be the only copy — the item is often no longer in the sender's hands. The row waits at state 'queued' for #223.

The new limiter keys on the caller alone, since a submission carries no email. keyByCallerAndEmail's comment warns that a bare ip bucket is a shared allowance, and that trade is taken knowingly: the link is the per-caller identity and its cap is the per-caller bound, while this limiter does the different job of bounding what one address can throw at an endpoint that writes files. Twenty per fifteen minutes is deliberately looser than the password-reset allowance — somebody photographing a box of stock legitimately submits several in a row, and refusing them costs a consignment.

Because the route mounts the shared uploadImages, it inherits the type allowlist, the magic-byte check and #226's EXIF stripping without asking for any of them. A test asserts the stripping specifically, since this is the route where it matters most: the photo comes from a stranger's phone rather than the shop's own camera.

Backend: 284 integration (12 new), 309 unit, lint unchanged at 6 pre-existing warnings, build clean.

Ref #222
2026-08-31 15:42:26 -05:00

210 lines
7.7 KiB
TypeScript

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<string[]> {
return fs.readdir(UPLOADS_DIR);
}
async function issueLink(label = 'Sarah', maxSubmissions?: number | null): Promise<string> {
const res = await request(app)
.post('/api/admin/upload-links')
.send({ label, ...(maxSubmissions === undefined ? {} : { maxSubmissions }) });
expect(res.status).toBe(201);
return res.body.token as string;
}
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('<html>not an image</html>'), {
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);
});
});