import { describe, it, expect } from 'vitest';
import {
sanitizePath,
validateToolArgs,
sanitizeString,
maskSensitiveData,
SecurityError,
ValidationError,
RateLimiter,
} from '../src/utils/security.js';
import * as path from 'path';
import * as os from 'os';
describe('sanitizePath', () => {
const workspaceRoot = '/workspace/project';
it('should allow paths within workspace', () => {
const result = sanitizePath('src/file.al', workspaceRoot);
expect(result).toBe(path.resolve(workspaceRoot, 'src/file.al'));
});
it('should allow nested paths within workspace', () => {
const result = sanitizePath('src/tables/MyTable.Table.al', workspaceRoot);
expect(result).toBe(path.resolve(workspaceRoot, 'src/tables/MyTable.Table.al'));
});
it('should reject path traversal with ../', () => {
expect(() => sanitizePath('../outside.txt', workspaceRoot)).toThrow(SecurityError);
});
it('should reject path traversal with multiple ../', () => {
expect(() => sanitizePath('src/../../outside.txt', workspaceRoot)).toThrow(SecurityError);
});
it('should reject absolute paths outside workspace', () => {
expect(() => sanitizePath('/etc/passwd', workspaceRoot)).toThrow(SecurityError);
});
it('should reject URL-encoded path traversal', () => {
expect(() => sanitizePath('%2e%2e/outside.txt', workspaceRoot)).toThrow(SecurityError);
});
it('should reject null bytes', () => {
expect(() => sanitizePath('file\0.txt', workspaceRoot)).toThrow(SecurityError);
});
});
describe('validateToolArgs', () => {
it('should pass with all required arguments', () => {
const args = { path: '/some/path', content: 'hello' };
expect(() => validateToolArgs(args, ['path', 'content'], {})).not.toThrow();
});
it('should throw for missing required argument', () => {
const args = { path: '/some/path' };
expect(() => validateToolArgs(args, ['path', 'content'], {})).toThrow(ValidationError);
});
it('should validate types correctly', () => {
const args = { count: 5, name: 'test' };
expect(() => validateToolArgs(args, [], { count: 'number', name: 'string' })).not.toThrow();
});
it('should throw for incorrect type', () => {
const args = { count: '5' }; // string instead of number
expect(() => validateToolArgs(args, [], { count: 'number' })).toThrow(ValidationError);
});
});
describe('sanitizeString', () => {
it('should return valid strings unchanged', () => {
expect(sanitizeString('hello world')).toBe('hello world');
});
it('should remove null bytes', () => {
expect(sanitizeString('hello\0world')).toBe('helloworld');
});
it('should truncate strings exceeding maxLength', () => {
const longString = 'a'.repeat(100);
expect(sanitizeString(longString, 50)).toBe('a'.repeat(50));
});
it('should throw for non-string input', () => {
expect(() => sanitizeString(123 as unknown as string)).toThrow(ValidationError);
});
});
describe('maskSensitiveData', () => {
it('should mask API keys in objects', () => {
const obj = { apiKey: 'super-secret-api-key-12345' };
const result = maskSensitiveData(obj) as Record<string, unknown>;
expect(result.apiKey).toBe('[REDACTED]');
});
it('should mask passwords in objects', () => {
const obj = { password: 'mypassword123' };
const result = maskSensitiveData(obj) as Record<string, unknown>;
expect(result.password).toBe('[REDACTED]');
});
it('should mask authorization headers', () => {
const obj = { authorization: 'Bearer token12345678901234567890' };
const result = maskSensitiveData(obj) as Record<string, unknown>;
expect(result.authorization).toBe('[REDACTED]');
});
it('should mask long strings that look like tokens', () => {
const token = 'abcdefghijklmnopqrstuvwxyz1234567890';
const result = maskSensitiveData(token) as string;
expect(result).toContain('[MASKED:');
expect(result).toContain('...');
expect(result).toContain(']');
});
it('should not mask short normal strings', () => {
const obj = { name: 'John' };
const result = maskSensitiveData(obj) as Record<string, unknown>;
expect(result.name).toBe('John');
});
it('should handle nested objects', () => {
const obj = {
user: {
name: 'John',
api_key: 'secret123456789012345'
}
};
const result = maskSensitiveData(obj) as Record<string, Record<string, unknown>>;
expect(result.user.name).toBe('John');
expect(result.user.api_key).toBe('[REDACTED]');
});
it('should handle arrays', () => {
const arr = [{ apiKey: 'secret' }, { name: 'test' }];
const result = maskSensitiveData(arr) as Array<Record<string, unknown>>;
expect(result[0].apiKey).toBe('[REDACTED]');
expect(result[1].name).toBe('test');
});
});
describe('RateLimiter', () => {
it('should allow requests within limit', () => {
const limiter = new RateLimiter(5, 1000);
expect(limiter.isAllowed()).toBe(true);
expect(limiter.isAllowed()).toBe(true);
expect(limiter.isAllowed()).toBe(true);
expect(limiter.remaining()).toBe(2);
});
it('should block requests exceeding limit', () => {
const limiter = new RateLimiter(3, 1000);
expect(limiter.isAllowed()).toBe(true);
expect(limiter.isAllowed()).toBe(true);
expect(limiter.isAllowed()).toBe(true);
expect(limiter.isAllowed()).toBe(false);
expect(limiter.remaining()).toBe(0);
});
it('should reset after window expires', async () => {
const limiter = new RateLimiter(2, 100); // 100ms window
expect(limiter.isAllowed()).toBe(true);
expect(limiter.isAllowed()).toBe(true);
expect(limiter.isAllowed()).toBe(false);
// Wait for window to reset
await new Promise(resolve => setTimeout(resolve, 150));
expect(limiter.isAllowed()).toBe(true);
});
it('should return 0 resetIn when no requests', () => {
const limiter = new RateLimiter(5, 1000);
expect(limiter.resetIn()).toBe(0);
});
it('should return time until reset when requests made', () => {
const limiter = new RateLimiter(5, 1000);
limiter.isAllowed();
const resetIn = limiter.resetIn();
expect(resetIn).toBeGreaterThan(0);
expect(resetIn).toBeLessThanOrEqual(1000);
});
});
describe('sanitizePath edge cases', () => {
const workspaceRoot = '/workspace/project';
it('should handle double URL-encoded path traversal', () => {
expect(() => sanitizePath('%252e%252e/outside.txt', workspaceRoot)).toThrow(SecurityError);
});
it('should handle backslash path separators', () => {
// On Windows-like paths
const result = sanitizePath('src\\tables\\MyTable.al', workspaceRoot);
expect(result).toContain('MyTable.al');
});
});
describe('maskSensitiveData edge cases', () => {
it('should handle null values', () => {
expect(maskSensitiveData(null)).toBeNull();
});
it('should handle undefined values', () => {
expect(maskSensitiveData(undefined)).toBeUndefined();
});
it('should handle primitive values', () => {
expect(maskSensitiveData(42)).toBe(42);
expect(maskSensitiveData(true)).toBe(true);
});
it('should handle empty objects', () => {
const result = maskSensitiveData({});
expect(result).toEqual({});
});
it('should handle empty arrays', () => {
const result = maskSensitiveData([]);
expect(result).toEqual([]);
});
it('should mask secrets in objects', () => {
const obj = { secret: 'mysecretvalue123' };
const result = maskSensitiveData(obj) as Record<string, unknown>;
expect(result.secret).toBe('[REDACTED]');
});
it('should mask tokens in objects', () => {
const obj = { token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9' };
const result = maskSensitiveData(obj) as Record<string, unknown>;
expect(result.token).toBe('[REDACTED]');
});
it('should mask credentials in objects', () => {
const obj = { credential: 'user:pass' };
const result = maskSensitiveData(obj) as Record<string, unknown>;
expect(result.credential).toBe('[REDACTED]');
});
it('should not mask empty sensitive fields', () => {
const obj = { apiKey: '' };
const result = maskSensitiveData(obj) as Record<string, unknown>;
expect(result.apiKey).toBe('');
});
it('should handle very deep nesting', () => {
const deepObj = { a: { b: { c: { d: { e: { f: { g: { h: { i: { j: { k: 'value' } } } } } } } } } } };
const result = maskSensitiveData(deepObj);
expect(result).toBeDefined();
});
});
describe('validateToolArgs edge cases', () => {
it('should handle null values as missing', () => {
const args = { path: null };
expect(() => validateToolArgs(args as Record<string, unknown>, ['path'], {})).toThrow(ValidationError);
});
it('should handle undefined values as missing', () => {
const args = { path: undefined };
expect(() => validateToolArgs(args as Record<string, unknown>, ['path'], {})).toThrow(ValidationError);
});
it('should validate array type', () => {
const args = { items: [1, 2, 3] };
expect(() => validateToolArgs(args, [], { items: 'array' })).not.toThrow();
});
it('should reject non-array when array expected', () => {
const args = { items: 'not an array' };
expect(() => validateToolArgs(args, [], { items: 'array' })).toThrow(ValidationError);
});
it('should validate object type', () => {
const args = { config: { key: 'value' } };
expect(() => validateToolArgs(args, [], { config: 'object' })).not.toThrow();
});
it('should validate boolean type', () => {
const args = { enabled: false };
expect(() => validateToolArgs(args, [], { enabled: 'boolean' })).not.toThrow();
});
});