package docsite import ( "context" "errors" "io" "net" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "time" ) func TestServeAddrPolicy(t *testing.T) { for _, tc := range []struct { addr string allowRemote bool want string // "" accepts; otherwise an error substring }{ {"127.0.0.1:8088", false, ""}, {"127.0.0.2:0", false, ""}, {"[::1]:8088", false, ""}, {"localhost:8088", false, ""}, {"0.0.0.0:8088", false, "not a loopback address"}, {"[::]:8088", false, "not a loopback address"}, {"192.168.1.10:8088", false, "not a loopback address"}, {"docs.example.com:8088", false, "not a loopback address"}, {":8088", false, "not a loopback address"}, {"0.0.0.0:8088", true, ""}, {"[::]:8088", true, ""}, {"docs.example.com:8088", true, ""}, {"127.0.0.1", false, "invalid --addr"}, {"127.0.0.1", true, "invalid --addr"}, } { err := checkServeAddr(tc.addr, tc.allowRemote) if (tc.want == "") != (err == nil) || (err != nil && !strings.Contains(err.Error(), tc.want)) { t.Errorf("checkServeAddr(%q, %v) = %v, want %q", tc.addr, tc.allowRemote, err, tc.want) } } if DefaultServeAddr != "127.0.0.1:8088" || checkServeAddr(DefaultServeAddr, false) != nil { t.Errorf("DefaultServeAddr %s must be loopback", DefaultServeAddr) } } func TestServeHandlerBranches(t *testing.T) { out := buildTheme(t) if err := os.MkdirAll(filepath.Join(out, "nested", "dir"), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(out, "nested", "index.html"), []byte("nested index"), 0o644); err != nil { t.Fatal(err) } srv := httptest.NewServer(Handler(out)) defer srv.Close() do := func(method, p string) (*http.Response, string) { t.Helper() req, err := http.NewRequest(method, srv.URL+p, nil) if err != nil { t.Fatal(err) } res, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } defer res.Body.Close() body, _ := io.ReadAll(res.Body) return res, string(body) } if res, body := do(http.MethodGet, "/nested/"); res.StatusCode != http.StatusOK || body != "nested index" { t.Errorf("GET /nested/ = %d %q", res.StatusCode, body) } if res, body := do(http.MethodHead, "/index.html"); res.StatusCode != http.StatusOK || body != "" { t.Errorf("HEAD /index.html = %d %q", res.StatusCode, body) } for _, p := range []string{"/nested/dir/", "/.hidden/x", "/assets/.x.css", "/nope"} { res, body := do(http.MethodGet, p) if res.StatusCode != http.StatusNotFound || !strings.Contains(body, "Page not found") || !strings.HasPrefix(res.Header.Get("Content-Type"), "text/html") { t.Errorf("GET %s = %d %q", p, res.StatusCode, res.Header.Get("Content-Type")) } } for _, m := range []string{http.MethodPost, http.MethodPut, http.MethodDelete} { res, _ := do(m, "/index.html") if res.StatusCode != http.StatusMethodNotAllowed || res.Header.Get("Allow") != "GET, HEAD" { t.Errorf("%s = %d Allow=%q", m, res.StatusCode, res.Header.Get("Allow")) } } // Without a 404.html the handler still answers 404. bare := httptest.NewServer(Handler(t.TempDir())) defer bare.Close() res, err := http.Get(bare.URL + "/missing") if err != nil { t.Fatal(err) } res.Body.Close() if res.StatusCode != http.StatusNotFound { t.Errorf("bare 404 = %d", res.StatusCode) } } func TestServeRebuildKeepsLastGoodBuild(t *testing.T) { root := themeTree(t) out := &syncBuffer{} s := &server{opts: Options{Root: root, Commands: fixtureCommands}, tmp: t.TempDir(), out: out} s.opts, _ = s.opts.normalize() if !s.rebuild() { t.Fatalf("first build failed: %s", out.String()) } first := s.dir() if filepath.Base(first) != "build-1" { t.Fatalf("first build dir = %s", first) } // A problem keeps the previous build and says so. index := filepath.Join(root, "docs", "index.md") good, err := os.ReadFile(index) if err != nil { t.Fatal(err) } if err := os.WriteFile(index, append(append([]byte{}, good...), "\nSee `summer no:such`.\n"...), 0o644); err != nil { t.Fatal(err) } if s.rebuild() { t.Fatal("rebuild accepted a planted problem") } if s.dir() != first { t.Fatalf("serving %s after a failed build, want %s", s.dir(), first) } for _, want := range []string{`command: "no:such" is not a summer or application command`, "docs:serve: build failed, still serving the previous version"} { if !strings.Contains(out.String(), want) { t.Errorf("output missing %q:\n%s", want, out.String()) } } if _, err := os.Stat(filepath.Join(s.tmp, "build-2")); !errors.Is(err, os.ErrNotExist) { t.Error("the failed build directory was kept") } // A load error is printed too. site := filepath.Join(root, "docs", "site.yaml") cfg, err := os.ReadFile(site) if err != nil { t.Fatal(err) } if err := os.Remove(site); err != nil { t.Fatal(err) } if s.rebuild() || !strings.Contains(out.String(), "read site config") { t.Fatalf("rebuild without site.yaml: %s", out.String()) } if err := os.WriteFile(site, cfg, 0o644); err != nil { t.Fatal(err) } // Two good builds later the first directory is gone; the one before // the current build is kept for in-flight requests. if err := os.WriteFile(index, good, 0o644); err != nil { t.Fatal(err) } if !s.rebuild() || !s.rebuild() { t.Fatalf("rebuilds failed: %s", out.String()) } if _, err := os.Stat(first); !errors.Is(err, os.ErrNotExist) { t.Errorf("%s survived two newer builds", first) } if _, err := os.Stat(s.stale); err != nil { t.Errorf("the previous build %s was removed early", s.stale) } if _, err := os.Stat(filepath.Join(s.dir(), "index.html")); err != nil { t.Error(err) } } func TestServeStartupFailures(t *testing.T) { // A tree with a problem never starts serving. root := themeTree(t) appendFile(t, root, "docs/index.md", "\n`summer no:such`\n") var out syncBuffer err := Serve(context.Background(), Options{Root: root, Commands: fixtureCommands}, "127.0.0.1:0", false, &out) if err == nil || err.Error() != "docs:serve: build failed" { t.Fatalf("Serve with a problem = %v", err) } if strings.Contains(out.String(), "still serving") || !strings.Contains(out.String(), "no:such") { t.Errorf("output = %s", out.String()) } // A taken address is reported. ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatal(err) } defer ln.Close() err = Serve(context.Background(), Options{Root: themeTree(t), Commands: fixtureCommands}, ln.Addr().String(), false, &out) if err == nil || !strings.HasPrefix(err.Error(), "docs:serve: ") { t.Fatalf("Serve on a taken address = %v", err) } } // TestServeWatchRebuilds edits a page while Serve runs and waits for the // new build, then plants a problem and checks the old build stays up. func TestServeWatchRebuilds(t *testing.T) { root := themeTree(t) ctx, cancel := context.WithCancel(context.Background()) defer cancel() out := &syncBuffer{} done := make(chan error, 1) go func() { done <- Serve(ctx, Options{Root: root, Commands: fixtureCommands}, "127.0.0.1:0", false, out) }() waitFor := func(what string, cond func() bool) { t.Helper() for deadline := time.Now().Add(20 * time.Second); time.Now().Before(deadline); time.Sleep(20 * time.Millisecond) { if cond() { return } } t.Fatalf("timed out waiting for %s; output:\n%s", what, out.String()) } var base string waitFor("the serving line", func() bool { if m := servingLine.FindStringSubmatch(out.String()); m != nil { base = m[1] return true } return false }) get := func(p string) string { t.Helper() res, err := http.Get(base + p) if err != nil { t.Fatal(err) } defer res.Body.Close() b, _ := io.ReadAll(res.Body) return string(b) } // A dot-file change is ignored; a page edit rebuilds. writeFile(t, root, "docs/.scratch", "x") appendFile(t, root, "docs/setup/second.md", "\nFreshly edited.\n") waitFor("a rebuild", func() bool { return strings.Contains(out.String(), "docs:serve: rebuilt") }) waitFor("the edited page", func() bool { return strings.Contains(get("/setup/second.html"), "Freshly edited.") }) // A new page rebuilds and is served. writeFile(t, root, "docs/setup/third.md", page("Third", "setup", 90, "Brand new.\n")) waitFor("the new page", func() bool { return strings.Contains(get("/setup/third.html"), "Brand new.") }) // A planted problem keeps the last good build online. appendFile(t, root, "docs/setup/second.md", "\n`summer no:such`\n") waitFor("the failed rebuild", func() bool { return strings.Contains(out.String(), "still serving the previous version") }) if !strings.Contains(get("/setup/second.html"), "Freshly edited.") { t.Error("the last good build is no longer served") } cancel() select { case err := <-done: if err != nil { t.Fatalf("Serve = %v after cancel", err) } case <-time.After(10 * time.Second): t.Fatal("Serve did not return after cancel") } }