fix: MEDIUM issues #31-#36 — inline text editor, image/paste persist, input validation on all routes, useEffect deps, online count fix

This commit is contained in:
A0 Orchestrator
2026-06-30 14:03:22 +02:00
parent 172f933456
commit ee664750e6
15 changed files with 566 additions and 16 deletions
+15 -1
View File
@@ -5,12 +5,15 @@ import type { FastifyInstance } from 'fastify';
import type { DatabaseInterface, DBBlock } from '../database/DatabaseInterface.js';
import { requireAuth } from '../auth/authMiddleware.js';
import type { AuthService } from '../auth/AuthService.js';
import { validateName, validateIdParam } from '../utils/validation.js';
export function registerBlockRoutes(fastify: FastifyInstance, db: DatabaseInterface, authService: AuthService) {
// List all blocks for a drawing
fastify.get('/api/drawings/:drawingId/blocks', async (request, reply) => {
if (!requireAuth(request, reply, authService)) return;
const { drawingId } = request.params as { drawingId: string };
const idErr = validateIdParam(drawingId, 'drawingId');
if (idErr) return reply.code(400).send({ error: idErr });
return db.listBlocks(drawingId);
});
@@ -18,8 +21,11 @@ export function registerBlockRoutes(fastify: FastifyInstance, db: DatabaseInterf
fastify.post('/api/drawings/:drawingId/blocks', async (request, reply) => {
if (!requireAuth(request, reply, authService)) return;
const { drawingId } = request.params as { drawingId: string };
const idErr = validateIdParam(drawingId, 'drawingId');
if (idErr) return reply.code(400).send({ error: idErr });
const body = request.body as Partial<DBBlock>;
if (!body.name) return reply.code(400).send({ error: 'Name is required' });
const nameErr = validateName(body.name, 'name');
if (nameErr) return reply.code(400).send({ error: nameErr });
return reply.code(201).send(db.createBlock({ ...body, drawing_id: drawingId }));
});
@@ -27,7 +33,13 @@ export function registerBlockRoutes(fastify: FastifyInstance, db: DatabaseInterf
fastify.patch('/api/blocks/:id', async (request, reply) => {
if (!requireAuth(request, reply, authService)) return;
const { id } = request.params as { id: string };
const idErr = validateIdParam(id, 'id');
if (idErr) return reply.code(400).send({ error: idErr });
const body = request.body as Partial<DBBlock>;
if (body.name !== undefined) {
const nameErr = validateName(body.name, 'name');
if (nameErr) return reply.code(400).send({ error: nameErr });
}
const updated = db.updateBlock(id, body);
if (!updated) return reply.code(404).send({ error: 'Block not found' });
return updated;
@@ -37,6 +49,8 @@ export function registerBlockRoutes(fastify: FastifyInstance, db: DatabaseInterf
fastify.delete('/api/blocks/:id', async (request, reply) => {
if (!requireAuth(request, reply, authService)) return;
const { id } = request.params as { id: string };
const idErr = validateIdParam(id, 'id');
if (idErr) return reply.code(400).send({ error: idErr });
const ok = db.deleteBlock(id);
if (!ok) return reply.code(404).send({ error: 'Block not found' });
return reply.code(204).send();
+19
View File
@@ -5,12 +5,15 @@ import type { FastifyInstance } from 'fastify';
import type { DatabaseInterface, DBDrawing } from '../database/DatabaseInterface.js';
import { requireAuth } from '../auth/authMiddleware.js';
import type { AuthService } from '../auth/AuthService.js';
import { validateName, validateIdParam } from '../utils/validation.js';
export function registerDrawingRoutes(fastify: FastifyInstance, db: DatabaseInterface, authService: AuthService) {
// List drawings for a project
fastify.get('/api/projects/:projectId/drawings', async (request, reply) => {
if (!requireAuth(request, reply, authService)) return;
const { projectId } = request.params as { projectId: string };
const idErr = validateIdParam(projectId, 'projectId');
if (idErr) return reply.code(400).send({ error: idErr });
return db.listDrawings(projectId);
});
@@ -18,6 +21,8 @@ export function registerDrawingRoutes(fastify: FastifyInstance, db: DatabaseInte
fastify.get('/api/drawings/:id', async (request, reply) => {
if (!requireAuth(request, reply, authService)) return;
const { id } = request.params as { id: string };
const idErr = validateIdParam(id, 'id');
if (idErr) return reply.code(400).send({ error: idErr });
const drawing = db.getDrawing(id);
if (!drawing) return reply.code(404).send({ error: 'Drawing not found' });
return drawing;
@@ -27,7 +32,13 @@ export function registerDrawingRoutes(fastify: FastifyInstance, db: DatabaseInte
fastify.post('/api/projects/:projectId/drawings', async (request, reply) => {
if (!requireAuth(request, reply, authService)) return;
const { projectId } = request.params as { projectId: string };
const idErr = validateIdParam(projectId, 'projectId');
if (idErr) return reply.code(400).send({ error: idErr });
const body = request.body as Partial<DBDrawing>;
if (body.name !== undefined) {
const nameErr = validateName(body.name);
if (nameErr) return reply.code(400).send({ error: nameErr });
}
return reply.code(201).send(db.createDrawing({ ...body, project_id: projectId }));
});
@@ -35,7 +46,13 @@ export function registerDrawingRoutes(fastify: FastifyInstance, db: DatabaseInte
fastify.patch('/api/drawings/:id', async (request, reply) => {
if (!requireAuth(request, reply, authService)) return;
const { id } = request.params as { id: string };
const idErr = validateIdParam(id, 'id');
if (idErr) return reply.code(400).send({ error: idErr });
const body = request.body as Partial<DBDrawing>;
if (body.name !== undefined) {
const nameErr = validateName(body.name);
if (nameErr) return reply.code(400).send({ error: nameErr });
}
const updated = db.updateDrawing(id, body);
if (!updated) return reply.code(404).send({ error: 'Drawing not found' });
return updated;
@@ -45,6 +62,8 @@ export function registerDrawingRoutes(fastify: FastifyInstance, db: DatabaseInte
fastify.delete('/api/drawings/:id', async (request, reply) => {
if (!requireAuth(request, reply, authService)) return;
const { id } = request.params as { id: string };
const idErr = validateIdParam(id, 'id');
if (idErr) return reply.code(400).send({ error: idErr });
const ok = db.deleteDrawing(id);
if (!ok) return reply.code(404).send({ error: 'Drawing not found' });
return reply.code(204).send();
+9
View File
@@ -5,12 +5,15 @@ import type { FastifyInstance } from 'fastify';
import type { DatabaseInterface, DBElement } from '../database/DatabaseInterface.js';
import { requireAuth } from '../auth/authMiddleware.js';
import type { AuthService } from '../auth/AuthService.js';
import { validateIdParam } from '../utils/validation.js';
export function registerElementRoutes(fastify: FastifyInstance, db: DatabaseInterface, authService: AuthService) {
// List elements for a drawing
fastify.get('/api/drawings/:drawingId/elements', async (request, reply) => {
if (!requireAuth(request, reply, authService)) return;
const { drawingId } = request.params as { drawingId: string };
const idErr = validateIdParam(drawingId, 'drawingId');
if (idErr) return reply.code(400).send({ error: idErr });
return db.listElements(drawingId);
});
@@ -18,6 +21,8 @@ export function registerElementRoutes(fastify: FastifyInstance, db: DatabaseInte
fastify.post('/api/drawings/:drawingId/elements', async (request, reply) => {
if (!requireAuth(request, reply, authService)) return;
const { drawingId } = request.params as { drawingId: string };
const idErr = validateIdParam(drawingId, 'drawingId');
if (idErr) return reply.code(400).send({ error: idErr });
const body = request.body as Partial<DBElement>;
return reply.code(201).send(db.createElement({ ...body, drawing_id: drawingId }));
});
@@ -26,6 +31,8 @@ export function registerElementRoutes(fastify: FastifyInstance, db: DatabaseInte
fastify.patch('/api/elements/:id', async (request, reply) => {
if (!requireAuth(request, reply, authService)) return;
const { id } = request.params as { id: string };
const idErr = validateIdParam(id, 'id');
if (idErr) return reply.code(400).send({ error: idErr });
const body = request.body as Partial<DBElement>;
const updated = db.updateElement(id, body);
if (!updated) return reply.code(404).send({ error: 'Element not found' });
@@ -36,6 +43,8 @@ export function registerElementRoutes(fastify: FastifyInstance, db: DatabaseInte
fastify.delete('/api/elements/:id', async (request, reply) => {
if (!requireAuth(request, reply, authService)) return;
const { id } = request.params as { id: string };
const idErr = validateIdParam(id, 'id');
if (idErr) return reply.code(400).send({ error: idErr });
const ok = db.deleteElement(id);
if (!ok) return reply.code(404).send({ error: 'Element not found' });
return reply.code(204).send();
+17
View File
@@ -5,12 +5,15 @@ import type { FastifyInstance } from 'fastify';
import type { DatabaseInterface, DBLayer } from '../database/DatabaseInterface.js';
import { requireAuth } from '../auth/authMiddleware.js';
import type { AuthService } from '../auth/AuthService.js';
import { validateName, validateIdParam } from '../utils/validation.js';
export function registerLayerRoutes(fastify: FastifyInstance, db: DatabaseInterface, authService: AuthService) {
// List layers for a drawing
fastify.get('/api/drawings/:drawingId/layers', async (request, reply) => {
if (!requireAuth(request, reply, authService)) return;
const { drawingId } = request.params as { drawingId: string };
const idErr = validateIdParam(drawingId, 'drawingId');
if (idErr) return reply.code(400).send({ error: idErr });
return db.listLayers(drawingId);
});
@@ -18,7 +21,13 @@ export function registerLayerRoutes(fastify: FastifyInstance, db: DatabaseInterf
fastify.post('/api/drawings/:drawingId/layers', async (request, reply) => {
if (!requireAuth(request, reply, authService)) return;
const { drawingId } = request.params as { drawingId: string };
const idErr = validateIdParam(drawingId, 'drawingId');
if (idErr) return reply.code(400).send({ error: idErr });
const body = request.body as Partial<DBLayer>;
if (body.name !== undefined) {
const nameErr = validateName(body.name);
if (nameErr) return reply.code(400).send({ error: nameErr });
}
return reply.code(201).send(db.createLayer({ ...body, drawing_id: drawingId }));
});
@@ -26,7 +35,13 @@ export function registerLayerRoutes(fastify: FastifyInstance, db: DatabaseInterf
fastify.patch('/api/layers/:id', async (request, reply) => {
if (!requireAuth(request, reply, authService)) return;
const { id } = request.params as { id: string };
const idErr = validateIdParam(id, 'id');
if (idErr) return reply.code(400).send({ error: idErr });
const body = request.body as Partial<DBLayer>;
if (body.name !== undefined) {
const nameErr = validateName(body.name);
if (nameErr) return reply.code(400).send({ error: nameErr });
}
const updated = db.updateLayer(id, body);
if (!updated) return reply.code(404).send({ error: 'Layer not found' });
return updated;
@@ -36,6 +51,8 @@ export function registerLayerRoutes(fastify: FastifyInstance, db: DatabaseInterf
fastify.delete('/api/layers/:id', async (request, reply) => {
if (!requireAuth(request, reply, authService)) return;
const { id } = request.params as { id: string };
const idErr = validateIdParam(id, 'id');
if (idErr) return reply.code(400).send({ error: idErr });
const ok = db.deleteLayer(id);
if (!ok) return reply.code(404).send({ error: 'Layer not found' });
return reply.code(204).send();
+5
View File
@@ -4,6 +4,7 @@
import type { FastifyInstance } from 'fastify';
import type { DatabaseInterface } from '../database/DatabaseInterface.js';
import type { AuthService } from '../auth/AuthService.js';
import { validateIdParam } from '../utils/validation.js';
function extractToken(request: any): string | null {
const auth = request.headers?.authorization;
@@ -46,6 +47,8 @@ export function registerNotificationRoutes(fastify: FastifyInstance, db: Databas
const user = authService.getUserFromSession(token);
if (!user) return reply.code(401).send({ error: 'Invalid or expired session' });
const { id } = request.params as { id: string };
const idErr = validateIdParam(id, 'id');
if (idErr) return reply.code(400).send({ error: idErr });
const notif = db.getNotification(id);
if (!notif) return reply.code(404).send({ error: 'Notification not found' });
if (notif.user_id !== user.id) return reply.code(403).send({ error: 'Forbidden' });
@@ -61,6 +64,8 @@ export function registerNotificationRoutes(fastify: FastifyInstance, db: Databas
const user = authService.getUserFromSession(token);
if (!user) return reply.code(401).send({ error: 'Invalid or expired session' });
const { id } = request.params as { id: string };
const idErr = validateIdParam(id, 'id');
if (idErr) return reply.code(400).send({ error: idErr });
const notif = db.getNotification(id);
if (!notif) return reply.code(404).send({ error: 'Notification not found' });
if (notif.user_id !== user.id) return reply.code(403).send({ error: 'Forbidden' });
+13 -1
View File
@@ -5,6 +5,7 @@ import type { FastifyInstance } from 'fastify';
import type { DatabaseInterface, DBProject } from '../database/DatabaseInterface.js';
import { requireAuth } from '../auth/authMiddleware.js';
import type { AuthService } from '../auth/AuthService.js';
import { validateName, validateIdParam } from '../utils/validation.js';
export function registerProjectRoutes(fastify: FastifyInstance, db: DatabaseInterface, authService: AuthService) {
// List projects owned by the authenticated user
@@ -18,6 +19,8 @@ export function registerProjectRoutes(fastify: FastifyInstance, db: DatabaseInte
fastify.get('/api/projects/:id', async (request, reply) => {
if (!requireAuth(request, reply, authService)) return;
const { id } = request.params as { id: string };
const idErr = validateIdParam(id, 'id');
if (idErr) return reply.code(400).send({ error: idErr });
const project = db.getProject(id);
if (!project) return reply.code(404).send({ error: 'Project not found' });
return project;
@@ -28,7 +31,8 @@ export function registerProjectRoutes(fastify: FastifyInstance, db: DatabaseInte
const user = requireAuth(request, reply, authService);
if (!user) return;
const body = request.body as Partial<DBProject>;
if (!body.name) return reply.code(400).send({ error: 'Name is required' });
const nameErr = validateName(body.name);
if (nameErr) return reply.code(400).send({ error: nameErr });
return reply.code(201).send(db.createProject({ ...body, owner_id: user.id }));
});
@@ -36,7 +40,13 @@ export function registerProjectRoutes(fastify: FastifyInstance, db: DatabaseInte
fastify.patch('/api/projects/:id', async (request, reply) => {
if (!requireAuth(request, reply, authService)) return;
const { id } = request.params as { id: string };
const idErr = validateIdParam(id, 'id');
if (idErr) return reply.code(400).send({ error: idErr });
const body = request.body as Partial<DBProject>;
if (body.name !== undefined) {
const nameErr = validateName(body.name);
if (nameErr) return reply.code(400).send({ error: nameErr });
}
const updated = db.updateProject(id, body);
if (!updated) return reply.code(404).send({ error: 'Project not found' });
return updated;
@@ -46,6 +56,8 @@ export function registerProjectRoutes(fastify: FastifyInstance, db: DatabaseInte
fastify.delete('/api/projects/:id', async (request, reply) => {
if (!requireAuth(request, reply, authService)) return;
const { id } = request.params as { id: string };
const idErr = validateIdParam(id, 'id');
if (idErr) return reply.code(400).send({ error: idErr });
const ok = db.deleteProject(id);
if (!ok) return reply.code(404).send({ error: 'Project not found' });
return reply.code(204).send();
+9 -5
View File
@@ -5,12 +5,15 @@ import type { FastifyInstance } from 'fastify';
import type { DatabaseInterface } from '../database/DatabaseInterface.js';
import { requireAuth } from '../auth/authMiddleware.js';
import type { AuthService } from '../auth/AuthService.js';
import { validateSettingsKey, validateSettingsValue } from '../utils/validation.js';
export function registerSettingsRoutes(fastify: FastifyInstance, db: DatabaseInterface, authService: AuthService) {
// Get a single setting by key
fastify.get('/api/settings/:key', async (request, reply) => {
if (!requireAuth(request, reply, authService)) return;
const { key } = request.params as { key: string };
const keyErr = validateSettingsKey(key);
if (keyErr) return reply.code(400).send({ error: keyErr });
const setting = db.getSetting(key);
if (!setting) return reply.code(404).send({ error: 'Setting not found' });
return setting;
@@ -20,11 +23,12 @@ export function registerSettingsRoutes(fastify: FastifyInstance, db: DatabaseInt
fastify.put('/api/settings/:key', async (request, reply) => {
if (!requireAuth(request, reply, authService)) return;
const { key } = request.params as { key: string };
const keyErr = validateSettingsKey(key);
if (keyErr) return reply.code(400).send({ error: keyErr });
const body = request.body as { value?: string };
if (body.value === undefined) {
return { error: 'Value is required' };
}
db.setSetting(key, body.value);
return { key, value: body.value, updated_at: new Date().toISOString() };
const valueErr = validateSettingsValue(body.value);
if (valueErr) return reply.code(400).send({ error: valueErr });
db.setSetting(key, body.value as string);
return { key, value: body.value as string, updated_at: new Date().toISOString() };
});
}
+7
View File
@@ -4,6 +4,7 @@
import type { FastifyInstance } from 'fastify';
import type { DatabaseInterface } from '../database/DatabaseInterface.js';
import type { AuthService } from '../auth/AuthService.js';
import { validateIdParam } from '../utils/validation.js';
function extractToken(request: any): string | null {
const auth = request.headers?.authorization;
@@ -21,6 +22,8 @@ export function registerShareRoutes(fastify: FastifyInstance, db: DatabaseInterf
const user = authService.getUserFromSession(token);
if (!user) return reply.code(401).send({ error: 'Invalid or expired session' });
const { projectId } = request.params as { projectId: string };
const projIdErr = validateIdParam(projectId, 'projectId');
if (projIdErr) return reply.code(400).send({ error: projIdErr });
const project = db.getProject(projectId);
if (!project) return reply.code(404).send({ error: 'Project not found' });
if (project.owner_id !== user.id) return reply.code(403).send({ error: 'Forbidden' });
@@ -34,6 +37,8 @@ export function registerShareRoutes(fastify: FastifyInstance, db: DatabaseInterf
const user = authService.getUserFromSession(token);
if (!user) return reply.code(401).send({ error: 'Invalid or expired session' });
const { projectId } = request.params as { projectId: string };
const projIdErr = validateIdParam(projectId, 'projectId');
if (projIdErr) return reply.code(400).send({ error: projIdErr });
const project = db.getProject(projectId);
if (!project) return reply.code(404).send({ error: 'Project not found' });
if (project.owner_id !== user.id) return reply.code(403).send({ error: 'Forbidden' });
@@ -69,6 +74,8 @@ export function registerShareRoutes(fastify: FastifyInstance, db: DatabaseInterf
const user = authService.getUserFromSession(token);
if (!user) return reply.code(401).send({ error: 'Invalid or expired session' });
const { id } = request.params as { id: string };
const idErr = validateIdParam(id, 'id');
if (idErr) return reply.code(400).send({ error: idErr });
const share = db.getProjectShare(id);
if (!share) return reply.code(404).send({ error: 'Share not found' });
const project = db.getProject(share.project_id);
+7
View File
@@ -4,6 +4,7 @@
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
import type { DatabaseInterface, DBUser } from '../database/DatabaseInterface.js';
import type { AuthService } from '../auth/AuthService.js';
import { validateIdParam } from '../utils/validation.js';
function extractToken(request: FastifyRequest): string | null {
const auth = request.headers?.authorization;
@@ -43,6 +44,8 @@ export function registerUserRoutes(fastify: FastifyInstance, db: DatabaseInterfa
fastify.get('/api/users/:id', async (request, reply) => {
if (!requireAdmin(request, reply, authService)) return;
const { id } = request.params as { id: string };
const idErr = validateIdParam(id, 'id');
if (idErr) return reply.code(400).send({ error: idErr });
const user = db.getUser(id);
if (!user) return reply.code(404).send({ error: 'User not found' });
const { password_hash, ...safe } = user;
@@ -53,6 +56,8 @@ export function registerUserRoutes(fastify: FastifyInstance, db: DatabaseInterfa
fastify.patch('/api/users/:id', async (request, reply) => {
if (!requireAdmin(request, reply, authService)) return;
const { id } = request.params as { id: string };
const idErr = validateIdParam(id, 'id');
if (idErr) return reply.code(400).send({ error: idErr });
const body = request.body as Partial<DBUser>;
// Prevent password update via this endpoint
delete body.password_hash;
@@ -66,6 +71,8 @@ export function registerUserRoutes(fastify: FastifyInstance, db: DatabaseInterfa
fastify.delete('/api/users/:id', async (request, reply) => {
if (!requireAdmin(request, reply, authService)) return;
const { id } = request.params as { id: string };
const idErr = validateIdParam(id, 'id');
if (idErr) return reply.code(400).send({ error: idErr });
const ok = db.deleteUser(id);
if (!ok) return reply.code(404).send({ error: 'User not found' });
return reply.code(204).send();
+85
View File
@@ -0,0 +1,85 @@
/**
* Validation utilities for backend route input validation.
* Provides lightweight, manual validation functions without external dependencies.
*/
/** Maximum allowed length for name fields */
export const MAX_NAME_LENGTH = 255;
/** Maximum allowed length for ID fields */
export const MAX_ID_LENGTH = 128;
/** Allowed ID pattern: alphanumeric, hyphens, underscores */
const ID_PATTERN = /^[a-zA-Z0-9_-]+$/;
/**
* Validate a name field (non-empty string within max length).
* Returns null if valid, or an error message string if invalid.
*/
export function validateName(value: unknown, fieldName = 'name'): string | null {
const capitalized = fieldName.charAt(0).toUpperCase() + fieldName.slice(1);
if (typeof value !== 'string') {
return `${capitalized} is required and must be a string`;
}
if (value.trim().length === 0) {
return `${capitalized} must not be empty`;
}
if (value.length > MAX_NAME_LENGTH) {
return `${capitalized} must be at most ${MAX_NAME_LENGTH} characters`;
}
return null;
}
/**
* Validate an ID parameter (non-empty, alphanumeric/hyphen/underscore, within max length).
* Returns null if valid, or an error message string if invalid.
*/
export function validateIdParam(id: unknown, fieldName = 'id'): string | null {
if (typeof id !== 'string') {
return `${fieldName} is required and must be a string`;
}
if (id.length === 0) {
return `${fieldName} must not be empty`;
}
if (id.length > MAX_ID_LENGTH) {
return `${fieldName} is too long`;
}
if (!ID_PATTERN.test(id)) {
return `${fieldName} contains invalid characters`;
}
return null;
}
/**
* Validate a settings key (non-empty, reasonable length, alphanumeric + hyphens/underscores/dots).
* Returns null if valid, or an error message string if invalid.
*/
export function validateSettingsKey(key: unknown): string | null {
if (typeof key !== 'string' || key.trim().length === 0) {
return 'Settings key is required';
}
if (key.length > MAX_NAME_LENGTH) {
return 'Settings key is too long';
}
if (!/^[a-zA-Z0-9_.-]+$/.test(key)) {
return 'Settings key contains invalid characters';
}
return null;
}
/**
* Validate a settings value (non-empty string within reasonable length).
* Returns null if valid, or an error message string if invalid.
*/
export function validateSettingsValue(value: unknown): string | null {
if (value === undefined || value === null) {
return 'Value is required';
}
if (typeof value !== 'string') {
return 'Value must be a string';
}
if (value.length > 65535) {
return 'Value is too long';
}
return null;
}
+135
View File
@@ -0,0 +1,135 @@
/**
* Validation Utils Tests
*/
import { describe, it, expect } from 'vitest';
import { validateName, validateIdParam, validateSettingsKey, validateSettingsValue, MAX_NAME_LENGTH, MAX_ID_LENGTH } from '../src/utils/validation.js';
describe('Validation Utils', () => {
describe('validateName', () => {
it('should accept a valid name', () => {
expect(validateName('Test Project')).toBeNull();
});
it('should reject undefined', () => {
expect(validateName(undefined)).toContain('required');
});
it('should reject null', () => {
expect(validateName(null)).toContain('required');
});
it('should reject non-string types', () => {
expect(validateName(123)).toContain('required');
expect(validateName(true)).toContain('required');
expect(validateName({})).toContain('required');
});
it('should reject empty string', () => {
expect(validateName('')).toContain('empty');
});
it('should reject whitespace-only string', () => {
expect(validateName(' ')).toContain('empty');
});
it('should reject string exceeding max length', () => {
const longName = 'a'.repeat(MAX_NAME_LENGTH + 1);
expect(validateName(longName)).toContain('at most');
});
it('should accept string at exactly max length', () => {
const maxName = 'a'.repeat(MAX_NAME_LENGTH);
expect(validateName(maxName)).toBeNull();
});
it('should use custom field name in error', () => {
const err = validateName(undefined, 'project');
expect(err).toContain('Project');
});
});
describe('validateIdParam', () => {
it('should accept a valid alphanumeric ID', () => {
expect(validateIdParam('abc123')).toBeNull();
});
it('should accept ID with hyphens and underscores', () => {
expect(validateIdParam('my-id_123')).toBeNull();
});
it('should reject undefined', () => {
expect(validateIdParam(undefined)).toContain('required');
});
it('should reject null', () => {
expect(validateIdParam(null)).toContain('required');
});
it('should reject empty string', () => {
expect(validateIdParam('')).toContain('empty');
});
it('should reject ID with special characters', () => {
expect(validateIdParam('abc!def')).toContain('invalid');
expect(validateIdParam('abc def')).toContain('invalid');
expect(validateIdParam('abc/def')).toContain('invalid');
expect(validateIdParam('abc.def')).toContain('invalid');
});
it('should reject ID exceeding max length', () => {
const longId = 'a'.repeat(MAX_ID_LENGTH + 1);
expect(validateIdParam(longId)).toContain('too long');
});
it('should accept ID at exactly max length', () => {
const maxId = 'a'.repeat(MAX_ID_LENGTH);
expect(validateIdParam(maxId)).toBeNull();
});
});
describe('validateSettingsKey', () => {
it('should accept a valid key', () => {
expect(validateSettingsKey('theme.color')).toBeNull();
});
it('should accept key with dots, hyphens, underscores', () => {
expect(validateSettingsKey('my-setting_key.value')).toBeNull();
});
it('should reject undefined', () => {
expect(validateSettingsKey(undefined)).toContain('required');
});
it('should reject empty string', () => {
expect(validateSettingsKey('')).toContain('required');
});
it('should reject key with special characters', () => {
expect(validateSettingsKey('abc!def')).toContain('invalid');
expect(validateSettingsKey('abc def')).toContain('invalid');
});
});
describe('validateSettingsValue', () => {
it('should accept a valid string value', () => {
expect(validateSettingsValue('some value')).toBeNull();
});
it('should accept empty string value', () => {
expect(validateSettingsValue('')).toBeNull();
});
it('should reject undefined', () => {
expect(validateSettingsValue(undefined)).toContain('required');
});
it('should reject null', () => {
expect(validateSettingsValue(null)).toContain('required');
});
it('should reject non-string types', () => {
expect(validateSettingsValue(123)).toContain('string');
expect(validateSettingsValue(true)).toContain('string');
});
});
});