Creating a link now requires a valid email address and mails the link to it, which is the whole point: getting a link to a contributor was previously a copy-and-paste into whatever the admin happened to use. The send is awaited and its outcome reported, unlike every other sender in this codebase, which fires and forgets because nobody is waiting on the answer. Here somebody is. The admin is looking at the screen, and whether they now have to send the link by hand is exactly the thing they need to know — and QA blocks delivery to any address outside MAIL_ALLOWLIST by design, so a link that was never emailed would otherwise look precisely like one that was. A send that could not happen does not roll the link back. The token is displayed exactly once, so a rollback would leave the admin retrying and holding a different link, discarding work that had succeeded. They end up with a usable link and an honest statement about delivery instead. One inaccuracy left deliberately: an SMTP rejection is reported as skipped-unconfigured rather than a fourth outcome of its own. The distinction is real but nothing consumes it, and the admin's next action is identical either way. Also updates the other integration tests that created a link with only a label, since an address is now required, and adds the uploadLink template key that GET /api/admin/email-templates was missing from its list — an omission left by the template's addition in the prior commit on this branch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
276 lines
10 KiB
TypeScript
276 lines
10 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, 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<string, string> = {}) {
|
|
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('<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);
|
|
});
|
|
});
|
|
|
|
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;
|
|
});
|
|
});
|