intake.integration.test.ts, intakeCeiling.integration.test.ts and uploadLinks.integration.test.ts all create upload links, and since #260 that now sends real mail. env.setup.ts never clears SMTP_USER, SMTP_PASSWORD or MAIL_ALLOWLIST, so with those inherited from a developer's shell these three files opened live TLS connections to smtp.gmail.com:465 and, with no allowlist set, actually delivered to sarah@example.com. This is the exact hazard the "Do not add one back" comment in tests/unit/mailOutcome.test.ts already warns about, reintroduced at the integration layer. All three now mock ../../src/mailer the same way accountDetails.integration.test.ts, favorites.integration.test.ts and resendVerification.integration.test.ts already do. uploadLinks.integration.test.ts is the one place that needs to see specific MailOutcome values come back through the route, so its two outcome tests were restructured to drive the mock's return value directly (sentMail.mockResolvedValueOnce(...)) instead of threading SMTP_USER/MAIL_ALLOWLIST through the real sendMail. That is a cleaner test anyway: it isolates the route's job (reporting whatever outcome sendMail returns) from sendMail's own skip logic, which is already covered hermetically by mailOutcome.test.ts and mailAllowlist.test.ts. Also addresses the related minor finding that nothing asserted the mail actually carried the working link: required: ['submitUrl'] on the template only guards that the placeholder is present in the body, not that the route supplied a correct value for it. A new test in uploadLinks.integration.test.ts inspects the mock's captured call and asserts the html contains the created token's /submit/ URL, and covers the three submissionsAllowed phrasings (a numeric cap, a cap of exactly one, and uncapped). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
284 lines
10 KiB
TypeScript
284 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';
|
|
|
|
// Every submission-cap test here issues a link, which now emails it. Mocked
|
|
// so the suite never opens a real connection to smtp.gmail.com — see the
|
|
// "Do not add one back" warning in tests/unit/mailOutcome.test.ts, which this
|
|
// mirrors at the integration layer.
|
|
jest.mock('../../src/mailer', () => ({
|
|
sendMail: jest.fn().mockResolvedValue('sent')
|
|
}));
|
|
|
|
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;
|
|
});
|
|
});
|