diff --git a/backend/__tests__/routes/adminCategoriesNameLength.test.js b/backend/__tests__/routes/adminCategoriesNameLength.test.js new file mode 100644 index 00000000..6d0a7915 --- /dev/null +++ b/backend/__tests__/routes/adminCategoriesNameLength.test.js @@ -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); + }); +}); diff --git a/backend/src/routes/adminCategories.js b/backend/src/routes/adminCategories.js index d5ae2404..e7bddada 100644 --- a/backend/src/routes/adminCategories.js +++ b/backend/src/routes/adminCategories.js @@ -40,7 +40,11 @@ router.get('/event/:eventId', adminAuth, requirePermission('settings.view'), req // Create a new category 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('is_global').optional().isBoolean(), body('event_id').optional().isInt(), @@ -127,7 +131,8 @@ router.post('/', adminAuth, requirePermission('settings.edit'), [ // Update a category 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) => { if (value === null || value === undefined) return true; return Number.isInteger(Number(value)); diff --git a/frontend/src/components/admin/CategoryManager.tsx b/frontend/src/components/admin/CategoryManager.tsx index 786d28ad..712b0829 100644 --- a/frontend/src/components/admin/CategoryManager.tsx +++ b/frontend/src/components/admin/CategoryManager.tsx @@ -138,6 +138,7 @@ export const CategoryManager: React.FC = () => { onChange={(e) => setNewCategoryName(e.target.value)} onKeyPress={(e) => e.key === 'Enter' && handleCreate()} 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" autoFocus /> @@ -188,6 +189,7 @@ export const CategoryManager: React.FC = () => { if (e.key === 'Enter') handleUpdate(category.id); 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" autoFocus /> diff --git a/frontend/src/components/admin/EventCategoryManager.tsx b/frontend/src/components/admin/EventCategoryManager.tsx index cfcea573..a49127f1 100644 --- a/frontend/src/components/admin/EventCategoryManager.tsx +++ b/frontend/src/components/admin/EventCategoryManager.tsx @@ -206,6 +206,7 @@ export const EventCategoryManager: React.FC = ({ even onChange={(e) => setNewCategoryName(e.target.value)} onKeyPress={(e) => e.key === 'Enter' && handleCreate()} 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" autoFocus />