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://history",
38 want: "dumb://history",
39 },
40 {
41 name: "about scheme unchanged",
42 input: "about:blank",
43 want: "about:blank",
44 },
45 {
46 name: "mailto scheme unchanged",
47 input: "mailto:foo@example.com",
48 want: "mailto:foo@example.com",
49 },
50 {
51 name: "magnet scheme unchanged",
52 input: "magnet:?xt=urn:btih:123",
53 want: "magnet:?xt=urn:btih:123",
54 },
55 {
56 name: "obsidian scheme unchanged",
57 input: "obsidian://open?vault=foo.bar",
58 want: "obsidian://open?vault=foo.bar",
59 },
60 {
61 name: "domain gets https",
62 input: "example.com",
63 want: "https://example.com",
64 },
65 {
66 name: "domain with path gets https",
67 input: "example.com/path",
68 want: "https://example.com/path",
69 },
70 {
71 name: "search query unchanged",
72 input: "hello world",
73 want: "hello world",
74 },
75 {
76 name: "single word unchanged",
77 input: "hello",
78 want: "hello",
79 },
80 {
81 name: "localhost",
82 input: "localhost",
83 want: "http://localhost",
84 },
85 {
86 name: "localhost with port",
87 input: "localhost:5173",
88 want: "http://localhost:5173",
89 },
90 {
91 name: "localhost with alt port",
92 input: "localhost:8080",
93 want: "http://localhost:8080",
94 },
95 {
96 name: "localhost with path",
97 input: "localhost/api/v1",
98 want: "http://localhost/api/v1",
99 },
100 {
101 name: "localhost.com is a domain not localhost",
102 input: "localhost.com",
103 want: "https://localhost.com",
104 },
105 {
106 name: "localhostevil.com is a domain not localhost",
107 input: "localhostevil.com",
108 want: "https://localhostevil.com",
109 },
110 }
111
112 for _, tt := range tests {
113 t.Run(tt.name, func(t *testing.T) {
114 got := Normalize(tt.input)
115 if got != tt.want {
116 t.Errorf("Normalize(%q) = %q, want %q", tt.input, got, tt.want)
117 }
118 })
119 }
120}
121
122func TestNormalize_LocalFiles(t *testing.T) {
123 // Create a temporary file for testing
124 tmpDir := t.TempDir()
125 tmpFile := filepath.Join(tmpDir, "test.html")
126 if err := os.WriteFile(tmpFile, []byte("<html></html>"), 0644); err != nil {
127 t.Fatalf("failed to create temp file: %v", err)
128 }
129
130 // Create a subdirectory with a file
131 subDir := filepath.Join(tmpDir, "subdir")
132 if err := os.Mkdir(subDir, 0755); err != nil {
133 t.Fatalf("failed to create subdir: %v", err)
134 }
135 subFile := filepath.Join(subDir, "page.html")
136 if err := os.WriteFile(subFile, []byte("<html></html>"), 0644); err != nil {
137 t.Fatalf("failed to create subdir file: %v", err)
138 }
139
140 tests := []struct {
141 name string
142 input string
143 want string
144 }{
145 {
146 name: "absolute path to existing file",
147 input: tmpFile,
148 want: "file://" + tmpFile,
149 },
150 {
151 name: "absolute path to existing directory",
152 input: tmpDir,
153 want: "file://" + tmpDir,
154 },
155 {
156 name: "non-existent absolute path returned unchanged",
157 input: "/nonexistent/file.html",
158 want: "/nonexistent/file.html",
159 },
160 {
161 name: "non-existent relative path with dot prefix returned unchanged",
162 input: "./nonexistent/file.html",
163 want: "./nonexistent/file.html",
164 },
165 {
166 name: "non-existent home path returned unchanged",
167 input: "~/nonexistent/file.html",
168 want: "~/nonexistent/file.html",
169 },
170 }
171
172 for _, tt := range tests {
173 t.Run(tt.name, func(t *testing.T) {
174 got := Normalize(tt.input)
175 if got != tt.want {
176 t.Errorf("Normalize(%q) = %q, want %q", tt.input, got, tt.want)
177 }
178 })
179 }
180}
181
182func TestNormalize_RelativePaths(t *testing.T) {
183 // Create a temp file in current directory
184 tmpFile, err := os.CreateTemp(".", "test-*.html")
185 if err != nil {
186 t.Fatalf("failed to create temp file: %v", err)
187 }
188 tmpFile.Close()
189 defer os.Remove(tmpFile.Name())
190
191 // Get absolute path for comparison
192 absPath, err := filepath.Abs(tmpFile.Name())
193 if err != nil {
194 t.Fatalf("failed to get abs path: %v", err)
195 }
196
197 t.Run("relative path to existing file", func(t *testing.T) {
198 got := Normalize(tmpFile.Name())
199 want := "file://" + absPath
200 if got != want {
201 t.Errorf("Normalize(%q) = %q, want %q", tmpFile.Name(), got, want)
202 }
203 })
204}
205
206func TestExpandHome(t *testing.T) {
207 home, err := os.UserHomeDir()
208 if err != nil {
209 t.Skip("cannot get home directory")
210 }
211
212 tests := []struct {
213 name string
214 input string
215 want string
216 }{
217 {
218 name: "tilde expansion",
219 input: "~/Documents",
220 want: filepath.Join(home, "Documents"),
221 },
222 {
223 name: "no tilde unchanged",
224 input: "/absolute/path",
225 want: "/absolute/path",
226 },
227 {
228 name: "relative path unchanged",
229 input: "./relative/path",
230 want: "./relative/path",
231 },
232 {
233 name: "tilde in middle unchanged",
234 input: "/path/~/file",
235 want: "/path/~/file",
236 },
237 }
238
239 for _, tt := range tests {
240 t.Run(tt.name, func(t *testing.T) {
241 got := expandHome(tt.input)
242 if got != tt.want {
243 t.Errorf("expandHome(%q) = %q, want %q", tt.input, got, tt.want)
244 }
245 })
246 }
247}
248
249func TestTrimLeadingSpacesIfURL(t *testing.T) {
250 tests := []struct {
251 name string
252 input string
253 want string
254 }{
255 {
256 name: "single space before https URL",
257 input: " https://example.com",
258 want: "https://example.com",
259 },
260 {
261 name: "multiple spaces before https URL",
262 input: " https://example.com",
263 want: "https://example.com",
264 },
265 {
266 name: "tab before URL",
267 input: "\thttps://example.com",
268 want: "https://example.com",
269 },
270 {
271 name: "mixed whitespace before URL",
272 input: " \t https://example.com",
273 want: "https://example.com",
274 },
275 {
276 name: "space before domain",
277 input: " example.com",
278 want: "example.com",
279 },
280 {
281 name: "spaces before localhost",
282 input: " localhost:8080",
283 want: "localhost:8080",
284 },
285 {
286 name: "no leading space URL unchanged",
287 input: "https://example.com",
288 want: "https://example.com",
289 },
290 {
291 name: "search query with leading space unchanged",
292 input: " hello world",
293 want: " hello world",
294 },
295 {
296 name: "single word with leading space unchanged",
297 input: " hello",
298 want: " hello",
299 },
300 {
301 name: "empty string unchanged",
302 input: "",
303 want: "",
304 },
305 {
306 name: "only spaces unchanged",
307 input: " ",
308 want: " ",
309 },
310 }
311
312 for _, tt := range tests {
313 t.Run(tt.name, func(t *testing.T) {
314 got := TrimLeadingSpacesIfURL(tt.input)
315 if got != tt.want {
316 t.Errorf("TrimLeadingSpacesIfURL(%q) = %q, want %q", tt.input, got, tt.want)
317 }
318 })
319 }
320}
321
322func TestLooksLikeURL(t *testing.T) {
323 tests := []struct {
324 name string
325 input string
326 want bool
327 }{
328 {
329 name: "empty string",
330 input: "",
331 want: false,
332 },
333 {
334 name: "http URL",
335 input: "http://example.com",
336 want: true,
337 },
338 {
339 name: "https URL",
340 input: "https://example.com",
341 want: true,
342 },
343 {
344 name: "file URL",
345 input: "file:///path/to/file",
346 want: true,
347 },
348 {
349 name: "dumb URL",
350 input: "dumb://history",
351 want: true,
352 },
353 {
354 name: "obsidian URL",
355 input: "obsidian://open?vault=foo.bar",
356 want: true,
357 },
358 {
359 name: "about URL",
360 input: "about:blank",
361 want: true,
362 },
363 {
364 name: "spotify URL",
365 input: "spotify://track/123",
366 want: true,
367 },
368 {
369 name: "mailto URL",
370 input: "mailto:foo@example.com",
371 want: true,
372 },
373 {
374 name: "magnet URL",
375 input: "magnet:?xt=urn:btih:123",
376 want: true,
377 },
378 {
379 name: "vscode URL",
380 input: "vscode://file/tmp/demo",
381 want: true,
382 },
383 {
384 name: "domain",
385 input: "example.com",
386 want: true,
387 },
388 {
389 name: "domain with path",
390 input: "example.com/path",
391 want: true,
392 },
393 {
394 name: "search query",
395 input: "hello world",
396 want: false,
397 },
398 {
399 name: "single word",
400 input: "hello",
401 want: false,
402 },
403 {
404 name: "localhost",
405 input: "localhost",
406 want: true,
407 },
408 {
409 name: "localhost with port",
410 input: "localhost:5173",
411 want: true,
412 },
413 {
414 name: "localhost with alt port",
415 input: "localhost:8080",
416 want: true,
417 },
418 {
419 name: "localhost with path",
420 input: "localhost/api/v1",
421 want: true,
422 },
423 {
424 name: "localhost.com is a domain not localhost",
425 input: "localhost.com",
426 want: true,
427 },
428 {
429 name: "localhostevil.com is a domain not localhost",
430 input: "localhostevil.com",
431 want: true,
432 },
433 {
434 name: "ipv4 with port",
435 input: "100.64.0.10:3000",
436 want: true,
437 },
438 {
439 name: "ipv4",
440 input: "127.0.0.1",
441 want: true,
442 },
443 {
444 name: "ipv6 bracketed",
445 input: "[::1]",
446 want: true,
447 },
448 }
449
450 for _, tt := range tests {
451 t.Run(tt.name, func(t *testing.T) {
452 got := LooksLikeURL(tt.input)
453 if got != tt.want {
454 t.Errorf("LooksLikeURL(%q) = %v, want %v", tt.input, got, tt.want)
455 }
456 })
457 }
458}
459
460func TestNormalize_IPAddressGetsHTTPByDefault(t *testing.T) {
461 tests := []struct {
462 name string
463 input string
464 want string
465 }{
466 {name: "tailnet ipv4", input: "100.64.0.10", want: "http://100.64.0.10"},
467 {name: "tailnet ipv4 with port", input: "100.64.0.10:8080", want: "http://100.64.0.10:8080"},
468 {name: "ipv4 with path", input: "100.64.0.10/api", want: "http://100.64.0.10/api"},
469 }
470
471 for _, tt := range tests {
472 t.Run(tt.name, func(t *testing.T) {
473 got := Normalize(tt.input)
474 if got != tt.want {
475 t.Errorf("Normalize(%q) = %q, want %q", tt.input, got, tt.want)
476 }
477 })
478 }
479}
480
481func TestIsExternalScheme(t *testing.T) {
482 tests := []struct {
483 name string
484 input string
485 want bool
486 }{
487 {
488 name: "empty string",
489 input: "",
490 want: false,
491 },
492 {
493 name: "http scheme",
494 input: "http://example.com",
495 want: false,
496 },
497 {
498 name: "https scheme",
499 input: "https://example.com",
500 want: false,
501 },
502 {
503 name: "file scheme",
504 input: "file:///path/to/file.html",
505 want: false,
506 },
507 {
508 name: "dumb scheme",
509 input: "dumb://history",
510 want: false,
511 },
512 {
513 name: "about scheme",
514 input: "about:blank",
515 want: false,
516 },
517 {
518 name: "data scheme",
519 input: "data:text/plain;base64,SGVsbG8gV29ybGQ==",
520 want: false,
521 },
522 {
523 name: "blob scheme",
524 input: "blob:https://example.com/blob-id",
525 want: false,
526 },
527 {
528 name: "javascript scheme",
529 input: "javascript:alert('hello')",
530 want: false,
531 },
532 {
533 name: "vscode scheme",
534 input: "vscode://open",
535 want: true,
536 },
537 {
538 name: "spotify scheme",
539 input: "spotify:track:123",
540 want: true,
541 },
542 {
543 name: "steam scheme",
544 input: "steam://run/123",
545 want: true,
546 },
547 {
548 name: "uppercase HTTP scheme",
549 input: "HTTP://example.com",
550 want: false,
551 },
552 {
553 name: "uppercase ABOUT scheme",
554 input: "ABOUT:blank",
555 want: false,
556 },
557 {
558 name: "uppercase VSCODE scheme",
559 input: "VSCODE://open",
560 want: true,
561 },
562 {
563 name: "geo scheme",
564 input: "geo:37.786971,-122.399677",
565 want: true,
566 },
567 {
568 name: "xmpp scheme",
569 input: "xmpp:user@example.com",
570 want: true,
571 },
572 {
573 name: "malformed URL",
574 input: "://invalid",
575 want: false,
576 },
577 {
578 name: "no scheme",
579 input: "example.com",
580 want: false,
581 },
582 {
583 name: "localhost port is not external scheme",
584 input: "localhost:5173",
585 want: false,
586 },
587 {
588 name: "scheme only",
589 input: "custom://",
590 want: true,
591 },
592 }
593
594 for _, tt := range tests {
595 t.Run(tt.name, func(t *testing.T) {
596 got := IsExternalScheme(tt.input)
597 if got != tt.want {
598 t.Errorf("IsExternalScheme(%q) = %v, want %v", tt.input, got, tt.want)
599 }
600 })
601 }
602}
603
604func TestExtractOrigin_ValidURI(t *testing.T) {
605 tests := []struct {
606 name string
607 uri string
608 expected string
609 }{
610 {
611 name: "https with port",
612 uri: "https://example.com:8443/path?query=1",
613 expected: "https://example.com:8443",
614 },
615 {
616 name: "https without port",
617 uri: "https://example.com/path",
618 expected: "https://example.com",
619 },
620 {
621 name: "http",
622 uri: "http://localhost:8080/app",
623 expected: "http://localhost:8080",
624 },
625 {
626 name: "uppercase host",
627 uri: "https://EXAMPLE.COM/path",
628 expected: "https://example.com",
629 },
630 {
631 name: "uppercase scheme",
632 uri: "HTTPS://example.com/path",
633 expected: "https://example.com",
634 },
635 {
636 name: "https default port omitted",
637 uri: "https://example.com:443/path",
638 expected: "https://example.com",
639 },
640 {
641 name: "http default port omitted",
642 uri: "http://example.com:80/path",
643 expected: "http://example.com",
644 },
645 {
646 name: "https explicit non-default port kept",
647 uri: "https://example.com:8080/path",
648 expected: "https://example.com:8080",
649 },
650 {
651 name: "mixed case with default port",
652 uri: "HTTPS://EXAMPLE.COM:443/path",
653 expected: "https://example.com",
654 },
655 {
656 name: "ipv6 with explicit port",
657 uri: "https://[::1]:8443/path",
658 expected: "https://[::1]:8443",
659 },
660 }
661
662 for _, tt := range tests {
663 t.Run(tt.name, func(t *testing.T) {
664 origin, err := ExtractOrigin(tt.uri)
665 if err != nil {
666 t.Errorf("ExtractOrigin(%q) returned error: %v", tt.uri, err)
667 return
668 }
669 if origin != tt.expected {
670 t.Errorf("ExtractOrigin(%q) = %q, want %q", tt.uri, origin, tt.expected)
671 }
672 })
673 }
674}
675
676func TestExtractOrigin_InvalidURI(t *testing.T) {
677 tests := []struct {
678 name string
679 uri string
680 }{
681 {name: "empty", uri: ""},
682 {name: "no scheme", uri: "example.com"},
683 {name: "no host", uri: "https://"},
684 }
685
686 for _, tt := range tests {
687 t.Run(tt.name, func(t *testing.T) {
688 origin, err := ExtractOrigin(tt.uri)
689 if err == nil {
690 t.Errorf("ExtractOrigin(%q) should return error, got origin: %q", tt.uri, origin)
691 }
692 })
693 }
694}
695
696func TestCanonicalDomain(t *testing.T) {
697 tests := []struct {
698 name string
699 input string
700 want string
701 }{
702 {name: "empty", input: "", want: ""},
703 {name: "whitespace only", input: " ", want: ""},
704 {name: "url", input: "https://www.example.com/path", want: "example.com"},
705 {name: "url with port", input: "https://www.example.com:8443/path", want: "example.com:8443"},
706 {name: "url with userinfo", input: "https://user:pass@example.com/path", want: "example.com"},
707 {name: "url with userinfo and port", input: "https://user:pass@example.com:8443/path", want: "example.com:8443"},
708 {name: "domain", input: "www.example.com", want: "example.com"},
709 {name: "domain with path", input: "www.example.com/path?q=1", want: "example.com"},
710 }
711
712 for _, tt := range tests {
713 t.Run(tt.name, func(t *testing.T) {
714 if got := CanonicalDomain(tt.input); got != tt.want {
715 t.Errorf("CanonicalDomain(%q) = %q, want %q", tt.input, got, tt.want)
716 }
717 })
718 }
719}
720
721func TestDisplayDomain(t *testing.T) {
722 tests := []struct {
723 name string
724 input string
725 want string
726 }{
727 {name: "empty", input: "", want: ""},
728 {name: "whitespace only", input: " ", want: ""},
729 {name: "url with port", input: "https://www.example.com:8443/path", want: "example.com"},
730 {name: "url without port", input: "https://www.example.com/path", want: "example.com"},
731 {name: "url with userinfo", input: "https://user:pass@example.com/path", want: "example.com"},
732 {name: "no www prefix", input: "https://docs.example.com/path", want: "docs.example.com"},
733 {name: "host only", input: "www.example.org:9443/path?q=1", want: "example.org"},
734 {name: "invalid url", input: "%zz", want: "%zz"},
735 {name: "non-http scheme", input: "ftp://www.example.net:21/file", want: "example.net"},
736 {name: "ipv6 url", input: "https://[2001:db8::1]:8443/path", want: "2001:db8::1"},
737 {name: "uppercase host", input: "https://WWW.EXAMPLE.COM/path", want: "example.com"},
738 }
739
740 for _, tt := range tests {
741 t.Run(tt.name, func(t *testing.T) {
742 if got := DisplayDomain(tt.input); got != tt.want {
743 t.Errorf("DisplayDomain(%q) = %q, want %q", tt.input, got, tt.want)
744 }
745 })
746 }
747}