Files
redefined-designs/backend/tests/integration/uploadLinks.integration.test.ts
T
bermudalamb 3392f6f10d feat(intake): issue and revoke named upload links (#222)
Three routes behind the admin gate: list, create, revoke. A link is named because provenance matters more than convenience — when one is shared further than intended the question is which one, and every submission will record the link it arrived through, so revoking kills that link rather than the feature.

The token is returned by exactly one response and is unrecoverable afterwards, which is why the admin screen has to present it as a one-time reveal. The listing selects its columns explicitly rather than `SELECT *`, so `token_hash` cannot reach a response the moment somebody adds a convenience — and a test asserts the listing carries neither the token nor the digest.

An absent `maxSubmissions` gets a bounded default of 25 rather than null. Absent means nobody decided; an explicit null means unlimited, which is a decision visible in the request. Reading absent as unlimited is what would quietly make every link unbounded, and the common case is the one that has to be safe.

Revoking is idempotent through COALESCE, and a test asserts the second call returns the *same* timestamp rather than merely succeeding. The useful fact is when access ended, and a button that errors on a double-click teaches people to distrust it — which is the last thing wanted on the control that contains a leak.

Mounted above the `/api/admin` catch-all, which would otherwise swallow the path, and behind requireAdminGate on the router itself per the reasoning in middleware/adminGate.ts.

Lint caught me reintroducing something this codebase had already solved: I wrote `.replace(/\/+$/, '')` to trim PUBLIC_URL, and app.ts carried a hand-written loop with a comment explaining that exact regex backtracks. Rather than duplicate the loop, trimTrailingSlashes moved to utils.ts and both callers now share it.

Backend: 272 integration (9 new), 308 unit, lint back to its 6 pre-existing warnings, build clean.

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

111 lines
3.9 KiB
TypeScript

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);
});
});