Merge pull request 'fix(admin): answer 404 for an unreadable id instead of 500 (#207)' (#289) from fix/207-guard-every-admin-id into main
Linting / lint (push) Successful in 7m50s
SonarQube Analysis / sonarqube (push) Failing after 44m6s

Reviewed-on: #289
This commit was merged in pull request #289.
This commit is contained in:
2026-09-03 16:47:32 -05:00
3 changed files with 123 additions and 22 deletions
+27 -8
View File
@@ -205,15 +205,15 @@ router.put('/items/:id', uploadImages, asyncRoute(async (req: Request, res: Resp
// Only touch the category when the field was actually submitted, so a
// caller that omits it doesn't silently uncategorize the item.
if (categoryId !== undefined) {
await client.query(`UPDATE items SET category_id=$1 WHERE id=$2`, [categoryId, req.params.id]);
await client.query(`UPDATE items SET category_id=$1 WHERE id=$2`, [categoryId, itemId]);
}
if (tagNames) {
await setItemTags(client, Number(req.params.id), await resolveTagIds(client, tagNames));
await setItemTags(client, itemId, await resolveTagIds(client, tagNames));
}
if (files.length) {
const { rows: existing } = await client.query<MaxSortRow>(
`SELECT COALESCE(MAX(sort_order), -1) AS max_sort FROM item_images WHERE item_id = $1`,
[req.params.id]
[itemId]
);
// COALESCE'd MAX, so the aggregate always returns exactly one row.
const nextSort = requireRow(existing, 'the MAX(sort_order) aggregate').max_sort + 1;
@@ -221,7 +221,7 @@ router.put('/items/:id', uploadImages, asyncRoute(async (req: Request, res: Resp
// always has this param, but noUncheckedIndexedAccess cannot know that,
// and the helper's typed parameter surfaces what the old inline query's
// unknown[] hid.
await insertItemImages(client, Number(req.params.id), files, nextSort);
await insertItemImages(client, itemId, files, nextSort);
}
await client.query('COMMIT');
// S2077, the same constant-plus-$1 shape as the create route above.
@@ -246,7 +246,14 @@ router.put('/items/:id', uploadImages, asyncRoute(async (req: Request, res: Resp
}));
router.delete('/items/:id', asyncRoute(async (req: Request, res: Response) => {
const itemId = Number(req.params.id);
// 404 rather than the 500 a raw Number() produced: 'abc' became NaN, reached
// Postgres as the text "NaN", raised 22P02 on an integer column and told the
// caller the server had broken. An item that cannot exist is not found (#207).
//
// A well-formed but absent id still answers 204. DELETE is idempotent and the
// caller's intent — that the item should not exist — is satisfied either way.
const itemId = readId(req.params.id);
if (itemId === null) return res.status(404).json({ error: 'not found' });
// Collected before the delete: favorites cascade with the item, so after it
// is gone there is no record of who was watching. Restricted to unsold items
@@ -262,7 +269,13 @@ router.delete('/items/:id', asyncRoute(async (req: Request, res: Response) => {
}));
router.delete('/items/:id/images/:imageId', asyncRoute(async (req: Request, res: Response) => {
await pool.query(`DELETE FROM item_images WHERE id = $1 AND item_id = $2`, [req.params.imageId, req.params.id]);
// Both ids, not just the first. A route carrying two of them can guard one
// and forget the other, and the forgotten one fails exactly as loudly (#207).
const itemId = readId(req.params.id);
const imageId = readId(req.params.imageId);
if (itemId === null || imageId === null) return res.status(404).json({ error: 'not found' });
await pool.query(`DELETE FROM item_images WHERE id = $1 AND item_id = $2`, [imageId, itemId]);
res.status(204).end();
}));
@@ -290,7 +303,13 @@ router.post('/items/:id/mark-sold', asyncRoute(async (req: Request, res: Respons
// duplication, so the admin UI labels that button "Publish" when the item is
// pending. This is the reverse, and it is not symmetrical — see the guard.
router.post('/items/:id/unpublish', asyncRoute(async (req: Request, res: Response) => {
const { rows } = await pool.query<ItemStatusRow>(`SELECT status FROM items WHERE id = $1`, [req.params.id]);
// Guarded before the lookup, so a malformed id is 404 rather than the 500 the
// raw string produced at Postgres. The absent case below was already right;
// only the unreadable one was not (#207).
const itemId = readId(req.params.id);
if (itemId === null) return res.status(404).json({ error: 'not found' });
const { rows } = await pool.query<ItemStatusRow>(`SELECT status FROM items WHERE id = $1`, [itemId]);
if (!rows.length) {
return res.status(404).json({ error: 'not found' });
}
@@ -313,7 +332,7 @@ router.post('/items/:id/unpublish', asyncRoute(async (req: Request, res: Respons
const { rows: updated } = await pool.query<ItemRecord>(
`UPDATE items SET status='pending' WHERE id=$1 RETURNING *`,
[req.params.id]
[itemId]
);
res.json(updated[0]);
}));
+42 -13
View File
@@ -3,6 +3,7 @@ import { pool } from '../db';
import { asyncRoute } from '../asyncRoute';
import { draftQueued } from '../intake/draftingWorker';
import { nextPriceSource, PriceSource } from '../intake/priceSource';
import { readId } from '../utils';
import {
NoOriginalToRestoreError,
removeImageBackground,
@@ -97,6 +98,13 @@ router.post(
return res.status(400).json({ error: 'a price in whole cents is required' });
}
// Guarded before a connection is taken. A malformed id reached Postgres as
// text, raised 22P02 on an integer column and surfaced as a 500 — telling
// the admin the server had broken when the truth is that no such draft can
// exist (#207).
const itemId = readId(req.params.itemId);
if (itemId === null) return res.status(404).json({ error: 'no draft for this item' });
const client = await pool.connect();
try {
await client.query('BEGIN');
@@ -109,7 +117,7 @@ router.post(
FROM item_drafts d JOIN items i ON i.id = d.item_id
WHERE d.item_id = $1
FOR UPDATE OF d, i`,
[req.params.itemId]
[itemId]
);
const existing = rows[0];
if (!existing) {
@@ -124,11 +132,11 @@ router.post(
SET name = $2, description = $3, price_cents = $4,
status = 'available', sold_at = NULL, reserved_until = NULL, paypal_order_id = NULL
WHERE id = $1`,
[req.params.itemId, name, description === '' ? null : description, priceCents]
[itemId, name, description === '' ? null : description, priceCents]
);
await client.query(`UPDATE item_drafts SET price_source = $2 WHERE item_id = $1`, [
req.params.itemId,
itemId,
priceSource
]);
@@ -155,9 +163,12 @@ router.post(
router.post(
'/:itemId/regenerate',
asyncRoute(async (req: Request, res: Response) => {
const itemId = readId(req.params.itemId);
if (itemId === null) return res.status(404).json({ error: 'no draft for this item' });
const { rowCount } = await pool.query(
`UPDATE item_drafts SET state = 'queued', attempts = 0, ai_error = NULL WHERE item_id = $1`,
[req.params.itemId]
[itemId]
);
if (rowCount === 0) return res.status(404).json({ error: 'no draft for this item' });
@@ -185,18 +196,21 @@ router.post(
router.post(
'/:itemId/discard',
asyncRoute(async (req: Request, res: Response) => {
const itemId = readId(req.params.itemId);
if (itemId === null) return res.status(404).json({ error: 'no draft for this item' });
const client = await pool.connect();
try {
await client.query('BEGIN');
const { rowCount } = await client.query(
`UPDATE item_drafts SET state = 'discarded' WHERE item_id = $1`,
[req.params.itemId]
[itemId]
);
if (rowCount === 0) {
await client.query('ROLLBACK');
return res.status(404).json({ error: 'no draft for this item' });
}
await client.query(`UPDATE items SET status = 'pending' WHERE id = $1`, [req.params.itemId]);
await client.query(`UPDATE items SET status = 'pending' WHERE id = $1`, [itemId]);
await client.query('COMMIT');
res.json({ state: 'discarded' });
} catch (err) {
@@ -220,11 +234,14 @@ router.post(
router.post(
'/:itemId/restore',
asyncRoute(async (req: Request, res: Response) => {
const itemId = readId(req.params.itemId);
if (itemId === null) return res.status(404).json({ error: 'no draft for this item' });
const { rowCount } = await pool.query(
`UPDATE item_drafts
SET state = CASE WHEN ai_name IS NULL THEN 'failed' ELSE 'ready' END
WHERE item_id = $1`,
[req.params.itemId]
[itemId]
);
if (rowCount === 0) return res.status(404).json({ error: 'no draft for this item' });
res.json({ restored: true });
@@ -239,8 +256,8 @@ router.post(
* so guessing one is not hard.
*/
async function imageOfItem(
itemId: string,
imageId: string
itemId: number,
imageId: number
): Promise<{ image_path: string; original_image_path: string | null } | null> {
const { rows } = await pool.query<{ image_path: string; original_image_path: string | null }>(
`SELECT image_path, original_image_path
@@ -266,13 +283,20 @@ async function imageOfItem(
router.post(
'/:itemId/images/:imageId/remove-background',
asyncRoute(async (req: Request, res: Response) => {
const { itemId = '', imageId = '' } = req.params;
// Both ids, before either reaches Postgres. A route carrying two of them
// can guard one and forget the other, and the forgotten one is a 500 rather
// than the 404 that "no such photo" actually means (#207).
const itemId = readId(req.params.itemId);
const imageId = readId(req.params.imageId);
if (itemId === null || imageId === null) {
return res.status(404).json({ error: 'no such photo on this item' });
}
if ((await imageOfItem(itemId, imageId)) === null) {
return res.status(404).json({ error: 'no such photo on this item' });
}
try {
await removeImageBackground(Number(imageId));
await removeImageBackground(imageId);
} catch (err) {
console.error(`[drafts] background removal for image ${imageId}:`, err);
@@ -321,14 +345,19 @@ router.post(
router.post(
'/:itemId/images/:imageId/restore-original',
asyncRoute(async (req: Request, res: Response) => {
const { itemId = '', imageId = '' } = req.params;
const itemId = readId(req.params.itemId);
const imageId = readId(req.params.imageId);
if (itemId === null || imageId === null) {
return res.status(404).json({ error: 'this photo has no original to restore' });
}
const existing = await imageOfItem(itemId, imageId);
if (existing === null || existing.original_image_path === null) {
return res.status(404).json({ error: 'this photo has no original to restore' });
}
try {
await restoreImageOriginal(Number(imageId));
await restoreImageOriginal(imageId);
} catch (err) {
// Narrow on purpose: only NoOriginalToRestoreError means "another
// request already did this, the work is done". This precheck and
@@ -101,10 +101,63 @@ describe('admin item routes for an id that does exist', () => {
describe('a non-numeric id on every admin item route', () => {
// Each of these used to reach Postgres, raise 22P02 and surface as a 500.
// The block was named "every" while covering two; it now covers every POST
// route that takes an id, which is what makes the name true (#207).
it.each([
['mark-sold', '/api/admin/items/abc/mark-sold'],
['mark-available', '/api/admin/items/abc/mark-available']
['mark-available', '/api/admin/items/abc/mark-available'],
['unpublish', '/api/admin/items/abc/unpublish'],
['regenerate', '/api/admin/item-drafts/abc/regenerate'],
['discard', '/api/admin/item-drafts/abc/discard'],
['restore', '/api/admin/item-drafts/abc/restore'],
['remove-background', '/api/admin/item-drafts/abc/images/1/remove-background'],
['restore-original', '/api/admin/item-drafts/abc/images/1/restore-original'],
// The image id, not the item id — a route with two of them can guard one
// and forget the other, and only a case each way would notice.
['remove-background by image', '/api/admin/item-drafts/1/images/abc/remove-background'],
['restore-original by image', '/api/admin/item-drafts/1/images/abc/restore-original']
])('%s answers 404', async (_name, path) => {
expect((await request(app).post(path)).status).toBe(404);
});
it('PUT answers 404', async () => {
const res = await request(app)
.put('/api/admin/items/abc')
.field('name', 'renamed')
.field('price', '10.00');
expect(res.status).toBe(404);
});
// Publish validates its body before it looks at the id, so this sends a
// valid one — otherwise the 400 would mask whether the id was ever handled.
it('publish answers 404', async () => {
const res = await request(app)
.post('/api/admin/item-drafts/abc/publish')
.send({ name: 'a name', description: '', priceCents: 1000 });
expect(res.status).toBe(404);
});
it.each([
['DELETE an item', '/api/admin/items/abc'],
['DELETE an image by item id', '/api/admin/items/abc/images/1'],
['DELETE an image by image id', '/api/admin/items/1/images/abc']
])('%s answers 404', async (_name, path) => {
expect((await request(app).delete(path)).status).toBe(404);
});
});
/**
* A well-formed id that is simply absent.
*
* Deliberately not the same question as a malformed one. DELETE stays 204 for
* an item that is not there: the method is idempotent and the caller's intent —
* that the item should not exist — is satisfied either way. What must not
* happen is a 500, which is what a malformed id used to produce.
*/
describe('a well-formed but absent id on the delete routes', () => {
it('DELETE answers 204 rather than failing', async () => {
expect((await request(app).delete(`/api/admin/items/${ABSENT}`)).status).toBe(204);
});
});