| | 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 | }); |