fix(categories): validate category name length instead of 500ing
photo_categories.name is varchar(100). Neither the input nor the route
checked length, so a 267-char name hit a raw Postgres "value too long",
came back as a 500, and the form silently stayed open with no toast.
Add isLength({ max: 100 }) to POST / and PUT /:id (the update route had the
identical gap) so it returns the route family's normal 400 { errors: [...] }
shape that the toast helper already renders, and maxLength={100} on the three
category-name inputs (create + inline edit in CategoryManager, create in
EventCategoryManager).
Refs testplan REPORT.md #4 (Part 7.01).
This commit is contained in:
@@ -0,0 +1,76 @@
|
|||||||
|
/**
|
||||||
|
* photo_categories.name is varchar(100). Without a length check the insert
|
||||||
|
* hit Postgres' "value too long" and the route's catch turned it into a raw
|
||||||
|
* 500 with no message the form could surface — a >100-char name must come
|
||||||
|
* back as a normal 400 validation error instead.
|
||||||
|
*/
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
|
|
||||||
|
process.env.NODE_ENV = 'test';
|
||||||
|
process.env.TEST_DATABASE_PATH = path.join(
|
||||||
|
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-catlen-')), 'db.sqlite',
|
||||||
|
);
|
||||||
|
process.env.JWT_SECRET = process.env.JWT_SECRET || 'catlen-test-secret';
|
||||||
|
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-catlen-storage-'));
|
||||||
|
|
||||||
|
const request = require('supertest');
|
||||||
|
const express = require('express');
|
||||||
|
const cookieParser = require('cookie-parser');
|
||||||
|
const { bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken } = require('../integration/helpers/crmDb');
|
||||||
|
|
||||||
|
const TOO_LONG = 'z'.repeat(101);
|
||||||
|
|
||||||
|
describe('category name length validation', () => {
|
||||||
|
let db; let cleanup; let app; let superTok;
|
||||||
|
|
||||||
|
const auth = (req) => req.set('Authorization', `Bearer ${superTok}`);
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
({ db, cleanup } = await bootCrmDb());
|
||||||
|
const { adminId: superId } = await seedMinimal(db);
|
||||||
|
await assignAdminRole(db, superId, 'super_admin');
|
||||||
|
superTok = mintAdminToken(superId);
|
||||||
|
|
||||||
|
app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(cookieParser());
|
||||||
|
app.use('/api/admin/categories', require('../../src/routes/adminCategories'));
|
||||||
|
}, 120000);
|
||||||
|
|
||||||
|
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||||
|
|
||||||
|
it('rejects a >100-char name on create with a 400, not a 500', async () => {
|
||||||
|
const res = await auth(request(app).post('/api/admin/categories'))
|
||||||
|
.send({ name: TOO_LONG, is_global: true });
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.errors.some((e) => e.path === 'name')).toBe(true);
|
||||||
|
const rows = await db('photo_categories').where('name', TOO_LONG);
|
||||||
|
expect(rows).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a >100-char name on update with a 400, not a 500', async () => {
|
||||||
|
const created = await auth(request(app).post('/api/admin/categories'))
|
||||||
|
.send({ name: 'zzcatlen-ok', is_global: true });
|
||||||
|
expect(created.status).toBe(200);
|
||||||
|
|
||||||
|
const res = await auth(request(app).put(`/api/admin/categories/${created.body.id}`))
|
||||||
|
.send({ name: TOO_LONG });
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.errors.some((e) => e.path === 'name')).toBe(true);
|
||||||
|
const row = await db('photo_categories').where('id', created.body.id).first();
|
||||||
|
expect(row.name).toBe('zzcatlen-ok');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still accepts a name at exactly the 100-char limit', async () => {
|
||||||
|
const name = 'y'.repeat(100);
|
||||||
|
const res = await auth(request(app).post('/api/admin/categories'))
|
||||||
|
.send({ name, is_global: true });
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.name).toBe(name);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -40,7 +40,11 @@ router.get('/event/:eventId', adminAuth, requirePermission('settings.view'), req
|
|||||||
|
|
||||||
// Create a new category
|
// Create a new category
|
||||||
router.post('/', adminAuth, requirePermission('settings.edit'), [
|
router.post('/', adminAuth, requirePermission('settings.edit'), [
|
||||||
body('name').notEmpty().withMessage('Category name is required'),
|
// photo_categories.name is varchar(100) — without the length check Postgres
|
||||||
|
// raises "value too long" and the catch below turns it into a raw 500 with
|
||||||
|
// no usable message for the form.
|
||||||
|
body('name').notEmpty().withMessage('Category name is required')
|
||||||
|
.isLength({ max: 100 }).withMessage('Category name must be at most 100 characters'),
|
||||||
body('slug').optional(),
|
body('slug').optional(),
|
||||||
body('is_global').optional().isBoolean(),
|
body('is_global').optional().isBoolean(),
|
||||||
body('event_id').optional().isInt(),
|
body('event_id').optional().isInt(),
|
||||||
@@ -127,7 +131,8 @@ router.post('/', adminAuth, requirePermission('settings.edit'), [
|
|||||||
|
|
||||||
// Update a category
|
// Update a category
|
||||||
router.put('/:id', adminAuth, requirePermission('settings.edit'), [
|
router.put('/:id', adminAuth, requirePermission('settings.edit'), [
|
||||||
body('name').notEmpty().withMessage('Category name is required'),
|
body('name').notEmpty().withMessage('Category name is required')
|
||||||
|
.isLength({ max: 100 }).withMessage('Category name must be at most 100 characters'),
|
||||||
body('hero_photo_id').optional({ nullable: true }).custom((value) => {
|
body('hero_photo_id').optional({ nullable: true }).custom((value) => {
|
||||||
if (value === null || value === undefined) return true;
|
if (value === null || value === undefined) return true;
|
||||||
return Number.isInteger(Number(value));
|
return Number.isInteger(Number(value));
|
||||||
|
|||||||
@@ -138,6 +138,7 @@ export const CategoryManager: React.FC = () => {
|
|||||||
onChange={(e) => setNewCategoryName(e.target.value)}
|
onChange={(e) => setNewCategoryName(e.target.value)}
|
||||||
onKeyPress={(e) => e.key === 'Enter' && handleCreate()}
|
onKeyPress={(e) => e.key === 'Enter' && handleCreate()}
|
||||||
placeholder={t('categories.categoryName')}
|
placeholder={t('categories.categoryName')}
|
||||||
|
maxLength={100}
|
||||||
className="flex-1 px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-md bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-primary-500"
|
className="flex-1 px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-md bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-primary-500"
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
@@ -188,6 +189,7 @@ export const CategoryManager: React.FC = () => {
|
|||||||
if (e.key === 'Enter') handleUpdate(category.id);
|
if (e.key === 'Enter') handleUpdate(category.id);
|
||||||
if (e.key === 'Escape') cancelEdit();
|
if (e.key === 'Escape') cancelEdit();
|
||||||
}}
|
}}
|
||||||
|
maxLength={100}
|
||||||
className="flex-1 px-3 py-1 border border-neutral-300 dark:border-neutral-600 rounded-md bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-primary-500"
|
className="flex-1 px-3 py-1 border border-neutral-300 dark:border-neutral-600 rounded-md bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-primary-500"
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -206,6 +206,7 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
|||||||
onChange={(e) => setNewCategoryName(e.target.value)}
|
onChange={(e) => setNewCategoryName(e.target.value)}
|
||||||
onKeyPress={(e) => e.key === 'Enter' && handleCreate()}
|
onKeyPress={(e) => e.key === 'Enter' && handleCreate()}
|
||||||
placeholder={t('categories.categoryName')}
|
placeholder={t('categories.categoryName')}
|
||||||
|
maxLength={100}
|
||||||
className="flex-1 px-3 py-1.5 text-sm border border-neutral-300 dark:border-neutral-600 rounded-md bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-primary-500"
|
className="flex-1 px-3 py-1.5 text-sm border border-neutral-300 dark:border-neutral-600 rounded-md bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-primary-500"
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user