1package url
2
3import (
4 "os"
5 "path/filepath"
6 "testing"
7)
8
9func TestNormalize(t *testing.T) {
10 tests := []struct {
11 name string
12 input string
13 want string
14 }{
15 {
16 name: "empty string",
17 input: "",
18 want: "",
19 },
20 {
21 name: "http scheme unchanged",
22 input: "http://example.com",
23 want: "http://example.com",
24 },
25 {
26 name: "https scheme unchanged",
27 input: "https://example.com",
28 want: "https://example.com",
29 },
30 {
31 name: "file scheme unchanged",
32 input: "file:///path/to/file.html",
33 want: "file:///path/to/file.html",
34 },
35 {
36 name: "dumb scheme unchanged",
37 input: "dumb://home",
38 want: "dumb://home",
39 },
40 {
41 name: "about scheme unchanged",
42 input: "about:blank",
43 want: "about:blank",
44 },
45 {
46 name: "domain gets https",
47 input: "example.com",
48 want: "https://example.com",
49 },
50 {
51 name: "domain with path gets https",
52 input: "example.com/path",
53 want: "https://example.com/path",
54 },
55 {
56 name: "search query unchanged",
57 input: "hello world",
58 want: "hello world",
59 },
60 {
61 name: "single word unchanged",
62 input: "hello",
63 want: "hello",
64 },
65 {
66 name: "localhost",
67 input: "localhost",
68 want: "http://localhost",
69 },
70 {
71 name: "localhost with port",
72 input: "localhost:5173",
73 want: "http://localhost:5173",
74 },
75 {
76 name: "localhost with alt port",
77 input: "localhost:8080",
78 want: "http://localhost:8080",
79 },
80 {
81 name: "localhost with path",
82 input: "localhost/api/v1",
83 want: "http://localhost/api/v1",
84 },
85 {
86 name: "localhost.com is a domain not localhost",
87 input: "localhost.com",
88 want: "https://localhost.com",
89 },
90 {
91 name: "localhostevil.com is a domain not localhost",
92 input: "localhostevil.com",
93 want: "https://localhostevil.com",
94 },
95 }
96
97 for _, tt := range tests {
98 t.Run(tt.name, func(t *testing.T) {
99 got := Normalize(tt.input)
100 if got != tt.want {
101 t.Errorf("Normalize(%q) = %q, want %q", tt.input, got, tt.want)
102 }
103 })
104 }
105}
106
107func TestNormalize_LocalFiles(t *testing.T) {
108 // Create a temporary file for testing
109 tmpDir := t.TempDir()
110 tmpFile := filepath.Join(tmpDir, "test.html")
111 if err := os.WriteFile(tmpFile, []byte("<html></html>"), 0644); err != nil {
112 t.Fatalf("failed to create temp file: %v", err)
113 }
114
115 // Create a subdirectory with a file
116 subDir := filepath.Join(tmpDir, "subdir")
117 if err := os.Mkdir(subDir, 0755); err != nil {
118 t.Fatalf("failed to create subdir: %v", err)
119 }
120 subFile := filepath.Join(subDir, "page.html")
121 if err := os.WriteFile(subFile, []byte("<html></html>"), 0644); err != nil {
122 t.Fatalf("failed to create subdir file: %v", err)
123 }
124
125 tests := []struct {
126 name string
127 input string
128 want string
129 }{
130 {
131 name: "absolute path to existing file",
132 input: tmpFile,
133 want: "file://" + tmpFile,
134 },
135 {
136 name: "absolute path to existing directory",
137 input: tmpDir,
138 want: "file://" + tmpDir,
139 },
140 {
141 name: "non-existent absolute path returned unchanged",
142 input: "/nonexistent/file.html",
143 want: "/nonexistent/file.html",
144 },
145 {
146 name: "non-existent relative path with dot prefix returned unchanged",
147 input: "./nonexistent/file.html",
148 want: "./nonexistent/file.html",
149 },
150 {
151 name: "non-existent home path returned unchanged",
152 input: "~/nonexistent/file.html",
153 want: "~/nonexistent/file.html",
154 },
155 }
156
157 for _, tt := range tests {
158 t.Run(tt.name, func(t *testing.T) {
159 got := Normalize(tt.input)
160 if got != tt.want {
161 t.Errorf("Normalize(%q) = %q, want %q", tt.input, got, tt.want)
162 }
163 })
164 }
165}
166
167func TestNormalize_RelativePaths(t *testing.T) {
168 // Create a temp file in current directory
169 tmpFile, err := os.CreateTemp(".", "test-*.html")
170 if err != nil {
171 t.Fatalf("failed to create temp file: %v", err)
172 }
173 tmpFile.Close()
174 defer os.Remove(tmpFile.Name())
175
176 // Get absolute path for comparison
177 absPath, err := filepath.Abs(tmpFile.Name())
178 if err != nil {
179 t.Fatalf("failed to get abs path: %v", err)
180 }
181
182 t.Run("relative path to existing file", func(t *testing.T) {
183 got := Normalize(tmpFile.Name())
184 want := "file://" + absPath
185 if got != want {
186 t.Errorf("Normalize(%q) = %q, want %q", tmpFile.Name(), got, want)
187 }
188 })
189}
190
191func TestExpandHome(t *testing.T) {
192 home, err := os.UserHomeDir()
193 if err != nil {
194 t.Skip("cannot get home directory")
195 }
196
197 tests := []struct {
198 name string
199 input string
200 want string
201 }{
202 {
203 name: "tilde expansion",
204 input: "~/Documents",
205 want: filepath.Join(home, "Documents"),
206 },
207 {
208 name: "no tilde unchanged",
209 input: "/absolute/path",
210 want: "/absolute/path",
211 },
212 {
213 name: "relative path unchanged",
214 input: "./relative/path",
215 want: "./relative/path",
216 },
217 {
218 name: "tilde in middle unchanged",
219 input: "/path/~/file",
220 want: "/path/~/file",
221 },
222 }
223
224 for _, tt := range tests {
225 t.Run(tt.name, func(t *testing.T) {
226 got := expandHome(tt.input)
227 if got != tt.want {
228 t.Errorf("expandHome(%q) = %q, want %q", tt.input, got, tt.want)
229 }
230 })
231 }
232}
233
234func TestTrimLeadingSpacesIfURL(t *testing.T) {
235 tests := []struct {
236 name string
237 input string
238 want string
239 }{
240 {
241 name: "single space before https URL",
242 input: " https://example.com",
243 want: "https://example.com",
244 },
245 {
246 name: "multiple spaces before https URL",
247 input: " https://example.com",
248 want: "https://example.com",
249 },
250 {
251 name: "tab before URL",
252 input: "\thttps://example.com",
253 want: "https://example.com",
254 },
255 {
256 name: "mixed whitespace before URL",
257 input: " \t https://example.com",
258 want: "https://example.com",
259 },
260 {
261 name: "space before domain",
262 input: " example.com",
263 want: "example.com",
264 },
265 {
266 name: "spaces before localhost",
267 input: " localhost:8080",
268 want: "localhost:8080",
269 },
270 {
271 name: "no leading space URL unchanged",
272 input: "https://example.com",
273 want: "https://example.com",
274 },
275 {
276 name: "search query with leading space unchanged",
277 input: " hello world",
278 want: " hello world",
279 },
280 {
281 name: "single word with leading space unchanged",
282 input: " hello",
283 want: " hello",
284 },
285 {
286 name: "empty string unchanged",
287 input: "",
288 want: "",
289 },
290 {
291 name: "only spaces unchanged",
292 input: " ",
293 want: " ",
294 },
295 }
296
297 for _, tt := range tests {
298 t.Run(tt.name, func(t *testing.T) {
299 got := TrimLeadingSpacesIfURL(tt.input)
300 if got != tt.want {
301 t.Errorf("TrimLeadingSpacesIfURL(%q) = %q, want %q", tt.input, got, tt.want)
302 }
303 })
304 }
305}
306
307func TestLooksLikeURL(t *testing.T) {
308 tests := []struct {
309 name string
310 input string
311 want bool
312 }{
313 {
314 name: "empty string",
315 input: "",
316 want: false,
317 },
318 {
319 name: "http URL",
320 input: "http://example.com",
321 want: true,
322 },
323 {
324 name: "https URL",
325 input: "https://example.com",
326 want: true,
327 },
328 {
329 name: "file URL",
330 input: "file:///path/to/file",
331 want: true,
332 },
333 {
334 name: "dumb URL",
335 input: "dumb://home",
336 want: true,
337 },
338 {
339 name: "about URL",
340 input: "about:blank",
341 want: true,
342 },
343 {
344 name: "domain",
345 input: "example.com",
346 want: true,
347 },
348 {
349 name: "domain with path",
350 input: "example.com/path",
351 want: true,
352 },
353 {
354 name: "search query",
355 input: "hello world",
356 want: false,
357 },
358 {
359 name: "single word",
360 input: "hello",
361 want: false,
362 },
363 {
364 name: "localhost",
365 input: "localhost",
366 want: true,
367 },
368 {
369 name: "localhost with port",
370 input: "localhost:5173",
371 want: true,
372 },
373 {
374 name: "localhost with alt port",
375 input: "localhost:8080",
376 want: true,
377 },
378 {
379 name: "localhost with path",
380 input: "localhost/api/v1",
381 want: true,
382 },
383 {
384 name: "localhost.com is a domain not localhost",
385 input: "localhost.com",
386 want: true,
387 },
388 {
389 name: "localhostevil.com is a domain not localhost",
390 input: "localhostevil.com",
391 want: true,
392 },
393 }
394
395 for _, tt := range tests {
396 t.Run(tt.name, func(t *testing.T) {
397 got := LooksLikeURL(tt.input)
398 if got != tt.want {
399 t.Errorf("LooksLikeURL(%q) = %v, want %v", tt.input, got, tt.want)
400 }
401 })
402 }
403}
404
405func TestIsExternalScheme(t *testing.T) {
406 tests := []struct {
407 name string
408 input string
409 want bool
410 }{
411 {
412 name: "empty string",
413 input: "",
414 want: false,
415 },
416 {
417 name: "http scheme",
418 input: "http://example.com",
419 want: false,
420 },
421 {
422 name: "https scheme",
423 input: "https://example.com",
424 want: false,
425 },
426 {
427 name: "file scheme",
428 input: "file:///path/to/file.html",
429 want: false,
430 },
431 {
432 name: "dumb scheme",
433 input: "dumb://home",
434 want: false,
435 },
436 {
437 name: "about scheme",
438 input: "about:blank",
439 want: false,
440 },
441 {
442 name: "data scheme",
443 input: "data:text/plain;base64,SGVsbG8gV29ybGQ==",
444 want: false,
445 },
446 {
447 name: "blob scheme",
448 input: "blob:https://example.com/blob-id",
449 want: false,
450 },
451 {
452 name: "javascript scheme",
453 input: "javascript:alert('hello')",
454 want: false,
455 },
456 {
457 name: "vscode scheme",
458 input: "vscode://open",
459 want: true,
460 },
461 {
462 name: "spotify scheme",
463 input: "spotify:track:123",
464 want: true,
465 },
466 {
467 name: "steam scheme",
468 input: "steam://run/123",
469 want: true,
470 },
471 {
472 name: "uppercase HTTP scheme",
473 input: "HTTP://example.com",
474 want: false,
475 },
476 {
477 name: "uppercase ABOUT scheme",
478 input: "ABOUT:blank",
479 want: false,
480 },
481 {
482 name: "uppercase VSCODE scheme",
483 input: "VSCODE://open",
484 want: true,
485 },
486 {
487 name: "malformed URL",
488 input: "://invalid",
489 want: false,
490 },
491 {
492 name: "no scheme",
493 input: "example.com",
494 want: false,
495 },
496 {
497 name: "scheme only",
498 input: "custom://",
499 want: true,
500 },
501 }
502
503 for _, tt := range tests {
504 t.Run(tt.name, func(t *testing.T) {
505 got := IsExternalScheme(tt.input)
506 if got != tt.want {
507 t.Errorf("IsExternalScheme(%q) = %v, want %v", tt.input, got, tt.want)
508 }
509 })
510 }
511}
512
513func TestExtractOrigin_ValidURI(t *testing.T) {
514 tests := []struct {
515 name string
516 uri string
517 expected string
518 }{
519 {
520 name: "https with port",
521 uri: "https://example.com:8443/path?query=1",
522 expected: "https://example.com:8443",
523 },
524 {
525 name: "https without port",
526 uri: "https://example.com/path",
527 expected: "https://example.com",
528 },
529 {
530 name: "http",
531 uri: "http://localhost:8080/app",
532 expected: "http://localhost:8080",
533 },
534 {
535 name: "uppercase host",
536 uri: "https://EXAMPLE.COM/path",
537 expected: "https://example.com",
538 },
539 {
540 name: "uppercase scheme",
541 uri: "HTTPS://example.com/path",
542 expected: "https://example.com",
543 },
544 {
545 name: "https default port omitted",
546 uri: "https://example.com:443/path",
547 expected: "https://example.com",
548 },
549 {
550 name: "http default port omitted",
551 uri: "http://example.com:80/path",
552 expected: "http://example.com",
553 },
554 {
555 name: "https explicit non-default port kept",
556 uri: "https://example.com:8080/path",
557 expected: "https://example.com:8080",
558 },
559 {
560 name: "mixed case with default port",
561 uri: "HTTPS://EXAMPLE.COM:443/path",
562 expected: "https://example.com",
563 },
564 {
565 name: "ipv6 with explicit port",
566 uri: "https://[::1]:8443/path",
567 expected: "https://[::1]:8443",
568 },
569 }
570
571 for _, tt := range tests {
572 t.Run(tt.name, func(t *testing.T) {
573 origin, err := ExtractOrigin(tt.uri)
574 if err != nil {
575 t.Errorf("ExtractOrigin(%q) returned error: %v", tt.uri, err)
576 return
577 }
578 if origin != tt.expected {
579 t.Errorf("ExtractOrigin(%q) = %q, want %q", tt.uri, origin, tt.expected)
580 }
581 })
582 }
583}
584
585func TestExtractOrigin_InvalidURI(t *testing.T) {
586 tests := []struct {
587 name string
588 uri string
589 }{
590 {name: "empty", uri: ""},
591 {name: "no scheme", uri: "example.com"},
592 {name: "no host", uri: "https://"},
593 }
594
595 for _, tt := range tests {
596 t.Run(tt.name, func(t *testing.T) {
597 origin, err := ExtractOrigin(tt.uri)
598 if err == nil {
599 t.Errorf("ExtractOrigin(%q) should return error, got origin: %q", tt.uri, origin)
600 }
601 })
602 }
603}