import request from 'supertest'; import app from '../../src/app'; import { pool } from '../../src/db'; import { resetDb, closeDb } from './setup/testDb'; beforeEach(async () => { await resetDb(); }); afterAll(async () => { await pool.end(); await closeDb(); }); describe('issuing an upload link', () => { it('returns the token exactly once, at creation', async () => { const created = await request(app) .post('/api/admin/upload-links') .send({ label: 'Sarah' }); expect(created.status).toBe(201); expect(created.body.label).toBe('Sarah'); expect(created.body.token).toMatch(/^[A-Za-z0-9_-]{43}$/); expect(created.body.url).toContain(`/submit/${created.body.token}`); const listed = await request(app).get('/api/admin/upload-links'); expect(listed.status).toBe(200); expect(listed.body).toHaveLength(1); // The whole point of storing a digest: the listing cannot hand it back. expect(listed.body[0].token).toBeUndefined(); expect(listed.body[0].token_hash).toBeUndefined(); }); it('stores the digest rather than the token', async () => { const created = await request(app) .post('/api/admin/upload-links') .send({ label: 'Estate sale box 3' }); const { rows } = await pool.query<{ token_hash: string }>( `SELECT token_hash FROM upload_links` ); expect(rows[0]?.token_hash).not.toBe(created.body.token); expect(rows[0]?.token_hash).toMatch(/^[a-f0-9]{64}$/); }); it('refuses a link with no label', async () => { const res = await request(app).post('/api/admin/upload-links').send({ label: ' ' }); expect(res.status).toBe(400); }); it('refuses a non-positive submission cap', async () => { const res = await request(app) .post('/api/admin/upload-links') .send({ label: 'Bad cap', maxSubmissions: 0 }); expect(res.status).toBe(400); }); // Omitting the field is the common case, so it is the case that has to be // safe. An unbounded link should be something asked for, not something that // happens when nobody thought about it. it('bounds a link that was created without a cap', async () => { const res = await request(app).post('/api/admin/upload-links').send({ label: 'Sarah' }); expect(res.status).toBe(201); expect(res.body.max_submissions).toBe(25); }); it('allows unlimited when it is asked for explicitly', async () => { const res = await request(app) .post('/api/admin/upload-links') .send({ label: 'Always on', maxSubmissions: null }); expect(res.status).toBe(201); expect(res.body.max_submissions).toBeNull(); }); }); describe('revoking an upload link', () => { it('stamps revoked_at and reports it in the listing', async () => { const created = await request(app) .post('/api/admin/upload-links') .send({ label: 'Temporary' }); const revoked = await request(app) .post(`/api/admin/upload-links/${created.body.id}/revoke`); expect(revoked.status).toBe(200); expect(revoked.body.revoked_at).not.toBeNull(); }); // The useful fact is when access ended, so a second click must not rewrite // it — and it must not be an error either, because a button that fails on a // double-click teaches people to distrust it. it('is idempotent, keeping the original timestamp', async () => { const created = await request(app) .post('/api/admin/upload-links') .send({ label: 'Temporary' }); const first = await request(app).post(`/api/admin/upload-links/${created.body.id}/revoke`); const second = await request(app).post(`/api/admin/upload-links/${created.body.id}/revoke`); expect(second.status).toBe(200); expect(second.body.revoked_at).toBe(first.body.revoked_at); }); it('404s for a link that does not exist', async () => { const res = await request(app).post('/api/admin/upload-links/9999/revoke'); expect(res.status).toBe(404); }); });