package crawl import ( "context" "net/netip" "net/url" "os" "path/filepath" "strings" "testing" "time" "git.golem15.com/golem15/summercms/modules/compass" ) // TestAllowRequest pins what the headless browser may load: https on the // site host and its www twin, data: and blob:; http only in trusted mode; // other hosts, credentials in the URL and every other scheme refused. func TestAllowRequest(t *testing.T) { hosts := []string{"example.test", "www.example.test"} for _, tc := range []struct { url string trusted bool want bool }{ {"https://example.test/a.js", false, true}, {"https://WWW.example.test/b.css", false, true}, {"https://example.test:8443/x", false, true}, {"data:image/png;base64,AAAA", false, true}, {"blob:https://example.test/uuid", false, true}, {"http://example.test/a.js", false, false}, {"http://example.test/a.js", true, true}, {"https://cdn.other.test/lib.js", false, false}, {"https://example.test.evil.test/x", false, false}, {"https://user:pw@example.test/x", false, false}, {"ws://example.test/socket", false, false}, {"file:///etc/passwd", false, false}, {"javascript:alert(1)", true, false}, {"://bad", false, false}, } { if got := allowRequest(tc.url, hosts, tc.trusted); got != tc.want { t.Errorf("allowRequest(%q, trusted=%v) = %v, want %v", tc.url, tc.trusted, got, tc.want) } } if !allowRequest("https://[::1]/x", []string{"[::1]"}, false) { t.Fatal("a bracketed IPv6 host must match") } } // TestResolverRules pins Chrome's --host-resolver-rules: one MAP per // allowed host, sorted, to its first IPv4 address (else the first one, in // brackets for IPv6), a host without addresses left out, and MAP * // ~NOTFOUND last, since Chrome takes the first matching rule. func TestResolverRules(t *testing.T) { got := resolverRules(map[string][]netip.Addr{ "www.example.test": {netip.MustParseAddr("2001:db8::1")}, "example.test": {netip.MustParseAddr("2001:db8::2"), netip.MustParseAddr("192.0.2.10"), netip.MustParseAddr("192.0.2.11")}, "empty.test": nil, }) want := "MAP example.test 192.0.2.10, MAP www.example.test [2001:db8::1], MAP * ~NOTFOUND" if got != want { t.Fatalf("rules = %q\nwant %q", got, want) } if got := resolverRules(nil); got != "MAP * ~NOTFOUND" { t.Fatalf("no hosts = %q", got) } if !strings.HasSuffix(got, "MAP * ~NOTFOUND") || strings.Count(got, "~NOTFOUND") != 1 { t.Fatal("the catch-all must come once and last") } } // TestNewRenderer pins the comparison's start: no renderer and no error // without a Chrome path (skipped), an error for a relative path, a // missing file, a directory or a file that is not executable (failed). func TestNewRenderer(t *testing.T) { site, _ := url.Parse("https://example.test") cfg := func(path string) *compass.Config { c, err := compass.Open(compass.Options{Dir: t.TempDir()}) if err != nil { t.Fatal(err) } if path != "" { if err := c.Set(ChromePathConfigKey, path); err != nil { t.Fatal(err) } } return c } if r, err := NewRenderer(context.Background(), cfg(""), site, false, "ua"); r != nil || err != nil { t.Fatalf("no path: %v %v, want skipped", r, err) } if r, err := NewRenderer(context.Background(), nil, site, false, "ua"); r != nil || err != nil { t.Fatal("no config: skipped") } dir := t.TempDir() plain := filepath.Join(dir, "chrome.txt") if err := os.WriteFile(plain, []byte("x"), 0o644); err != nil { t.Fatal(err) } for name, path := range map[string]string{"relative": "bin/chrome", "missing": filepath.Join(dir, "nope"), "directory": dir, "not executable": plain} { r, err := NewRenderer(context.Background(), cfg(path), site, false, "ua") if r != nil || err == nil || !strings.Contains(err.Error(), "Chrome path") { t.Errorf("%s: %v %v, want a Chrome path error", name, r, err) } } } // TestRenderMax pins the page cap: the default 200, a positive config // value, and the default for 0 or less. func TestRenderMax(t *testing.T) { if RenderMax(nil) != DefaultRenderMax || DefaultRenderMax != 200 { t.Fatal("default cap") } for v, want := range map[int]int{50: 50, 0: DefaultRenderMax, -1: DefaultRenderMax} { c, _ := compass.Open(compass.Options{Dir: t.TempDir()}) _ = c.Set(RenderMaxConfigKey, v) if got := RenderMax(c); got != want { t.Errorf("RenderMax(%d) = %d, want %d", v, got, want) } } if got := SiteHosts("WWW.Example.test"); len(got) != 2 || got[0] != "www.example.test" || got[1] != "example.test" { t.Fatalf("SiteHosts = %v", got) } if got := SiteHosts("example.test"); got[1] != "www.example.test" || SiteHosts(" ") != nil { t.Fatalf("SiteHosts = %v", got) } } // TestRenderTabTimeout pins L3 WR-06: a tab that does not open answers an // error within TabOpenTimeout (lowered for the test) plus a margin, so the // crawl's render lock is released; the next Render is not held up by the // first one's hung tab. func TestRenderTabTimeout(t *testing.T) { saved := TabOpenTimeout TabOpenTimeout = 50 * time.Millisecond t.Cleanup(func() { TabOpenTimeout = saved }) hung := make(chan struct{}) t.Cleanup(func() { close(hung) }) r := &chromeRenderer{ allowed: []string{"example.test"}, browserCtx: context.Background(), newTab: func(parent context.Context) (context.Context, context.CancelFunc) { return context.WithCancel(parent) }, // A hung Chrome: opening the tab never answers, even when the tab // is closed. startTab: func(context.Context) error { <-hung return nil }, } for i := range 2 { start := time.Now() _, err := r.Render(t.Context(), "https://example.test/a") if err == nil || !strings.Contains(err.Error(), "did not open a tab within 50ms") { t.Fatalf("Render %d: %v, want the tab timeout", i, err) } if d := time.Since(start); d > time.Second { t.Fatalf("Render %d returned after %v", i, d) } } TabOpenTimeout = 5 * time.Second if got := seconds(TabOpenTimeout); got != "5 s" { t.Fatalf("seconds = %q", got) } }