fix(admin): answer 404 for an item id that does not exist (#207)
Three routes in admin.ts answered a miss with a success. PUT /items/:id ran an UPDATE that matched nothing, committed happily, selected nothing back and replied 200 with an empty body — a success the admin client could do nothing with, and no record anywhere that the item was not found. mark-sold and mark-available did the same. The create route beside them has always used requireRow for exactly this, which is why this reads as an oversight rather than a decision.
A garbage id was worse in a different direction. Number('abc') is NaN, the driver sends it to Postgres as the text "NaN", Postgres raises 22P02 for an integer column, and the catch turned that into a 500 — so a caller asking for an item that cannot exist was told the server broke. Both now answer 404, because from the caller's side "/items/abc" identifies no item in exactly the way "/items/999999" does.
readId is shared rather than repeated, and rejects zero, negatives and fractions as well as text: every id in this schema is a positive serial, so anything else identifies nothing.
mark-sold now notifies favouriters only after the row is known to exist, so nobody is told about a sale that did not happen.
The issue asked for the same shape to be checked across the other admin routes. It was: unpublish already looks the item up and 404s, and the tags and categories PUT routes both do an existence check before their UPDATE, so their rows[0] is guaranteed. items.ts already guards the public read. These three were the only ones lying about a miss.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
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();
|
||||
});
|
||||
|
||||
const PNG = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
|
||||
'base64'
|
||||
);
|
||||
|
||||
/**
|
||||
* Nothing exists, so every id below is absent. 999999 is well-formed and
|
||||
* missing; 'abc' is not a number at all. Before #207 the first answered 200
|
||||
* with an empty body and the second answered 500, because the raw string
|
||||
* reached Postgres and raised 22P02.
|
||||
*/
|
||||
const ABSENT = 999999;
|
||||
|
||||
describe('admin item routes for an id that does not exist', () => {
|
||||
it('PUT answers 404 rather than 200 with an empty body', async () => {
|
||||
const res = await request(app)
|
||||
.put(`/api/admin/items/${ABSENT}`)
|
||||
.field('name', 'renamed')
|
||||
.field('price', '10.00');
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body).toEqual({ error: 'not found' });
|
||||
});
|
||||
|
||||
it('mark-sold answers 404', async () => {
|
||||
const res = await request(app).post(`/api/admin/items/${ABSENT}/mark-sold`);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('mark-available answers 404', async () => {
|
||||
const res = await request(app).post(`/api/admin/items/${ABSENT}/mark-available`);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
// A garbage id used to reach Postgres and raise 22P02, which the catch turned
|
||||
// into a 500. From the caller's side "/items/abc" identifies no item, exactly
|
||||
// like "/items/999999" does.
|
||||
it.each(['abc', '1.5', '-1', ''])('PUT answers 404 for the id %p', async (id) => {
|
||||
const res = await request(app)
|
||||
.put(`/api/admin/items/${id}`)
|
||||
.field('name', 'renamed')
|
||||
.field('price', '10.00');
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('mark-available answers 404 for a non-numeric id', async () => {
|
||||
const res = await request(app).post('/api/admin/items/abc/mark-available');
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('admin item routes for an id that does exist', () => {
|
||||
async function makeItem(): Promise<number> {
|
||||
const res = await request(app)
|
||||
.post('/api/admin/items')
|
||||
.field('name', 'a real item')
|
||||
.field('price', '12.00')
|
||||
.attach('images', PNG, 'a.png');
|
||||
return res.body.id;
|
||||
}
|
||||
|
||||
// The point of the change is to stop lying about misses, not to start
|
||||
// refusing hits.
|
||||
it('PUT still updates and answers with the item', async () => {
|
||||
const id = await makeItem();
|
||||
|
||||
const res = await request(app)
|
||||
.put(`/api/admin/items/${id}`)
|
||||
.field('name', 'renamed')
|
||||
.field('price', '15.00');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.name).toBe('renamed');
|
||||
expect(res.body.price_cents).toBe(1500);
|
||||
});
|
||||
|
||||
it('mark-available still publishes', async () => {
|
||||
const id = await makeItem();
|
||||
|
||||
const res = await request(app).post(`/api/admin/items/${id}/mark-available`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.status).toBe('available');
|
||||
});
|
||||
});
|
||||
|
||||
describe('a non-numeric id on every admin item route', () => {
|
||||
// Each of these used to reach Postgres, raise 22P02 and surface as a 500.
|
||||
it.each([
|
||||
['mark-sold', '/api/admin/items/abc/mark-sold'],
|
||||
['mark-available', '/api/admin/items/abc/mark-available']
|
||||
])('%s answers 404', async (_name, path) => {
|
||||
expect((await request(app).post(path)).status).toBe(404);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user