fix(intake): stop a throttled sender being told their link is dead (#222)
Linting / lint (pull_request) Successful in 2m27s
SonarQube Analysis / sonarqube (pull_request) Successful in 22m27s

Adding e2e specs for the submission page found a defect in the page they were written for, which is what they were for.

One limiter counted page loads and submissions against the same twenty-per-quarter-hour allowance, so a sender working through a box of stock ran out after ten items — the exact person the feature exists for, and the exact case the limiter's own comment said must not be refused. The comment said refusing them costs a consignment while the number quietly did it.

Worse, the page could not tell a 429 from a 404. `fetchIntakeLink` treated any non-OK response as "no link", so a throttled sender was told "This link is not active" and sent to ask for a replacement — which could not have helped, because the problem was their address and a minute of patience. Two conditions needing opposite reactions were sharing a message.

Now two limiters, because the two requests cost different things. Reading a link hits one indexed row and writes nothing, so that allowance is generous at 120: someone re-reading the form or losing their signal should never be told to wait. Submitting writes up to six files, so that is the one worth bounding, at 30 — more than anyone photographing items can manage and far less than a script would want.

The page gains a third state. Unknown, revoked and used-up still collapse into one "not active" card, because whether a link exists is not something a stranger needs to learn. Throttled is deliberately kept apart from them, since "wait a moment" and "go and ask for another link" are opposite instructions.

Measured rather than assumed, on a freshly started process both times: before, 25 page loads produced 14 rejections; after, 40 produce none. The first attempt at that measurement was wrong and worth recording — the restart had failed with EADDRINUSE, so it read 30 of 30 against the old process's already-exhausted store.

The two specs now pass in a full parallel run alongside everything else. They are scoped the way #241 asks: unique run ids, assertions naming only this run's rows, nothing asserted about the table as a whole.

Backend: 284 integration, 309 unit. Frontend: build clean, lint unchanged at 2 pre-existing warnings.

Ref #222, #241
This commit is contained in:
2026-08-31 15:42:26 -05:00
parent 9b4d7f2d03
commit bcecda9122
6 changed files with 233 additions and 24 deletions
+34 -6
View File
@@ -144,13 +144,41 @@ export function keyByCaller(req: Request): string {
return ipKeyGenerator(req.ip ?? ''); return ipKeyGenerator(req.ip ?? '');
} }
// Deliberately looser than the password-reset allowance. Somebody photographing /**
// a box of stock legitimately submits several items in a row, and the cost of * Two limiters rather than one, because the two requests cost different things.
// refusing them is a lost consignment — whereas the cost of allowing a few too *
// many is some disk the volume guard and the per-link cap already bound. * Reading a link is a page load: it hits one indexed row and writes nothing.
export const intakeLimiter = rateLimit({ * Submitting writes up to six files to the uploads volume. Counting them
* against a single allowance meant reloading the page consumed the budget for
* sending items, and at twenty apiece that allowance ran out after ten items —
* for exactly the person this feature is for, somebody working through a box
* of stock. The comment here used to say refusing them costs a consignment,
* while the number quietly did it.
*
* Both still key on the caller alone, since a submission carries no email. The
* `keyByCallerAndEmail` comment warns that a bare `ip:` bucket is a shared
* allowance rather than a per-caller one, and that trade is accepted here: the
* link is the per-caller identity and its `max_submissions` is the per-caller
* cap, while these bound what one address can throw at an unauthenticated
* endpoint.
*/
export const intakeViewLimiter = rateLimit({
windowMs: 15 * 60 * 1000, windowMs: 15 * 60 * 1000,
limit: 20, // Generous, because it is a page load. Someone re-reading the form, losing
// their signal, or coming back to it should never be told to wait.
limit: 120,
keyGenerator: keyByCaller,
standardHeaders: 'draft-7',
legacyHeaders: false,
message: { error: 'too many requests — please try again shortly' }
});
export const intakeSubmitLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
// Each of these writes files, so this is the one worth bounding. Thirty in a
// quarter of an hour is more than anyone photographing items can manage and
// far less than a script would want.
limit: 30,
keyGenerator: keyByCaller, keyGenerator: keyByCaller,
standardHeaders: 'draft-7', standardHeaders: 'draft-7',
legacyHeaders: false, legacyHeaders: false,
+3 -3
View File
@@ -3,7 +3,7 @@ import { pool, requireRow } from '../db';
import { asyncRoute } from '../asyncRoute'; import { asyncRoute } from '../asyncRoute';
import { hashToken } from '../uploadLinks'; import { hashToken } from '../uploadLinks';
import { uploadImages, verifyUploadedImages, insertItemImages } from '../imageUpload'; import { uploadImages, verifyUploadedImages, insertItemImages } from '../imageUpload';
import { intakeLimiter } from '../rateLimit'; import { intakeViewLimiter, intakeSubmitLimiter } from '../rateLimit';
const router = Router(); const router = Router();
@@ -72,7 +72,7 @@ const requireUsableLink = asyncRoute(
} }
); );
router.get('/:token', intakeLimiter, asyncRoute(async (req: Request, res: Response) => { router.get('/:token', intakeViewLimiter, asyncRoute(async (req: Request, res: Response) => {
const link = await usableLink(req.params.token as string); const link = await usableLink(req.params.token as string);
if (!link) { if (!link) {
return res.status(404).json({ error: 'not found' }); return res.status(404).json({ error: 'not found' });
@@ -83,7 +83,7 @@ router.get('/:token', intakeLimiter, asyncRoute(async (req: Request, res: Respon
router.post( router.post(
'/:token', '/:token',
intakeLimiter, intakeSubmitLimiter,
requireUsableLink, requireUsableLink,
uploadImages, uploadImages,
asyncRoute(async (req: Request, res: Response) => { asyncRoute(async (req: Request, res: Response) => {
+26 -7
View File
@@ -11,6 +11,7 @@ import Space from 'antd/es/space';
import { UploadOutlined } from '@ant-design/icons'; import { UploadOutlined } from '@ant-design/icons';
import type { UploadFile } from 'antd/es/upload/interface'; import type { UploadFile } from 'antd/es/upload/interface';
import { fetchIntakeLink, submitItem } from './intakeApi'; import { fetchIntakeLink, submitItem } from './intakeApi';
import type { LinkState } from './intakeApi';
const { Title, Paragraph } = Typography; const { Title, Paragraph } = Typography;
const { TextArea } = Input; const { TextArea } = Input;
@@ -28,7 +29,7 @@ const MAX_IMAGES = 6;
export default function Submit() { export default function Submit() {
const { token = '' } = useParams(); const { token = '' } = useParams();
const [label, setLabel] = useState<string | null>(null); const [state, setState] = useState<LinkState>({ kind: 'unusable' });
const [checking, setChecking] = useState(true); const [checking, setChecking] = useState(true);
const [files, setFiles] = useState<UploadFile[]>([]); const [files, setFiles] = useState<UploadFile[]>([]);
const [note, setNote] = useState(''); const [note, setNote] = useState('');
@@ -38,11 +39,11 @@ export default function Submit() {
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
void fetchIntakeLink(token).then((link) => { void fetchIntakeLink(token).then((result) => {
// The token can change if the URL does, and a late response from the // The token can change if the URL does, and a late response from the
// previous one would otherwise overwrite the current answer. // previous one would otherwise overwrite the current answer.
if (cancelled) return; if (cancelled) return;
setLabel(link?.label ?? null); setState(result);
setChecking(false); setChecking(false);
}); });
return () => { return () => {
@@ -80,10 +81,28 @@ export default function Submit() {
); );
} }
// One state for every refusal, matching the server's single 404. Saying which // Kept apart from the state below on purpose. The two need opposite
// of revoked, unknown or used-up it was would tell a stranger whether a link // reactions — wait a moment, versus go and ask for a different link — so
// they guessed at exists. // telling a throttled sender their link was dead would send them to fetch a
if (label === null) { // replacement that could not have helped.
if (state.kind === 'throttled') {
return (
<div style={{ maxWidth: 640, margin: '48px auto', padding: '0 16px' }}>
<Card>
<Title level={3}>Too many requests just now</Title>
<Paragraph>
Your link is fine this page has just been asked for too many times from your
connection. Wait a minute and reload.
</Paragraph>
</Card>
</div>
);
}
// One state for unknown, revoked and used-up alike, matching the server's
// single 404. Saying which it was would tell a stranger whether a link they
// guessed at exists.
if (state.kind === 'unusable') {
return ( return (
<div style={{ maxWidth: 640, margin: '48px auto', padding: '0 16px' }}> <div style={{ maxWidth: 640, margin: '48px auto', padding: '0 16px' }}>
<Card> <Card>
+22 -8
View File
@@ -2,19 +2,33 @@ export interface IntakeLink {
label: string; label: string;
} }
export type LinkState =
| { kind: 'usable'; link: IntakeLink }
| { kind: 'unusable' }
| { kind: 'throttled' };
/** /**
* Whether a link works, and what it is called. * Whether a link works, and what it is called.
* *
* Every refusal from the server is a 404 by design — unknown, revoked and * The server answers 404 for unknown, revoked and exhausted links alike, and
* exhausted links are indistinguishable, because whether a link exists is not * that is deliberate — whether a link exists is not something a stranger needs
* something a stranger needs to learn. So there is one "this link does not * to learn. So all three collapse into one `unusable` state here rather than
* work" state here rather than several the page would have to explain, and * several the page would have to explain.
* null is the whole of it. *
* A 429 is deliberately *not* one of them. This used to treat any non-OK
* response as "no link", which meant a rate-limited sender was told their link
* was dead — sending them to ask for a replacement that would not have helped,
* because the problem was the address they were coming from and a minute of
* patience. Two conditions that need opposite reactions must not share a
* message.
*/ */
export async function fetchIntakeLink(token: string): Promise<IntakeLink | null> { export async function fetchIntakeLink(token: string): Promise<LinkState> {
const res = await fetch(`/api/intake/${encodeURIComponent(token)}`); const res = await fetch(`/api/intake/${encodeURIComponent(token)}`);
if (!res.ok) return null;
return res.json(); if (res.status === 429) return { kind: 'throttled' };
if (!res.ok) return { kind: 'unusable' };
return { kind: 'usable', link: await res.json() };
} }
export type SubmitResult = { ok: true } | { ok: false; error: string }; export type SubmitResult = { ok: true } | { ok: false; error: string };
@@ -0,0 +1,72 @@
import { test, expect, uniqueSuffix } from './fixtures';
/**
* Issuing and revoking upload links from the admin (#222).
*
* Every link is labelled with a fresh run id and every assertion is scoped to
* that row. The suite is fullyParallel against one shared database, so an
* assertion on the table as a whole would be an assertion on whatever other
* specs happen to be doing (#241).
*/
test.describe('Managing upload links', () => {
test('issues a link, shows its token once, and lists it bounded', async ({ page, admin }) => {
const label = `Sarah ${uniqueSuffix()}`;
await admin.goto();
await page.getByRole('tab', { name: 'Upload links' }).click();
await page.getByLabel('Link label').fill(label);
await page.getByRole('button', { name: 'Create link' }).click();
// Shown exactly once. The server keeps only a digest, so there is no
// reveal to come back to — the copy has to say so.
await expect(page.getByText('Copy this link now')).toBeVisible();
await expect(page.getByText(/\/submit\//)).toBeVisible();
await expect(page.getByText(/cannot be shown again/)).toBeVisible();
const row = page.getByRole('row', { name: new RegExp(label) });
await expect(row).toBeVisible();
// The default cap, not unlimited. An unbounded link should be asked for.
await expect(row.getByText('0 of 25')).toBeVisible();
await expect(row.getByText('Active')).toBeVisible();
});
test('revokes a link, naming the action on the confirm', async ({ page, admin }) => {
const label = `Temporary ${uniqueSuffix()}`;
await admin.goto();
await page.getByRole('tab', { name: 'Upload links' }).click();
await page.getByLabel('Link label').fill(label);
await page.getByRole('button', { name: 'Create link' }).click();
const row = page.getByRole('row', { name: new RegExp(label) });
await row.getByRole('button', { name: 'Revoke' }).click();
// The confirm names what it does rather than saying OK, which is what the
// rest of this admin's destructive actions do.
await page.getByRole('tooltip').getByRole('button', { name: 'Revoke' }).click();
await expect(row.getByText('Revoked')).toBeVisible();
// The row stays: what arrived through the link is kept, and the record of
// where it came from with it.
await expect(row).toBeVisible();
});
// Blank-means-unlimited would make the least deliberate action produce the
// least bounded link, so unlimited is a checkbox rather than an empty field.
test('makes unlimited a deliberate choice', async ({ page, admin }) => {
const label = `Always on ${uniqueSuffix()}`;
await admin.goto();
await page.getByRole('tab', { name: 'Upload links' }).click();
await page.getByLabel('Link label').fill(label);
await page.getByText('No limit').click();
await page.getByRole('button', { name: 'Create link' }).click();
const row = page.getByRole('row', { name: new RegExp(label) });
await expect(row).toBeVisible();
// A bare count rather than "0 of N".
await expect(row.getByText('0 of', { exact: false })).toHaveCount(0);
});
});
+76
View File
@@ -0,0 +1,76 @@
import { test, expect, createAdminContext, uniqueSuffix } from './fixtures';
/**
* The public submission page (#222).
*
* Every fixture here carries a run id and every assertion names only what this
* run created. The suite is fullyParallel against one shared database, so a
* spec that asserts on anything catalogue-wide is asserting on other specs too
* (#241).
*/
const RUN = `i${uniqueSuffix()}`;
let token = '';
test.beforeAll(async ({ playwright }) => {
const api = await createAdminContext(playwright);
const res = await api.post('/api/admin/upload-links', {
data: { label: `Intake spec ${RUN}` }
});
expect(res.status(), 'creating the upload link').toBe(201);
token = (await res.json()).token;
await api.dispose();
});
test.describe('Sending in an item through a link', () => {
test('shows the form for a link that works', async ({ page }) => {
await page.goto(`/submit/${token}`);
await expect(page.getByRole('heading', { name: 'Send in an item' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Choose photos' })).toBeVisible();
});
// Nothing to send is not a submission, and the server would refuse it — but
// the sender should not have to find that out by pressing the button.
test('will not send until a photo is chosen', async ({ page }) => {
await page.goto(`/submit/${token}`);
await expect(page.getByRole('button', { name: 'Send' })).toBeDisabled();
});
// One state for every refusal, matching the server's single 404. Saying which
// kind of dead a link is would tell a stranger whether one they guessed at
// exists.
test('explains an unusable link without saying which kind', async ({ page }) => {
await page.goto('/submit/not-a-real-token');
await expect(page.getByRole('heading', { name: 'This link is not active' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Choose photos' })).toHaveCount(0);
});
test('accepts a photo and says it arrived', async ({ page }) => {
await page.goto(`/submit/${token}`);
// A real 1x1 PNG, so the server's magic-byte check sees what it expects
// rather than a buffer that merely starts correctly.
const png = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64'
);
await page.setInputFiles('input[type="file"]', {
name: `${RUN}.png`,
mimeType: 'image/png',
buffer: png
});
await page.getByLabel('Anything you know about this item').fill(`Stoneware ${RUN}`);
await page.getByRole('button', { name: 'Send' }).click();
await expect(page.getByRole('heading', { name: 'Thank you — it arrived' })).toBeVisible();
// The wording matters: it is what stops a sender wondering why their item
// is not on the site.
await expect(page.getByText(/Nothing is listed for sale until/)).toBeVisible();
});
});