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 name: "ipv4 with port",
395 input: "100.64.0.10:3000",
396 want: true,
397 },
398 {
399 name: "ipv4",
400 input: "127.0.0.1",
401 want: true,
402 },
403 {
404 name: "ipv6 bracketed",
405 input: "[::1]",
406 want: true,
407 },
408 }
409
410 for _, tt := range tests {
411 t.Run(tt.name, func(t *testing.T) {
412 got := LooksLikeURL(tt.input)
413 if got != tt.want {
414 t.Errorf("LooksLikeURL(%q) = %v, want %v", tt.input, got, tt.want)
415 }
416 })
417 }
418}
419
420func TestNormalize_IPAddressGetsHTTPByDefault(t *testing.T) {
421 tests := []struct {
422 name string
423 input string
424 want string
425 }{
426 {name: "tailnet ipv4", input: "100.64.0.10", want: "http://100.64.0.10"},
427 {name: "tailnet ipv4 with port", input: "100.64.0.10:8080", want: "http://100.64.0.10:8080"},
428 {name: "ipv4 with path", input: "100.64.0.10/api", want: "http://100.64.0.10/api"},
429 }
430
431 for _, tt := range tests {
432 t.Run(tt.name, func(t *testing.T) {
433 got := Normalize(tt.input)
434 if got != tt.want {
435 t.Errorf("Normalize(%q) = %q, want %q", tt.input, got, tt.want)
436 }
437 })
438 }
439}
440
441func TestIsExternalScheme(t *testing.T) {
442 tests := []struct {
443 name string
444 input string
445 want bool
446 }{
447 {
448 name: "empty string",
449 input: "",
450 want: false,
451 },
452 {
453 name: "http scheme",
454 input: "http://example.com",
455 want: false,
456 },
457 {
458 name: "https scheme",
459 input: "https://example.com",
460 want: false,
461 },
462 {
463 name: "file scheme",
464 input: "file:///path/to/file.html",
465 want: false,
466 },
467 {
468 name: "dumb scheme",
469 input: "dumb://home",
470 want: false,
471 },
472 {
473 name: "about scheme",
474 input: "about:blank",
475 want: false,
476 },
477 {
478 name: "data scheme",
479 input: "data:text/plain;base64,SGVsbG8gV29ybGQ==",
480 want: false,
481 },
482 {
483 name: "blob scheme",
484 input: "blob:https://example.com/blob-id",
485 want: false,
486 },
487 {
488 name: "javascript scheme",
489 input: "javascript:alert('hello')",
490 want: false,
491 },
492 {
493 name: "vscode scheme",
494 input: "vscode://open",
495 want: true,
496 },
497 {
498 name: "spotify scheme",
499 input: "spotify:track:123",
500 want: true,
501 },
502 {
503 name: "steam scheme",
504 input: "steam://run/123",
505 want: true,
506 },
507 {
508 name: "uppercase HTTP scheme",
509 input: "HTTP://example.com",
510 want: false,
511 },
512 {
513 name: "uppercase ABOUT scheme",
514 input: "ABOUT:blank",
515 want: false,
516 },
517 {
518 name: "uppercase VSCODE scheme",
519 input: "VSCODE://open",
520 want: true,
521 },
522 {
523 name: "malformed URL",
524 input: "://invalid",
525 want: false,
526 },
527 {
528 name: "no scheme",
529 input: "example.com",
530 want: false,
531 },
532 {
533 name: "scheme only",
534 input: "custom://",
535 want: true,
536 },
537 }
538
539 for _, tt := range tests {
540 t.Run(tt.name, func(t *testing.T) {
541 got := IsExternalScheme(tt.input)
542 if got != tt.want {
543 t.Errorf("IsExternalScheme(%q) = %v, want %v", tt.input, got, tt.want)
544 }
545 })
546 }
547}
548
549func TestExtractOrigin_ValidURI(t *testing.T) {
550 tests := []struct {
551 name string
552 uri string
553 expected string
554 }{
555 {
556 name: "https with port",
557 uri: "https://example.com:8443/path?query=1",
558 expected: "https://example.com:8443",
559 },
560 {
561 name: "https without port",
562 uri: "https://example.com/path",
563 expected: "https://example.com",
564 },
565 {
566 name: "http",
567 uri: "http://localhost:8080/app",
568 expected: "http://localhost:8080",
569 },
570 {
571 name: "uppercase host",
572 uri: "https://EXAMPLE.COM/path",
573 expected: "https://example.com",
574 },
575 {
576 name: "uppercase scheme",
577 uri: "HTTPS://example.com/path",
578 expected: "https://example.com",
579 },
580 {
581 name: "https default port omitted",
582 uri: "https://example.com:443/path",
583 expected: "https://example.com",
584 },
585 {
586 name: "http default port omitted",
587 uri: "http://example.com:80/path",
588 expected: "http://example.com",
589 },
590 {
591 name: "https explicit non-default port kept",
592 uri: "https://example.com:8080/path",
593 expected: "https://example.com:8080",
594 },
595 {
596 name: "mixed case with default port",
597 uri: "HTTPS://EXAMPLE.COM:443/path",
598 expected: "https://example.com",
599 },
600 {
601 name: "ipv6 with explicit port",
602 uri: "https://[::1]:8443/path",
603 expected: "https://[::1]:8443",
604 },
605 }
606
607 for _, tt := range tests {
608 t.Run(tt.name, func(t *testing.T) {
609 origin, err := ExtractOrigin(tt.uri)
610 if err != nil {
611 t.Errorf("ExtractOrigin(%q) returned error: %v", tt.uri, err)
612 return
613 }
614 if origin != tt.expected {
615 t.Errorf("ExtractOrigin(%q) = %q, want %q", tt.uri, origin, tt.expected)
616 }
617 })
618 }
619}
620
621func TestExtractOrigin_InvalidURI(t *testing.T) {
622 tests := []struct {
623 name string
624 uri string
625 }{
626 {name: "empty", uri: ""},
627 {name: "no scheme", uri: "example.com"},
628 {name: "no host", uri: "https://"},
629 }
630
631 for _, tt := range tests {
632 t.Run(tt.name, func(t *testing.T) {
633 origin, err := ExtractOrigin(tt.uri)
634 if err == nil {
635 t.Errorf("ExtractOrigin(%q) should return error, got origin: %q", tt.uri, origin)
636 }
637 })
638 }
639}