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