| 1 | +import { describe, test, expect, jest } from '@jest/globals'; |
| 2 | +import { |
| 3 | + keyToEnv, |
| 4 | + getBasicAuthHeader, |
| 5 | + getHexString, |
| 6 | + normalizeZipEntryPath, |
| 7 | + deepMerge, |
| 8 | + uuidv4, |
| 9 | + humanizedDateTime, |
| 10 | + tryParse, |
| 11 | + clientRelativePath, |
| 12 | + getUniqueName, |
| 13 | + removeFileExtension, |
| 14 | + removeColorFormatting, |
| 15 | + getSeparator, |
| 16 | + isValidUrl, |
| 17 | + urlHostnameToIPv6, |
| 18 | + toBoolean, |
| 19 | + stringToBool, |
| 20 | + trimV1, |
| 21 | + trimTrailingSlash, |
| 22 | + mutateJsonString, |
| 23 | + isPathUnderParent, |
| 24 | + isFileURL, |
| 25 | + getRequestURL, |
| 26 | +} from '../src/util'; |
| 27 | + |
| 28 | +describe('keyToEnv', () => { |
| 29 | + test('should convert dotted key to env var format', () => { |
| 30 | + expect(keyToEnv('extensions.models.speechToText')).toBe('SILLYTAVERN_EXTENSIONS_MODELS_SPEECHTOTEXT'); |
| 31 | + }); |
| 32 | + |
| 33 | + test('should handle simple key without dots', () => { |
| 34 | + expect(keyToEnv('port')).toBe('SILLYTAVERN_PORT'); |
| 35 | + }); |
| 36 | + |
| 37 | + test('should coerce non-string input via String()', () => { |
| 38 | + expect(keyToEnv(42)).toBe('SILLYTAVERN_42'); |
| 39 | + }); |
| 40 | +}); |
| 41 | + |
| 42 | +describe('getBasicAuthHeader', () => { |
| 43 | + test('should return a valid Basic auth header', () => { |
| 44 | + expect(getBasicAuthHeader('user:pass')).toBe('Basic dXNlcjpwYXNz'); |
| 45 | + }); |
| 46 | + |
| 47 | + test('should handle empty string', () => { |
| 48 | + expect(getBasicAuthHeader('')).toBe('Basic '); |
| 49 | + }); |
| 50 | +}); |
| 51 | + |
| 52 | +describe('getHexString', () => { |
| 53 | + test('should return a string of the requested length', () => { |
| 54 | + expect(getHexString(8)).toHaveLength(8); |
| 55 | + expect(getHexString(32)).toHaveLength(32); |
| 56 | + }); |
| 57 | + |
| 58 | + test('should only contain hex characters', () => { |
| 59 | + expect(getHexString(64)).toMatch(/^[0-9a-f]+$/); |
| 60 | + }); |
| 61 | + |
| 62 | + test('should return empty string for length 0', () => { |
| 63 | + expect(getHexString(0)).toBe(''); |
| 64 | + }); |
| 65 | +}); |
| 66 | + |
| 67 | +describe('normalizeZipEntryPath', () => { |
| 68 | + test('should normalize backslashes to forward slashes', () => { |
| 69 | + expect(normalizeZipEntryPath('foo\\bar\\baz.txt')).toBe('foo/bar/baz.txt'); |
| 70 | + }); |
| 71 | + |
| 72 | + test('should strip leading ./', () => { |
| 73 | + expect(normalizeZipEntryPath('./file.txt')).toBe('file.txt'); |
| 74 | + }); |
| 75 | + |
| 76 | + test('should strip leading /', () => { |
| 77 | + expect(normalizeZipEntryPath('/absolute/path.txt')).toBe('absolute/path.txt'); |
| 78 | + }); |
| 79 | + |
| 80 | + test('should reject path traversal', () => { |
| 81 | + expect(normalizeZipEntryPath('../etc/passwd')).toBeNull(); |
| 82 | + }); |
| 83 | + |
| 84 | + test('should reject non-string input', () => { |
| 85 | + expect(normalizeZipEntryPath(42)).toBeNull(); |
| 86 | + expect(normalizeZipEntryPath(null)).toBeNull(); |
| 87 | + }); |
| 88 | + |
| 89 | + test('should reject empty or whitespace-only string', () => { |
| 90 | + expect(normalizeZipEntryPath('')).toBeNull(); |
| 91 | + expect(normalizeZipEntryPath(' ')).toBeNull(); |
| 92 | + }); |
| 93 | +}); |
| 94 | + |
| 95 | +describe('deepMerge', () => { |
| 96 | + test('should merge flat objects', () => { |
| 97 | + expect(deepMerge({ a: 1 }, { b: 2 })).toEqual({ a: 1, b: 2 }); |
| 98 | + }); |
| 99 | + |
| 100 | + test('should recursively merge nested objects', () => { |
| 101 | + const target = { nested: { a: 1, b: 2 } }; |
| 102 | + const source = { nested: { b: 3, c: 4 } }; |
| 103 | + expect(deepMerge(target, source)).toEqual({ nested: { a: 1, b: 3, c: 4 } }); |
| 104 | + }); |
| 105 | + |
| 106 | + test('should override primitives with source values', () => { |
| 107 | + expect(deepMerge({ a: 1 }, { a: 2 })).toEqual({ a: 2 }); |
| 108 | + }); |
| 109 | + |
| 110 | + test('should not mutate original objects', () => { |
| 111 | + const target = { a: { x: 1 } }; |
| 112 | + const source = { a: { y: 2 } }; |
| 113 | + const result = deepMerge(target, source); |
| 114 | + expect(target).toEqual({ a: { x: 1 } }); |
| 115 | + expect(result).toEqual({ a: { x: 1, y: 2 } }); |
| 116 | + }); |
| 117 | + |
| 118 | + test('should handle empty source', () => { |
| 119 | + expect(deepMerge({ a: 1 }, {})).toEqual({ a: 1 }); |
| 120 | + }); |
| 121 | +}); |
| 122 | + |
| 123 | +describe('uuidv4', () => { |
| 124 | + test('should return a valid UUIDv4 format', () => { |
| 125 | + const uuid = uuidv4(); |
| 126 | + expect(uuid).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/); |
| 127 | + }); |
| 128 | + |
| 129 | + test('should return unique values', () => { |
| 130 | + const a = uuidv4(); |
| 131 | + const b = uuidv4(); |
| 132 | + expect(a).not.toBe(b); |
| 133 | + }); |
| 134 | +}); |
| 135 | + |
| 136 | +describe('humanizedDateTime', () => { |
| 137 | + test('should format a known timestamp correctly', () => { |
| 138 | + // 2024-01-15 09:05:03.007 UTC |
| 139 | + const timestamp = Date.UTC(2024, 0, 15, 9, 5, 3, 7); |
| 140 | + const result = humanizedDateTime(timestamp); |
| 141 | + // The output uses local time, so just check the format pattern |
| 142 | + expect(result).toMatch(/^\d{4}-\d{2}-\d{2}@\d{2}h\d{2}m\d{2}s\d{3}ms$/); |
| 143 | + }); |
| 144 | +}); |
| 145 | + |
| 146 | +describe('tryParse', () => { |
| 147 | + test('should parse valid JSON', () => { |
| 148 | + expect(tryParse('{"a":1}')).toEqual({ a: 1 }); |
| 149 | + }); |
| 150 | + |
| 151 | + test('should parse JSON array', () => { |
| 152 | + expect(tryParse('[1,2,3]')).toEqual([1, 2, 3]); |
| 153 | + }); |
| 154 | + |
| 155 | + test('should return undefined for invalid JSON', () => { |
| 156 | + expect(tryParse('not json')).toBeUndefined(); |
| 157 | + }); |
| 158 | + |
| 159 | + test('should return undefined for empty string', () => { |
| 160 | + expect(tryParse('')).toBeUndefined(); |
| 161 | + }); |
| 162 | +}); |
| 163 | + |
| 164 | +describe('clientRelativePath', () => { |
| 165 | + test('should strip the root prefix and use forward slashes', () => { |
| 166 | + expect(clientRelativePath('/data/user', '/data/user/images/pic.png')).toBe('/images/pic.png'); |
| 167 | + }); |
| 168 | + |
| 169 | + test('should throw if path does not start with root', () => { |
| 170 | + expect(() => clientRelativePath('/data/user', '/other/path')).toThrow(); |
| 171 | + }); |
| 172 | +}); |
| 173 | + |
| 174 | +describe('getUniqueName', () => { |
| 175 | + test('should return base name with index when first try collides', () => { |
| 176 | + const existing = new Set(['Alice']); |
| 177 | + const result = getUniqueName('Alice', name => existing.has(name)); |
| 178 | + expect(result).toBe('Alice (1)'); |
| 179 | + }); |
| 180 | + |
| 181 | + test('should increment index until unique', () => { |
| 182 | + const existing = new Set(['Bob', 'Bob (1)', 'Bob (2)']); |
| 183 | + const result = getUniqueName('Bob', name => existing.has(name)); |
| 184 | + expect(result).toBe('Bob (3)'); |
| 185 | + }); |
| 186 | + |
| 187 | + test('should return null when maxTries exceeded', () => { |
| 188 | + const result = getUniqueName('X', () => true, { maxTries: 3 }); |
| 189 | + expect(result).toBeNull(); |
| 190 | + }); |
| 191 | + |
| 192 | + test('should support custom nameBuilder', () => { |
| 193 | + const existing = new Set(['doc.txt']); |
| 194 | + const result = getUniqueName('doc.txt', name => existing.has(name), { |
| 195 | + nameBuilder: (base, i) => `doc (${i}).txt`, |
| 196 | + }); |
| 197 | + expect(result).toBe('doc (1).txt'); |
| 198 | + }); |
| 199 | + |
| 200 | + test('should check basename first when startIndex is 0', () => { |
| 201 | + const result = getUniqueName('Free', () => false, { startIndex: 0 }); |
| 202 | + expect(result).toBe('Free'); |
| 203 | + }); |
| 204 | +}); |
| 205 | + |
| 206 | +describe('removeFileExtension', () => { |
| 207 | + test('should remove a single extension', () => { |
| 208 | + expect(removeFileExtension('image.png')).toBe('image'); |
| 209 | + }); |
| 210 | + |
| 211 | + test('should remove only the last extension', () => { |
| 212 | + expect(removeFileExtension('archive.tar.gz')).toBe('archive.tar'); |
| 213 | + }); |
| 214 | + |
| 215 | + test('should return filename unchanged if no extension', () => { |
| 216 | + expect(removeFileExtension('README')).toBe('README'); |
| 217 | + }); |
| 218 | + |
| 219 | + test('should handle dotfiles', () => { |
| 220 | + expect(removeFileExtension('.gitignore')).toBe(''); |
| 221 | + }); |
| 222 | +}); |
| 223 | + |
| 224 | +describe('removeColorFormatting', () => { |
| 225 | + test('should strip ANSI color codes', () => { |
| 226 | + expect(removeColorFormatting('\x1b[31mError\x1b[0m')).toBe('Error'); |
| 227 | + }); |
| 228 | + |
| 229 | + test('should return plain text unchanged', () => { |
| 230 | + expect(removeColorFormatting('no colors here')).toBe('no colors here'); |
| 231 | + }); |
| 232 | +}); |
| 233 | + |
| 234 | +describe('getSeparator', () => { |
| 235 | + test('should return n equals signs', () => { |
| 236 | + expect(getSeparator(5)).toBe('====='); |
| 237 | + }); |
| 238 | + |
| 239 | + test('should return empty string for 0', () => { |
| 240 | + expect(getSeparator(0)).toBe(''); |
| 241 | + }); |
| 242 | +}); |
| 243 | + |
| 244 | +describe('isValidUrl', () => { |
| 245 | + test('should accept valid HTTP URLs', () => { |
| 246 | + expect(isValidUrl('https://example.com')).toBe(true); |
| 247 | + expect(isValidUrl('http://localhost:8080/path')).toBe(true); |
| 248 | + }); |
| 249 | + |
| 250 | + test('should accept file URLs', () => { |
| 251 | + expect(isValidUrl('file:///tmp/test.txt')).toBe(true); |
| 252 | + }); |
| 253 | + |
| 254 | + test('should reject non-URL strings', () => { |
| 255 | + expect(isValidUrl('not a url')).toBe(false); |
| 256 | + expect(isValidUrl('')).toBe(false); |
| 257 | + }); |
| 258 | +}); |
| 259 | + |
| 260 | +describe('urlHostnameToIPv6', () => { |
| 261 | + test('should strip surrounding brackets', () => { |
| 262 | + expect(urlHostnameToIPv6('[::1]')).toBe('::1'); |
| 263 | + }); |
| 264 | + |
| 265 | + test('should handle already-clean hostname', () => { |
| 266 | + expect(urlHostnameToIPv6('::1')).toBe('::1'); |
| 267 | + }); |
| 268 | + |
| 269 | + test('should handle IPv4 passthrough', () => { |
| 270 | + expect(urlHostnameToIPv6('127.0.0.1')).toBe('127.0.0.1'); |
| 271 | + }); |
| 272 | +}); |
| 273 | + |
| 274 | +describe('toBoolean', () => { |
| 275 | + test('should handle "true" and "false" strings case-insensitively', () => { |
| 276 | + expect(toBoolean('true')).toBe(true); |
| 277 | + expect(toBoolean('TRUE')).toBe(true); |
| 278 | + expect(toBoolean('false')).toBe(false); |
| 279 | + expect(toBoolean('False')).toBe(false); |
| 280 | + }); |
| 281 | + |
| 282 | + test('should handle whitespace around boolean strings', () => { |
| 283 | + expect(toBoolean(' true ')).toBe(true); |
| 284 | + }); |
| 285 | + |
| 286 | + test('should use JS truthiness for non-boolean strings', () => { |
| 287 | + expect(toBoolean('hello')).toBe(true); |
| 288 | + expect(toBoolean('')).toBe(false); |
| 289 | + }); |
| 290 | + |
| 291 | + test('should handle non-string values', () => { |
| 292 | + expect(toBoolean(1)).toBe(true); |
| 293 | + expect(toBoolean(0)).toBe(false); |
| 294 | + expect(toBoolean(null)).toBe(false); |
| 295 | + expect(toBoolean(undefined)).toBe(false); |
| 296 | + }); |
| 297 | +}); |
| 298 | + |
| 299 | +describe('stringToBool', () => { |
| 300 | + test('should convert "true" to true', () => { |
| 301 | + expect(stringToBool('true')).toBe(true); |
| 302 | + expect(stringToBool(' TRUE ')).toBe(true); |
| 303 | + }); |
| 304 | + |
| 305 | + test('should convert "false" to false', () => { |
| 306 | + expect(stringToBool('false')).toBe(false); |
| 307 | + }); |
| 308 | + |
| 309 | + test('should pass through non-boolean strings', () => { |
| 310 | + expect(stringToBool('hello')).toBe('hello'); |
| 311 | + }); |
| 312 | + |
| 313 | + test('should pass through null', () => { |
| 314 | + expect(stringToBool(null)).toBe(null); |
| 315 | + }); |
| 316 | +}); |
| 317 | + |
| 318 | +describe('trimV1', () => { |
| 319 | + test('should remove trailing /v1', () => { |
| 320 | + expect(trimV1('https://api.example.com/v1')).toBe('https://api.example.com'); |
| 321 | + }); |
| 322 | + |
| 323 | + test('should remove trailing slash', () => { |
| 324 | + expect(trimV1('https://api.example.com/')).toBe('https://api.example.com'); |
| 325 | + }); |
| 326 | + |
| 327 | + test('should remove trailing slash then /v1', () => { |
| 328 | + expect(trimV1('https://api.example.com/v1/')).toBe('https://api.example.com'); |
| 329 | + }); |
| 330 | + |
| 331 | + test('should handle null/undefined gracefully', () => { |
| 332 | + expect(trimV1(null)).toBe(''); |
| 333 | + expect(trimV1(undefined)).toBe(''); |
| 334 | + }); |
| 335 | +}); |
| 336 | + |
| 337 | +describe('trimTrailingSlash', () => { |
| 338 | + test('should remove trailing slash', () => { |
| 339 | + expect(trimTrailingSlash('https://example.com/')).toBe('https://example.com'); |
| 340 | + }); |
| 341 | + |
| 342 | + test('should leave non-trailing-slash URLs unchanged', () => { |
| 343 | + expect(trimTrailingSlash('https://example.com')).toBe('https://example.com'); |
| 344 | + }); |
| 345 | + |
| 346 | + test('should handle null/undefined gracefully', () => { |
| 347 | + expect(trimTrailingSlash(null)).toBe(''); |
| 348 | + }); |
| 349 | +}); |
| 350 | + |
| 351 | +describe('mutateJsonString', () => { |
| 352 | + test('should apply mutation and re-serialize', () => { |
| 353 | + const result = mutateJsonString('{"a":1}', obj => { obj.b = 2; }); |
| 354 | + expect(JSON.parse(result)).toEqual({ a: 1, b: 2 }); |
| 355 | + }); |
| 356 | + |
| 357 | + test('should return original string on invalid JSON', () => { |
| 358 | + const input = 'not json'; |
| 359 | + const spy = jest.spyOn(console, 'error').mockImplementation(() => {}); |
| 360 | + expect(mutateJsonString(input, () => {})).toBe(input); |
| 361 | + spy.mockRestore(); |
| 362 | + }); |
| 363 | +}); |
| 364 | + |
| 365 | +describe('isPathUnderParent', () => { |
| 366 | + test('should accept child paths', () => { |
| 367 | + expect(isPathUnderParent('/data', '/data/users/file.txt')).toBe(true); |
| 368 | + }); |
| 369 | + |
| 370 | + test('should reject traversal attempts', () => { |
| 371 | + expect(isPathUnderParent('/data', '/data/../etc/passwd')).toBe(false); |
| 372 | + }); |
| 373 | + |
| 374 | + test('should reject sibling paths', () => { |
| 375 | + expect(isPathUnderParent('/data/a', '/data/b')).toBe(false); |
| 376 | + }); |
| 377 | + |
| 378 | + test('should accept the parent path itself', () => { |
| 379 | + expect(isPathUnderParent('/data', '/data')).toBe(true); |
| 380 | + }); |
| 381 | +}); |
| 382 | + |
| 383 | +describe('isFileURL', () => { |
| 384 | + test('should detect file:// string URLs', () => { |
| 385 | + expect(isFileURL('file:///tmp/test.txt')).toBe(true); |
| 386 | + }); |
| 387 | + |
| 388 | + test('should reject non-file string URLs', () => { |
| 389 | + expect(isFileURL('https://example.com')).toBe(false); |
| 390 | + }); |
| 391 | + |
| 392 | + test('should detect file:// URL objects', () => { |
| 393 | + expect(isFileURL(new URL('file:///tmp/test.txt'))).toBe(true); |
| 394 | + }); |
| 395 | + |
| 396 | + test('should detect file:// Request objects', () => { |
| 397 | + expect(isFileURL(new Request('file:///tmp/test.txt'))).toBe(true); |
| 398 | + }); |
| 399 | + |
| 400 | + test('should return false for non-matching types', () => { |
| 401 | + expect(isFileURL(42)).toBe(false); |
| 402 | + }); |
| 403 | +}); |
| 404 | + |
| 405 | +describe('getRequestURL', () => { |
| 406 | + test('should return string URLs as-is', () => { |
| 407 | + expect(getRequestURL('https://example.com')).toBe('https://example.com'); |
| 408 | + }); |
| 409 | + |
| 410 | + test('should extract href from URL objects', () => { |
| 411 | + expect(getRequestURL(new URL('https://example.com/path'))).toBe('https://example.com/path'); |
| 412 | + }); |
| 413 | + |
| 414 | + test('should extract url from Request objects', () => { |
| 415 | + expect(getRequestURL(new Request('https://example.com/path'))).toBe('https://example.com/path'); |
| 416 | + }); |
| 417 | + |
| 418 | + test('should throw for invalid types', () => { |
| 419 | + expect(() => getRequestURL(42)).toThrow(TypeError); |
| 420 | + }); |
| 421 | +}); |