diff --git a/src/spellcheck/tests/fetch-loader-security.test.ts b/src/spellcheck/tests/fetch-loader-security.test.ts index d1bac65..7b7f065 100644 --- a/src/spellcheck/tests/fetch-loader-security.test.ts +++ b/src/spellcheck/tests/fetch-loader-security.test.ts @@ -31,20 +31,20 @@ describe('FetchDictionaryLoader — URL injection protection', () => { expect(result).toBe(false); }); - it('resolves normal relative paths correctly', () => { + it('resolves normal relative paths correctly', async () => { const loader2 = new FetchDictionaryLoader('https://cdn.example.com/dictionaries'); // loadText will fail on fetch (no server), but should NOT throw injection errors // We test the URL resolution by expecting a fetch error, not a security error - expect(loader2.loadText('english/words.txt')).rejects.toThrow(); + await expect(loader2.loadText('english/words.txt')).rejects.toThrow(); }); - it('handles base URL with trailing slash consistently', () => { + it('handles base URL with trailing slash consistently', async () => { const loaderWithSlash = new FetchDictionaryLoader('https://cdn.example.com/dict/'); const loaderNoSlash = new FetchDictionaryLoader('https://cdn.example.com/dict'); // Both should reject traversal identically - expect(loaderWithSlash.loadText('../../secret')).rejects.toThrow('Path traversal detected'); - expect(loaderNoSlash.loadText('../../secret')).rejects.toThrow('Path traversal detected'); + await expect(loaderWithSlash.loadText('../../secret')).rejects.toThrow('Path traversal detected'); + await expect(loaderNoSlash.loadText('../../secret')).rejects.toThrow('Path traversal detected'); }); }); diff --git a/src/spellcheck/tests/lru-cache-security.test.ts b/src/spellcheck/tests/lru-cache-security.test.ts new file mode 100644 index 0000000..9fe0b70 --- /dev/null +++ b/src/spellcheck/tests/lru-cache-security.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect } from 'vitest'; + +import { LRUCache } from '../utils/lru-cache'; + +describe('LRUCache — accessCounter overflow protection', () => { + it('compacts when counter approaches MAX_SAFE_INTEGER', () => { + const cache = new LRUCache(10); + + // Add some entries + cache.set('a', 1); + cache.set('b', 2); + cache.set('c', 3); + + // Force the counter near the threshold by manipulating internal state + // We access the private field via bracket notation for testing + const cacheAny = cache as unknown as { + accessCounter: number; + accessOrder: Map; + }; + cacheAny.accessCounter = Number.MAX_SAFE_INTEGER - 500_000; + + // Set access orders to values near the threshold + cacheAny.accessOrder.set('a', Number.MAX_SAFE_INTEGER - 500_003); + cacheAny.accessOrder.set('b', Number.MAX_SAFE_INTEGER - 500_002); + cacheAny.accessOrder.set('c', Number.MAX_SAFE_INTEGER - 500_001); + + // This get() should trigger compaction since counter is near threshold + cache.get('a'); + + // After compaction, counter should be reset to a small value + expect(cacheAny.accessCounter).toBeLessThan(100); + + // Eviction order should still be correct: b was least recently used + // (a was just accessed, c was accessed before a but after b) + cache.set('d', 4); + cache.set('e', 5); + cache.set('f', 6); + cache.set('g', 7); + cache.set('h', 8); + cache.set('i', 9); + cache.set('j', 10); + + // Cache is now full (10 items: a,b,c,d,e,f,g,h,i,j) + // Adding one more should evict the LRU item (b, which wasn't accessed) + cache.set('k', 11); + + expect(cache.has('b')).toBe(false); // b was evicted (LRU) + expect(cache.has('a')).toBe(true); // a was recently accessed + expect(cache.has('k')).toBe(true); // k was just added + }); + + it('preserves correct eviction order after compaction', () => { + const cache = new LRUCache(3); + + cache.set('x', 1); + cache.set('y', 2); + cache.set('z', 3); + + // Access in order: x, y (so z is LRU) + cache.get('x'); + cache.get('y'); + + // Force compaction + const cacheAny = cache as unknown as { accessCounter: number }; + cacheAny.accessCounter = Number.MAX_SAFE_INTEGER - 500_000; + + // Trigger compaction via a set + cache.set('z', 33); // updates z's access time, triggers compaction + + // Now x is LRU (accessed before y, and z was just updated) + cache.set('w', 4); // should evict x + + expect(cache.has('x')).toBe(false); + expect(cache.has('y')).toBe(true); + expect(cache.has('z')).toBe(true); + expect(cache.has('w')).toBe(true); + }); + + it('does not compact when counter is well below threshold', () => { + const cache = new LRUCache(5); + + cache.set('a', 1); + cache.set('b', 2); + cache.get('a'); + cache.get('b'); + + const cacheAny = cache as unknown as { accessCounter: number }; + + // Counter should be small and not compacted + expect(cacheAny.accessCounter).toBeLessThan(100); + }); +}); diff --git a/src/spellcheck/tests/symspell-integration.test.ts b/src/spellcheck/tests/symspell-integration.test.ts index 2d57050..6944656 100644 --- a/src/spellcheck/tests/symspell-integration.test.ts +++ b/src/spellcheck/tests/symspell-integration.test.ts @@ -1,7 +1,8 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { SpellChecker } from '../spell-checker.js'; -import type { SpellEngine, SpellSuggestion } from '../engines/types.js'; +import { SpellChecker } from '../spell-checker'; +import { SymSpellEngine } from '../engines/symspell-engine'; +import type { SpellEngine, SpellSuggestion } from '../engines/types'; /** * Mock SpellEngine that simulates SymSpell behavior: @@ -575,3 +576,60 @@ describe('SpellEngine interface edge cases', () => { }); }); }); + +describe('SymSpellEngine — maxEditDistance validation', () => { + it('accepts maxEditDistance of 0', () => { + expect(() => new SymSpellEngine({ + wasmUrl: 'file:///dummy.wasm', + dictionaryUrl: 'file:///dummy.txt', + maxEditDistance: 0, + })).not.toThrow(); + }); + + it('accepts maxEditDistance of 5', () => { + expect(() => new SymSpellEngine({ + wasmUrl: 'file:///dummy.wasm', + dictionaryUrl: 'file:///dummy.txt', + maxEditDistance: 5, + })).not.toThrow(); + }); + + it('accepts default maxEditDistance (undefined → 2)', () => { + expect(() => new SymSpellEngine({ + wasmUrl: 'file:///dummy.wasm', + dictionaryUrl: 'file:///dummy.txt', + })).not.toThrow(); + }); + + it('rejects maxEditDistance of 6', () => { + expect(() => new SymSpellEngine({ + wasmUrl: 'file:///dummy.wasm', + dictionaryUrl: 'file:///dummy.txt', + maxEditDistance: 6, + })).toThrow('maxEditDistance must be an integer between 0 and 5, got 6'); + }); + + it('rejects maxEditDistance of 100', () => { + expect(() => new SymSpellEngine({ + wasmUrl: 'file:///dummy.wasm', + dictionaryUrl: 'file:///dummy.txt', + maxEditDistance: 100, + })).toThrow('maxEditDistance must be an integer between 0 and 5'); + }); + + it('rejects negative maxEditDistance', () => { + expect(() => new SymSpellEngine({ + wasmUrl: 'file:///dummy.wasm', + dictionaryUrl: 'file:///dummy.txt', + maxEditDistance: -1, + })).toThrow('maxEditDistance must be an integer between 0 and 5, got -1'); + }); + + it('rejects fractional maxEditDistance', () => { + expect(() => new SymSpellEngine({ + wasmUrl: 'file:///dummy.wasm', + dictionaryUrl: 'file:///dummy.txt', + maxEditDistance: 2.5, + })).toThrow('maxEditDistance must be an integer between 0 and 5, got 2.5'); + }); +});