/**
* Tests for validation utilities
*/
import { describe, it, expect } from 'vitest';
import {
truncateString,
validateString,
validateStringArray,
sanitizeFilename,
sanitizePathComponent,
validatePathWithinBase,
escapeRegex,
createSafeRegex,
createWordMatchRegex,
isValidISODate,
isPositiveInteger,
clampNumber,
MAX_LENGTHS,
} from '../validation.js';
describe('truncateString', () => {
it('returns undefined for undefined input', () => {
expect(truncateString(undefined, 100)).toBeUndefined();
});
it('returns string unchanged if shorter than max', () => {
expect(truncateString('hello', 10)).toBe('hello');
});
it('truncates string to max length', () => {
expect(truncateString('hello world', 5)).toBe('hello');
});
it('handles exact length', () => {
expect(truncateString('hello', 5)).toBe('hello');
});
it('handles empty string', () => {
expect(truncateString('', 10)).toBe('');
});
});
describe('validateString', () => {
it('returns undefined for undefined non-required', () => {
expect(validateString(undefined, 'test', 100)).toBeUndefined();
});
it('throws for undefined required field', () => {
expect(() => validateString(undefined, 'test', 100, true)).toThrow('test is required');
});
it('truncates long strings', () => {
expect(validateString('hello world', 'test', 5)).toBe('hello');
});
it('returns valid string unchanged', () => {
expect(validateString('hello', 'test', 100)).toBe('hello');
});
});
describe('validateStringArray', () => {
it('returns undefined for undefined input', () => {
expect(validateStringArray(undefined, 50)).toBeUndefined();
});
it('filters and truncates array items', () => {
const result = validateStringArray(['short', 'a very long string that exceeds limit'], 10);
expect(result).toEqual(['short', 'a very lon']);
});
it('filters empty strings', () => {
const result = validateStringArray(['hello', '', 'world'], 50);
expect(result).toEqual(['hello', 'world']);
});
it('limits array size', () => {
const largeArray = Array(200).fill('item');
const result = validateStringArray(largeArray, 50, 10);
expect(result).toHaveLength(10);
});
});
describe('sanitizeFilename', () => {
it('replaces dangerous characters with underscore', () => {
const result = sanitizeFilename('file<name');
expect(result).toContain('_');
expect(result).not.toContain('<');
});
it('replaces path separators', () => {
const result = sanitizeFilename('path/file\\name');
expect(result).not.toContain('/');
expect(result).not.toContain('\\');
});
it('returns unnamed for empty string', () => {
expect(sanitizeFilename('')).toBe('unnamed');
});
it('truncates long filenames to 255 chars', () => {
const longName = 'a'.repeat(300);
expect(sanitizeFilename(longName).length).toBeLessThanOrEqual(255);
});
it('handles normal filenames', () => {
expect(sanitizeFilename('my-file_2024.txt')).toBe('my-file_2024.txt');
});
it('replaces leading dots', () => {
const result = sanitizeFilename('..hidden');
expect(result[0]).not.toBe('.');
});
});
describe('sanitizePathComponent', () => {
it('sanitizes path traversal components', () => {
// Returns underscore for pure ".."
expect(sanitizePathComponent('..')).toBe('_');
});
it('replaces slashes with underscore', () => {
const result = sanitizePathComponent('path/to/file');
expect(result).not.toContain('/');
});
it('returns unnamed for empty input', () => {
expect(sanitizePathComponent('')).toBe('unnamed');
});
});
describe('validatePathWithinBase', () => {
it('returns resolved path for path within base', () => {
const result = validatePathWithinBase('/home/user/notes/file.md', '/home/user/notes');
expect(result).toBe('/home/user/notes/file.md');
});
it('returns resolved path for nested path within base', () => {
const result = validatePathWithinBase('/home/user/notes/project/file.md', '/home/user/notes');
expect(result).toBe('/home/user/notes/project/file.md');
});
it('throws for path outside base', () => {
expect(() => validatePathWithinBase('/etc/passwd', '/home/user/notes')).toThrow('Path traversal');
});
it('throws for path traversal attempt', () => {
expect(() => validatePathWithinBase('/home/user/notes/../../../etc/passwd', '/home/user/notes')).toThrow('Path traversal');
});
it('returns base path for base path itself', () => {
const result = validatePathWithinBase('/home/user/notes', '/home/user/notes');
expect(result).toBe('/home/user/notes');
});
});
describe('escapeRegex', () => {
it('escapes special characters', () => {
expect(escapeRegex('hello.*world')).toBe('hello\\.\\*world');
});
it('escapes all special regex chars', () => {
expect(escapeRegex('[test]')).toBe('\\[test\\]');
expect(escapeRegex('a+b?c')).toBe('a\\+b\\?c');
expect(escapeRegex('(foo)')).toBe('\\(foo\\)');
});
it('leaves normal text unchanged', () => {
expect(escapeRegex('hello world')).toBe('hello world');
});
});
describe('createSafeRegex', () => {
it('creates regex for safe pattern', () => {
const regex = createSafeRegex('hello');
expect(regex).toBeInstanceOf(RegExp);
expect(regex?.test('hello world')).toBe(true);
});
it('returns null for overly long pattern', () => {
const longPattern = 'a'.repeat(101);
expect(createSafeRegex(longPattern)).toBeNull();
});
it('escapes special characters in pattern', () => {
const regex = createSafeRegex('hello.*world');
expect(regex?.test('hello.*world')).toBe(true);
expect(regex?.test('helloXXXworld')).toBe(false);
});
});
describe('createWordMatchRegex', () => {
it('creates case-insensitive word match regex', () => {
const regex = createWordMatchRegex('hello');
expect(regex).not.toBeNull();
expect(regex!.test('Hello World')).toBe(true);
});
it('handles special characters', () => {
const regex = createWordMatchRegex('c++');
expect(regex).not.toBeNull();
expect(regex!.test('I use c++ for development')).toBe(true);
});
it('returns null for empty word', () => {
expect(createWordMatchRegex('')).toBeNull();
});
it('returns null for overly long word', () => {
expect(createWordMatchRegex('a'.repeat(101))).toBeNull();
});
});
describe('isValidISODate', () => {
it('returns true for date-only format (YYYY-MM-DD)', () => {
expect(isValidISODate('2024-01-15')).toBe(true);
});
it('returns true for full ISO timestamp', () => {
expect(isValidISODate('2024-01-15T10:30:00Z')).toBe(true);
expect(isValidISODate('2024-01-15T10:30:00.000Z')).toBe(true);
});
it('returns false for invalid date', () => {
expect(isValidISODate('not-a-date')).toBe(false);
});
it('returns false for empty string', () => {
expect(isValidISODate('')).toBe(false);
});
it('returns false for undefined', () => {
expect(isValidISODate(undefined)).toBe(false);
});
it('returns false for invalid date values', () => {
expect(isValidISODate('2024-13-45')).toBe(false);
});
});
describe('isPositiveInteger', () => {
it('returns true for positive integers', () => {
expect(isPositiveInteger(1)).toBe(true);
expect(isPositiveInteger(100)).toBe(true);
});
it('returns false for zero', () => {
expect(isPositiveInteger(0)).toBe(false);
});
it('returns false for negative numbers', () => {
expect(isPositiveInteger(-1)).toBe(false);
});
it('returns false for decimals', () => {
expect(isPositiveInteger(1.5)).toBe(false);
});
it('returns false for NaN', () => {
expect(isPositiveInteger(NaN)).toBe(false);
});
});
describe('clampNumber', () => {
it('returns value within range', () => {
expect(clampNumber(5, 1, 10, 5)).toBe(5);
});
it('clamps to min', () => {
expect(clampNumber(0, 1, 10, 5)).toBe(1);
});
it('clamps to max', () => {
expect(clampNumber(20, 1, 10, 5)).toBe(10);
});
it('returns default for undefined', () => {
expect(clampNumber(undefined, 1, 10, 5)).toBe(5);
});
});
describe('MAX_LENGTHS', () => {
it('has required fields', () => {
expect(MAX_LENGTHS.summary).toBeDefined();
expect(MAX_LENGTHS.query).toBeDefined();
expect(MAX_LENGTHS.path).toBeDefined();
expect(MAX_LENGTHS.tag).toBeDefined();
});
it('has reasonable values', () => {
expect(MAX_LENGTHS.summary).toBeGreaterThan(100);
expect(MAX_LENGTHS.tag).toBeGreaterThan(10);
});
});