261101.6 · d81d8f77dde7b2f932e94d374c52339ffce44bfc
Tests follow the code they cover
server_test.go and web_test.go kept their tests when server.go and web.go split. Tests now sit in the test file of the code they drive, and the shared helpers stay in the two original files.
Declarations moved unchanged, as a sorted comparison of every line outside the import blocks confirms. git blame -C -C traces them.
reading declarations
@@ -0,0 +1,26 @@+package main++import (+ "regexp"+ "strings"+ "testing"+)++// The build stamp shows only with --debug, and never on a share. It names+// the commit and the render time.+func TestBuildStamp(t *testing.T) {+ s, tr, _ := serveTree(t)+ defer func() { debugOn = false }()+ for _, c := range []struct{ show, shared, want bool }{+ {false, false, false}, {true, false, true}, {true, true, false},+ } {+ debugOn, tr.shared = c.show, c.shared+ body := get(s.handler(), "/a/").Body.String()+ if got := strings.Contains(body, `<p class="built" data-built="`); got != c.want {+ t.Errorf("stamp=%v shared=%v: shown=%v", c.show, c.shared, got)+ }+ if c.want && !regexp.MustCompile(`s ago, from [^<]+, rendered in [0-9.]+ ms</p>`).MatchString(body) {+ t.Errorf("stamp lacks commit or render time: %s", body)+ }+ }+}
@@ -0,0 +1,111 @@+package main++import (+ "net/http"+ "net/http/httptest"+ "net/url"+ "os/exec"+ "strings"+ "testing"+)++// A link to a repo on a forge, or a file or directory in one, maps to its+// path on the web address; other pages of a forge, and other hosts, map+// to nothing.+func TestWebPath(t *testing.T) {+ for in, want := range map[string]string{+ "https://github.com/junegunn/fzf": "/github.com/junegunn/fzf/",+ "https://www.github.com/junegunn/fzf.git": "/github.com/junegunn/fzf/",+ "https://github.com/junegunn/fzf/blob/master/README.md": "/github.com/junegunn/fzf/README.md",+ "https://github.com/junegunn/fzf/tree/v0.50/shell": "/github.com/junegunn/fzf/shell/",+ "https://github.com/junegunn/fzf/tree/master": "/github.com/junegunn/fzf/",+ "https://codeberg.org/forgejo/forgejo/src/branch/main/go.mod": "/codeberg.org/forgejo/forgejo/go.mod",+ "https://gitlab.com/a/b/c/-/blob/main/x.md": "/gitlab.com/a/b/c/x.md",+ "https://gitlab.com/a/b": "/gitlab.com/a/b/",+ "https://bitbucket.org/a/b/src/main/x.md": "/bitbucket.org/a/b/x.md",+ "https://github.com/junegunn/fzf/blob/26ff936/BUILD.md": "/github.com/junegunn/fzf@26ff936/BUILD.md",+ "https://github.com/junegunn/fzf/tree/26ff936df038b6a62ec42cf18db27b2ea2bb8ec3": "/github.com/junegunn/fzf@26ff936df038b6a62ec42cf18db27b2ea2bb8ec3/",+ "https://github.com/junegunn/fzf/blob/cafe/x.md": "/github.com/junegunn/fzf/x.md",+ "https://github.com/junegunn/fzf/raw/master/x.png": "/github.com/junegunn/fzf/x.png",+ "https://raw.githubusercontent.com/rivo/uniseg/master/LICENSE.txt": "/github.com/rivo/uniseg/LICENSE.txt",+ "https://raw.githubusercontent.com/rivo/uniseg/refs/heads/main/a/b.go": "/github.com/rivo/uniseg/a/b.go",+ "https://raw.githubusercontent.com/rivo/uniseg/0123abcd/LICENSE": "/github.com/rivo/uniseg@0123abcd/LICENSE",+ "https://raw.githubusercontent.com/rivo/uniseg": "",+ "https://raw.githubusercontent.com/rivo/uniseg/master": "",+ "https://codeberg.org/a/b/raw/branch/main/x.md": "/codeberg.org/a/b/x.md",+ "https://gitlab.com/a/b/-/raw/main/x.md": "/gitlab.com/a/b/x.md",+ "https://github.com/junegunn/fzf/issues/3": "",+ "https://github.com/junegunn": "",+ "https://github.com/sponsors/junegunn": "",+ "https://example.com/a/b": "",+ "ftp://github.com/a/b": "",+ "https://github.com/a/../b": "",+ } {+ u, err := url.Parse(in)+ if err != nil {+ t.Fatal(err)+ }+ if _, got := webPath(u); got != want {+ t.Errorf("%s: %q, want %q", in, got, want)+ }+ }+}++// A URL pasted whole after the address, or a forge's view of a file,+// redirects to the path that reads it. The mux has folded the URL's //+// into / before.+func TestWebPastedURL(t *testing.T) {+ w := testWeb(t, defaultDisk)+ for in, want := range map[string]string{+ "/https:/git.suckless.org/dwm/": "/git.suckless.org/dwm",+ "/https:/git.suckless.org/dwm": "/git.suckless.org/dwm",+ "/http:/github.com/junegunn/fzf": "/github.com/junegunn/fzf/",+ "/https:/github.com/junegunn/fzf/blob/master/main.go": "/github.com/junegunn/fzf/main.go",+ "/github.com/junegunn/fzf/tree/master/shell": "/github.com/junegunn/fzf/shell/",+ "/raw.githubusercontent.com/junegunn/fzf/master/x.md": "/github.com/junegunn/fzf/x.md",+ } {+ rec := webGet(w, in)+ if rec.Code != http.StatusMovedPermanently || rec.Header().Get("Location") != want {+ t.Errorf("%s: %d %s, want %s", in, rec.Code, rec.Header().Get("Location"), want)+ }+ }+ for _, in := range []string{"/github.com/junegunn", "/github.com/junegunn/fzf/README.md"} {+ if rec := webGet(w, in); rec.Code == http.StatusMovedPermanently {+ t.Errorf("%s redirects to %s", in, rec.Header().Get("Location"))+ }+ }+}++// A page of a repo on the web address links its forge's own permanent+// URL: a file or directory at the commit shown, or a commit.+func TestForgeLink(t *testing.T) {+ needGit(t)+ _, tr, dir := serveTree(t)+ if out, err := exec.Command("git", "init", "-q", dir).CombinedOutput(); err != nil {+ t.Fatalf("git init: %v\n%s", err, out)+ }+ commitAll(t, dir, "one")+ out, _ := exec.Command("git", "-C", dir, "rev-parse", "HEAD").Output()+ head := strings.TrimSpace(string(out))+ tr.web = &webRepo{key: "github.com/junegunn/fzf"}+ for _, c := range []struct{ path, kind, want string }{+ {"/a/b.go", "code", "https://github.com/junegunn/fzf/blob/" + head + "/a/b.go"},+ {"/a/", "dir", "https://github.com/junegunn/fzf/tree/" + head + "/a"},+ {"/", "dir", "https://github.com/junegunn/fzf/tree/" + head},+ {"/?commit=" + head, "commit", "https://github.com/junegunn/fzf/commit/" + head},+ {"/?log", "log", ""},+ } {+ got, lines := tr.forgeLink(httptest.NewRequest("GET", c.path, nil), c.kind)+ if got != c.want || c.want != "" && lines != "github" {+ t.Errorf("%s: %q %q, want %q", c.path, got, lines, c.want)+ }+ }+ tr.web.key = "gitlab.com/a/b/c"+ if got, lines := tr.forgeLink(httptest.NewRequest("GET", "/a/b.go", nil), "code"); got != "https://gitlab.com/a/b/c/-/blob/"+head+"/a/b.go" || lines != "gitlab" {+ t.Errorf("gitlab: %q %q", got, lines)+ }+ tr.web.key = "git.suckless.org/dwm"+ if got, _ := tr.forgeLink(httptest.NewRequest("GET", "/a/b.go", nil), "code"); got != "" {+ t.Errorf("an unknown forge: %q", got)+ }+}
@@ -0,0 +1,37 @@+package main++import (+ "net/http"+ "os"+ "path/filepath"+ "testing"+)++func TestForeignHostRejected(t *testing.T) {+ s, _, _ := serveTree(t)+ if rec := get(s.handler(), "/", "Host", "evil.example:7417"); rec.Code != http.StatusMisdirectedRequest {+ t.Fatalf("status %d, want 421", rec.Code)+ }+ for _, host := range []string{"localhost:9", "[::1]:7417", "127.0.0.1"} {+ if rec := get(s.handler(), "/bin.dat", "Host", host); rec.Code != http.StatusOK {+ t.Errorf("host %s: status %d, want 200", host, rec.Code)+ }+ }+}++func TestLocalHost(t *testing.T) {+ hosts := filepath.Join(t.TempDir(), "hosts")+ os.WriteFile(hosts, []byte("127.31.1.1 docs docs.local # "+progName+"\n192.168.1.5 nas\n# 127.0.0.1 commented\n"), 0o644)+ old := hostsFile+ hostsFile = hosts+ t.Cleanup(func() { hostsFile = old })+ for host, want := range map[string]bool{+ "127.31.1.1": true, "127.31.200.1": true, "127.0.0.1": true, "::1": true, "localhost": true,+ "docs": true, "DOCS.local": true,+ "nas": false, "commented": false, "evil.example": false, "10.0.0.1": false,+ } {+ if got := localHost(host); got != want {+ t.Errorf("%s: %v, want %v", host, got, want)+ }+ }+}
@@ -0,0 +1,137 @@+package main++import (+ "net"+ "net/http"+ "net/http/httptest"+ "net/netip"+ "os"+ "path/filepath"+ "strings"+ "testing"+ "time"+)++// Delete lock on the index returns to the index while other trees remain;+// the last tree's button ends on the stopped page.+func TestIndexDeleteReturns(t *testing.T) {+ s, tr, dir := serveTree(t)+ tr.prefix, tr.label = "/notes/", "~/notes"+ addTree(t, s, filepath.Join(dir, "a"), "/other/", "~/other")+ var got []*tree+ s.stop = func(ts []*tree, _ string) {+ got = ts+ for _, t := range ts {+ s.remove(t)+ }+ }+ post := func(form string) *httptest.ResponseRecorder {+ req := httptest.NewRequest("POST", ownPath+"stop", strings.NewReader(form))+ req.Host = "127.31.1.1:8080"+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")+ rec := httptest.NewRecorder()+ s.handler().ServeHTTP(rec, req)+ return rec+ }+ rec := post("tree=%2Fnotes%2F&from=index")+ if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/" || len(got) != 1 || got[0] != tr {+ t.Errorf("first delete: status %d location %q stopped %d", rec.Code, rec.Header().Get("Location"), len(got))+ }+ rec = post("tree=%2Fother%2F&from=index")+ if rec.Code != http.StatusGone || !strings.Contains(rec.Body.String(), "<th>stopped</th><td><span class=\"seg\">~/</span><wbr><span class=\"seg\">other</span></td>") ||+ !strings.Contains(rec.Body.String(), "<th>server</th><td>stopped; close this tab</td>") || !strings.Contains(rec.Body.String(), `<span>410</span>`) {+ t.Errorf("last delete: status %d: %s", rec.Code, rec.Body.String())+ }+ // The server closes as this page leaves, so the page carries its style.+ if body := rec.Body.String(); !strings.Contains(body, "<style>") || strings.Contains(body, ownPath+"style.") {+ t.Errorf("stopped page links its stylesheet instead of carrying it")+ }+}++// The index at / lists every served tree with a button that deletes its+// lock, and offers to stop them all.+func TestIndexListsTrees(t *testing.T) {+ s, tr, dir := serveTree(t)+ tr.prefix, tr.label = "/notes/", "~/notes"+ addTree(t, s, filepath.Join(dir, "a"), "/other/", "~/other")+ rec := get(s.handler(), "/")+ body := rec.Body.String()+ if rec.Code != http.StatusOK || !strings.Contains(body, `<a href="/notes/" title=""><span class="seg">~/</span><wbr><span class="seg">notes</span></a>`) || !strings.Contains(body, `<a href="/other/" title=""><span class="seg">~/</span><wbr><span class="seg">other</span></a>`) {+ t.Errorf("status %d, index lacks the trees: %s", rec.Code, body)+ }+ if !strings.Contains(body, `<button class="kill">Kill server</button>`) {+ t.Errorf("index lacks the button that stops every tree: %s", body)+ }+ if !strings.Contains(body, `name="tree" value="/notes/"><input type="hidden" name="from" value="index"><button>Delete lock</button>`) {+ t.Errorf("index lacks a Delete lock button per tree: %s", body)+ }+ if rec := get(s.handler(), "/elsewhere/"); rec.Code != http.StatusNotFound || !strings.Contains(rec.Body.String(), `href="/notes/"`) {+ t.Errorf("path outside every tree: status %d, want 404 with the index", rec.Code)+ }+}++// The index lists every server in the order of their addresses, each+// with its binary, so it reads the same from any of them. Only this+// server's trees carry buttons; another's address links to its index.+// The stale warning's command names the binary by path when PATH leads+// to another binary.+func TestIndexListsEveryServer(t *testing.T) {+ ep := testEndpoint(t)+ ln, err := net.Listen("tcp", "127.31.250.1:0")+ if err != nil {+ t.Skip(err)+ }+ other := netip.MustParseAddrPort(ln.Addr().String())+ ln.Close()+ dir := t.TempDir()+ _, served := start(t, config{dir: dir, ep: other})+ defer func() {+ os.Remove(filepath.Join(dir, lockName(other)))+ <-served+ }()++ s, tr, _ := serveTree(t)+ tr.prefix, tr.label = "/notes/", "~/notes"+ s.about = about{ep: ep, started: time.Now(), exe: "/opt/other/" + progName}+ body := get(s.handler(), "/").Body.String()+ own := "<h2 class=\"own\">" + ep.String() + ", this page</h2>\n<p class=\"own\"><code>" + segments(s.about.exe) + "</code>"+ theirs := "<h2 class=\"own\"><a href=\"" + origin(other) + "/\">" + other.String() + "</a></h2>"+ i, j := strings.Index(body, own), strings.Index(body, theirs)+ if i < 0 || j < 0 {+ t.Fatalf("index lacks a server: %s", body)+ }+ if (ep.Compare(other) < 0) != (i < j) {+ t.Errorf("servers out of address order: %s", body)+ }+ if strings.Count(body, "<button>Delete lock</button>") != 1 {+ t.Errorf("want one Delete lock button, for this server's tree: %s", body)+ }+ if !strings.Contains(body, `<a href="`+origin(other)+dir+`/" title="`+filepath.Join(dir, lockName(other))+`">`+segments(dir)+"</a>") {+ t.Errorf("index lacks the other server's tree: %s", body)+ }+ if got, want := s.about.restartCmd(), s.about.exe+" --restart"; got != want {+ t.Errorf("restart command %q, want %q", got, want)+ }+}++// A server's build and start show on the index only with --debug; they+// serve debugging alone.+func TestIndexTimesOnlyWithStamp(t *testing.T) {+ defer func() { debugOn = false }()+ b := indexServer{binary: "/usr/bin/" + progName, built: time.Now(), commit: "1f2241d", since: time.Now()}+ for _, show := range []bool{false, true} {+ debugOn = show+ got := b.account()+ if has := strings.Contains(got, "built ") && strings.Contains(got, "up since "); has != show {+ t.Errorf("stamp %t: %s", show, got)+ }+ if !strings.Contains(got, progName) {+ t.Errorf("stamp %t: the binary is missing: %s", show, got)+ }+ }+ debugOn = false+ s, _, _ := serveTree(t)+ if body := get(s.handler(), "/").Body.String(); strings.Contains(body, "since ") || strings.Contains(body, "class=\"since") {+ t.Errorf("index without --debug shows times: %s", body)+ }+}
@@ -0,0 +1,32 @@+package main++import (+ "net/http"+ "strings"+ "testing"+)++// A path breaks after its slashes, and a URL keeps // with its scheme.+func TestSegments(t *testing.T) {+ for in, want := range map[string]string{+ "~/a b/c": `<span class="seg">~/</span><wbr><span class="seg">a b/</span><wbr><span class="seg">c</span>`,+ "/tmp/x/": `<span class="seg">/</span><wbr><span class="seg">tmp/</span><wbr><span class="seg">x/</span>`,+ "http://h:80/a/": `<span class="seg">http://</span><wbr><span class="seg">h:80/</span><wbr><span class="seg">a/</span>`,+ "<x>/y": `<span class="seg"><x>/</span><wbr><span class="seg">y</span>`,+ } {+ if got := segments(in); got != want {+ t.Errorf("segments(%q) = %s, want %s", in, got, want)+ }+ }+}++// A missing path's 404 turns a coin above its message, hidden from screen+// readers. A refusal says why without one; TestLinksRefusedSayWhy checks.+func TestNotFoundCoin(t *testing.T) {+ s, _, _ := serveTree(t)+ rec := get(s.handler(), "/nowhere.md")+ body := rec.Body.String()+ if rec.Code != http.StatusNotFound || !strings.Contains(body, `<p class="coin" aria-hidden="true">`) || !strings.Contains(body, `<th>not found</th><td><span class="seg">http://</span><wbr><span class="seg">127.31.1.1:8080/</span><wbr><span class="seg">nowhere.md</span></td>`) {+ t.Errorf("status %d, want 404 with the coin and the message: %s", rec.Code, body)+ }+}
@@ -213,3 +213,24 @@ func TestWebLinksTurnAround(t *testing.T) { t.Errorf("%d marks, want 3: %s", n, s) } }++// Lines of a doc join into one paragraph, as standard Markdown has them,+// and a dash after a line starts a list.+func TestLinesJoin(t *testing.T) {+ s, _, _ := serveTree(t)+ body := get(s.handler(), "/index.md").Body.String()+ if !strings.Contains(body, "<p>line one\nline two</p>") {+ t.Errorf("lines not joined into one paragraph: %s", body)+ }+ if !strings.Contains(body, "<ul>\n<li>item</li>") {+ t.Errorf("a dash after a line does not start a list: %s", body)+ }+}++func TestBareURLIsLink(t *testing.T) {+ s, _, _ := serveTree(t)+ body := get(s.handler(), "/index.md").Body.String()+ if !strings.Contains(body, `<a href="https://example.com/x"`) {+ t.Errorf("bare URL not linked: %s", body)+ }+}
@@ -1,10 +1,8 @@ package main import (- "net" "net/http" "net/http/httptest"- "net/netip" "os" "path/filepath" "regexp"@@ -69,18 +67,6 @@ func do(h http.Handler, method, path string, hdr ...string) *httptest.ResponseRe return rec } -func TestForeignHostRejected(t *testing.T) {- s, _, _ := serveTree(t)- if rec := get(s.handler(), "/", "Host", "evil.example:7417"); rec.Code != http.StatusMisdirectedRequest {- t.Fatalf("status %d, want 421", rec.Code)- }- for _, host := range []string{"localhost:9", "[::1]:7417", "127.0.0.1"} {- if rec := get(s.handler(), "/bin.dat", "Host", host); rec.Code != http.StatusOK {- t.Errorf("host %s: status %d, want 200", host, rec.Code)- }- }-}- func TestEveryResponseForbidsScripts(t *testing.T) { s, _, _ := serveTree(t) rec := get(s.handler(), "/a/page.html?raw")@@ -105,77 +91,6 @@ func TestCacheHeaders(t *testing.T) { } } -func TestDirectoryRedirectsToSlash(t *testing.T) {- s, _, _ := serveTree(t)- rec := get(s.handler(), "/a")- if rec.Code != http.StatusMovedPermanently || rec.Header().Get("Location") != "/a/" {- t.Fatalf("status %d location %q, want 301 to /a/", rec.Code, rec.Header().Get("Location"))- }-}--func TestListingSkipsLockAndReadme(t *testing.T) {- s, _, _ := serveTree(t)- body := get(s.handler(), "/").Body.String()- if strings.Contains(body, "8080.lock") {- t.Error("listing shows the lockfile")- }- if !strings.Contains(body, `href="a/"`) || !strings.Contains(body, `href="index.md"`) {- t.Errorf("listing lacks entries: %s", body)- }- if body := get(s.handler(), "/a/").Body.String(); strings.Contains(body, "Readme of a") {- t.Errorf("README rendered below listing: %s", body)- }-}--// Lines of a doc join into one paragraph, as standard Markdown has them,-// and a dash after a line starts a list.-func TestLinesJoin(t *testing.T) {- s, _, _ := serveTree(t)- body := get(s.handler(), "/index.md").Body.String()- if !strings.Contains(body, "<p>line one\nline two</p>") {- t.Errorf("lines not joined into one paragraph: %s", body)- }- if !strings.Contains(body, "<ul>\n<li>item</li>") {- t.Errorf("a dash after a line does not start a list: %s", body)- }-}--func TestBareURLIsLink(t *testing.T) {- s, _, _ := serveTree(t)- body := get(s.handler(), "/index.md").Body.String()- if !strings.Contains(body, `<a href="https://example.com/x"`) {- t.Errorf("bare URL not linked: %s", body)- }-}--func TestCodePageIsHighlighted(t *testing.T) {- s, _, _ := serveTree(t)- body := get(s.handler(), "/a/b.go").Body.String()- if !strings.Contains(body, `class="sourceCode`) || !strings.Contains(body, `<span id="L3"><a href="#L3">`) {- t.Errorf("no highlighted code with GitHub line anchors: %s", body)- }- if !strings.Contains(body, `<span class="kw">package</span>`) {- t.Errorf("Go keyword not classed as pandoc does: %s", body)- }-}--func TestBinaryGoesRaw(t *testing.T) {- s, _, _ := serveTree(t)- rec := get(s.handler(), "/bin.dat")- if ct := rec.Header().Get("Content-Type"); strings.HasPrefix(ct, "text/") {- t.Errorf("binary served as %q", ct)- }-}--func TestNoEscapeFromRoot(t *testing.T) {- s, _, _ := serveTree(t)- for _, p := range []string{"/../../etc/passwd", "/escape/passwd", "/escape/"} {- if rec := get(s.handler(), p); rec.Code == http.StatusOK {- t.Errorf("%s: status 200, want refusal", p)- }- }-}- func TestStopRefusesOtherSites(t *testing.T) { s, _, _ := serveTree(t) stopped := make(chan struct{}, 1)@@ -209,42 +124,6 @@ func TestStopRefusesOtherSites(t *testing.T) { } } -// Delete lock on the index returns to the index while other trees remain;-// the last tree's button ends on the stopped page.-func TestIndexDeleteReturns(t *testing.T) {- s, tr, dir := serveTree(t)- tr.prefix, tr.label = "/notes/", "~/notes"- addTree(t, s, filepath.Join(dir, "a"), "/other/", "~/other")- var got []*tree- s.stop = func(ts []*tree, _ string) {- got = ts- for _, t := range ts {- s.remove(t)- }- }- post := func(form string) *httptest.ResponseRecorder {- req := httptest.NewRequest("POST", ownPath+"stop", strings.NewReader(form))- req.Host = "127.31.1.1:8080"- req.Header.Set("Content-Type", "application/x-www-form-urlencoded")- rec := httptest.NewRecorder()- s.handler().ServeHTTP(rec, req)- return rec- }- rec := post("tree=%2Fnotes%2F&from=index")- if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/" || len(got) != 1 || got[0] != tr {- t.Errorf("first delete: status %d location %q stopped %d", rec.Code, rec.Header().Get("Location"), len(got))- }- rec = post("tree=%2Fother%2F&from=index")- if rec.Code != http.StatusGone || !strings.Contains(rec.Body.String(), "<th>stopped</th><td><span class=\"seg\">~/</span><wbr><span class=\"seg\">other</span></td>") ||- !strings.Contains(rec.Body.String(), "<th>server</th><td>stopped; close this tab</td>") || !strings.Contains(rec.Body.String(), `<span>410</span>`) {- t.Errorf("last delete: status %d: %s", rec.Code, rec.Body.String())- }- // The server closes as this page leaves, so the page carries its style.- if body := rec.Body.String(); !strings.Contains(body, "<style>") || strings.Contains(body, ownPath+"style.") {- t.Errorf("stopped page links its stylesheet instead of carrying it")- }-}- // The fonts come from www, cached for a year: every file the stylesheet // names, and nothing else from the font directory. func TestFonts(t *testing.T) {@@ -303,42 +182,6 @@ func TestPrefixedTree(t *testing.T) { } } -// The index at / lists every served tree with a button that deletes its-// lock, and offers to stop them all.-func TestIndexListsTrees(t *testing.T) {- s, tr, dir := serveTree(t)- tr.prefix, tr.label = "/notes/", "~/notes"- addTree(t, s, filepath.Join(dir, "a"), "/other/", "~/other")- rec := get(s.handler(), "/")- body := rec.Body.String()- if rec.Code != http.StatusOK || !strings.Contains(body, `<a href="/notes/" title=""><span class="seg">~/</span><wbr><span class="seg">notes</span></a>`) || !strings.Contains(body, `<a href="/other/" title=""><span class="seg">~/</span><wbr><span class="seg">other</span></a>`) {- t.Errorf("status %d, index lacks the trees: %s", rec.Code, body)- }- if !strings.Contains(body, `<button class="kill">Kill server</button>`) {- t.Errorf("index lacks the button that stops every tree: %s", body)- }- if !strings.Contains(body, `name="tree" value="/notes/"><input type="hidden" name="from" value="index"><button>Delete lock</button>`) {- t.Errorf("index lacks a Delete lock button per tree: %s", body)- }- if rec := get(s.handler(), "/elsewhere/"); rec.Code != http.StatusNotFound || !strings.Contains(rec.Body.String(), `href="/notes/"`) {- t.Errorf("path outside every tree: status %d, want 404 with the index", rec.Code)- }-}--// A path breaks after its slashes, and a URL keeps // with its scheme.-func TestSegments(t *testing.T) {- for in, want := range map[string]string{- "~/a b/c": `<span class="seg">~/</span><wbr><span class="seg">a b/</span><wbr><span class="seg">c</span>`,- "/tmp/x/": `<span class="seg">/</span><wbr><span class="seg">tmp/</span><wbr><span class="seg">x/</span>`,- "http://h:80/a/": `<span class="seg">http://</span><wbr><span class="seg">h:80/</span><wbr><span class="seg">a/</span>`,- "<x>/y": `<span class="seg"><x>/</span><wbr><span class="seg">y</span>`,- } {- if got := segments(in); got != want {- t.Errorf("segments(%q) = %s, want %s", in, got, want)- }- }-}- // A tree inside another answers for its own paths. func TestNestedTreeWins(t *testing.T) { s, _, dir := serveTree(t)@@ -375,42 +218,6 @@ func TestStopWithdrawsOneTree(t *testing.T) { } } -func TestLocalHost(t *testing.T) {- hosts := filepath.Join(t.TempDir(), "hosts")- os.WriteFile(hosts, []byte("127.31.1.1 docs docs.local # "+progName+"\n192.168.1.5 nas\n# 127.0.0.1 commented\n"), 0o644)- old := hostsFile- hostsFile = hosts- t.Cleanup(func() { hostsFile = old })- for host, want := range map[string]bool{- "127.31.1.1": true, "127.31.200.1": true, "127.0.0.1": true, "::1": true, "localhost": true,- "docs": true, "DOCS.local": true,- "nas": false, "commented": false, "evil.example": false, "10.0.0.1": false,- } {- if got := localHost(host); got != want {- t.Errorf("%s: %v, want %v", host, got, want)- }- }-}--// The build stamp shows only with --debug, and never on a share. It names-// the commit and the render time.-func TestBuildStamp(t *testing.T) {- s, tr, _ := serveTree(t)- defer func() { debugOn = false }()- for _, c := range []struct{ show, shared, want bool }{- {false, false, false}, {true, false, true}, {true, true, false},- } {- debugOn, tr.shared = c.show, c.shared- body := get(s.handler(), "/a/").Body.String()- if got := strings.Contains(body, `<p class="built" data-built="`); got != c.want {- t.Errorf("stamp=%v shared=%v: shown=%v", c.show, c.shared, got)- }- if c.want && !regexp.MustCompile(`s ago, from [^<]+, rendered in [0-9.]+ ms</p>`).MatchString(body) {- t.Errorf("stamp lacks commit or render time: %s", body)- }- }-}- // The stylesheet's URL names its content, so the browser keeps it for a // year, and every page links that URL. func TestStylesheetCached(t *testing.T) {@@ -427,80 +234,3 @@ func TestStylesheetCached(t *testing.T) { t.Errorf("page does not link %s: %s", styleURL, body) } }--// The index lists every server in the order of their addresses, each-// with its binary, so it reads the same from any of them. Only this-// server's trees carry buttons; another's address links to its index.-// The stale warning's command names the binary by path when PATH leads-// to another binary.-func TestIndexListsEveryServer(t *testing.T) {- ep := testEndpoint(t)- ln, err := net.Listen("tcp", "127.31.250.1:0")- if err != nil {- t.Skip(err)- }- other := netip.MustParseAddrPort(ln.Addr().String())- ln.Close()- dir := t.TempDir()- _, served := start(t, config{dir: dir, ep: other})- defer func() {- os.Remove(filepath.Join(dir, lockName(other)))- <-served- }()-- s, tr, _ := serveTree(t)- tr.prefix, tr.label = "/notes/", "~/notes"- s.about = about{ep: ep, started: time.Now(), exe: "/opt/other/" + progName}- body := get(s.handler(), "/").Body.String()- own := "<h2 class=\"own\">" + ep.String() + ", this page</h2>\n<p class=\"own\"><code>" + segments(s.about.exe) + "</code>"- theirs := "<h2 class=\"own\"><a href=\"" + origin(other) + "/\">" + other.String() + "</a></h2>"- i, j := strings.Index(body, own), strings.Index(body, theirs)- if i < 0 || j < 0 {- t.Fatalf("index lacks a server: %s", body)- }- if (ep.Compare(other) < 0) != (i < j) {- t.Errorf("servers out of address order: %s", body)- }- if strings.Count(body, "<button>Delete lock</button>") != 1 {- t.Errorf("want one Delete lock button, for this server's tree: %s", body)- }- if !strings.Contains(body, `<a href="`+origin(other)+dir+`/" title="`+filepath.Join(dir, lockName(other))+`">`+segments(dir)+"</a>") {- t.Errorf("index lacks the other server's tree: %s", body)- }- if got, want := s.about.restartCmd(), s.about.exe+" --restart"; got != want {- t.Errorf("restart command %q, want %q", got, want)- }-}--// A missing path's 404 turns a coin above its message, hidden from screen-// readers. A refusal says why without one; TestLinksRefusedSayWhy checks.-func TestNotFoundCoin(t *testing.T) {- s, _, _ := serveTree(t)- rec := get(s.handler(), "/nowhere.md")- body := rec.Body.String()- if rec.Code != http.StatusNotFound || !strings.Contains(body, `<p class="coin" aria-hidden="true">`) || !strings.Contains(body, `<th>not found</th><td><span class="seg">http://</span><wbr><span class="seg">127.31.1.1:8080/</span><wbr><span class="seg">nowhere.md</span></td>`) {- t.Errorf("status %d, want 404 with the coin and the message: %s", rec.Code, body)- }-}--// A server's build and start show on the index only with --debug; they-// serve debugging alone.-func TestIndexTimesOnlyWithStamp(t *testing.T) {- defer func() { debugOn = false }()- b := indexServer{binary: "/usr/bin/" + progName, built: time.Now(), commit: "1f2241d", since: time.Now()}- for _, show := range []bool{false, true} {- debugOn = show- got := b.account()- if has := strings.Contains(got, "built ") && strings.Contains(got, "up since "); has != show {- t.Errorf("stamp %t: %s", show, got)- }- if !strings.Contains(got, progName) {- t.Errorf("stamp %t: the binary is missing: %s", show, got)- }- }- debugOn = false- s, _, _ := serveTree(t)- if body := get(s.handler(), "/").Body.String(); strings.Contains(body, "since ") || strings.Contains(body, "class=\"since") {- t.Errorf("index without --debug shows times: %s", body)- }-}
@@ -0,0 +1,57 @@+package main++import (+ "net/http"+ "strings"+ "testing"+)++func TestDirectoryRedirectsToSlash(t *testing.T) {+ s, _, _ := serveTree(t)+ rec := get(s.handler(), "/a")+ if rec.Code != http.StatusMovedPermanently || rec.Header().Get("Location") != "/a/" {+ t.Fatalf("status %d location %q, want 301 to /a/", rec.Code, rec.Header().Get("Location"))+ }+}++func TestListingSkipsLockAndReadme(t *testing.T) {+ s, _, _ := serveTree(t)+ body := get(s.handler(), "/").Body.String()+ if strings.Contains(body, "8080.lock") {+ t.Error("listing shows the lockfile")+ }+ if !strings.Contains(body, `href="a/"`) || !strings.Contains(body, `href="index.md"`) {+ t.Errorf("listing lacks entries: %s", body)+ }+ if body := get(s.handler(), "/a/").Body.String(); strings.Contains(body, "Readme of a") {+ t.Errorf("README rendered below listing: %s", body)+ }+}++func TestCodePageIsHighlighted(t *testing.T) {+ s, _, _ := serveTree(t)+ body := get(s.handler(), "/a/b.go").Body.String()+ if !strings.Contains(body, `class="sourceCode`) || !strings.Contains(body, `<span id="L3"><a href="#L3">`) {+ t.Errorf("no highlighted code with GitHub line anchors: %s", body)+ }+ if !strings.Contains(body, `<span class="kw">package</span>`) {+ t.Errorf("Go keyword not classed as pandoc does: %s", body)+ }+}++func TestBinaryGoesRaw(t *testing.T) {+ s, _, _ := serveTree(t)+ rec := get(s.handler(), "/bin.dat")+ if ct := rec.Header().Get("Content-Type"); strings.HasPrefix(ct, "text/") {+ t.Errorf("binary served as %q", ct)+ }+}++func TestNoEscapeFromRoot(t *testing.T) {+ s, _, _ := serveTree(t)+ for _, p := range []string{"/../../etc/passwd", "/escape/passwd", "/escape/"} {+ if rec := get(s.handler(), p); rec.Code == http.StatusOK {+ t.Errorf("%s: status 200, want refusal", p)+ }+ }+}
@@ -9,14 +9,12 @@ import ( "net/http/cgi" "net/http/httptest" "net/netip"- "net/url" "os" "os/exec" "path/filepath" "slices" "strings" "sync/atomic"- "syscall" "testing" "time" )@@ -235,43 +233,6 @@ func TestWebVersionFetchesADirectoryAtOnce(t *testing.T) { } } -// A visit after webRefresh fetches in the background, and the page moves-// to the new commit.-func TestWebRefreshes(t *testing.T) {- f := newForge(t, nil)- f.repo(t, "acct/r", map[string]string{"README.md": "one\n"})- w := testWeb(t, defaultDisk)- path := "/" + f.host + "/acct/r/README.md"- if rec := read(t, w, path); !strings.Contains(rec.Body.String(), "one") {- t.Fatalf("first read: %d\n%s", rec.Code, rec.Body)- }- head := f.repo(t, "acct/r", map[string]string{"README.md": "two\n"})- if body := webGet(w, path).Body.String(); !strings.Contains(body, "one") {- t.Fatalf("a fresh fetch fetched again:\n%s", body)- }- r := w.repos[f.host+"/acct/r"]- r.mu.Lock()- r.fetched, r.tried = time.Now().Add(-time.Hour), time.Now().Add(-time.Hour)- old := r.tree.dir- r.mu.Unlock()- webGet(w, path)- var body string- for end := time.Now().Add(20 * time.Second); time.Now().Before(end); time.Sleep(20 * time.Millisecond) {- if body = webGet(w, path).Body.String(); strings.Contains(body, "two") {- break- }- }- r.mu.Lock()- dir := r.tree.dir- r.mu.Unlock()- if !strings.Contains(body, "two") || !strings.Contains(body, ">"+stardatesIn(context.Background(), dir).byHash[head]+"</a>") {- t.Fatalf("not refreshed:\n%s", body)- }- if exists(old) {- t.Errorf("the old worktree %s is still there", old)- }-}- // Another site cannot make the browser download a repo. func TestWebAsksWhenAnotherSiteSends(t *testing.T) { f := newForge(t, nil)@@ -294,66 +255,6 @@ func TestWebAsksWhenAnotherSiteSends(t *testing.T) { } } -// When the cache outgrows its limit, the repo read longest ago goes.-func TestWebTrimsTheCache(t *testing.T) {- f := newForge(t, nil)- f.repo(t, "acct/a", map[string]string{"README.md": strings.Repeat("a", 1<<16)})- f.repo(t, "acct/b", map[string]string{"README.md": strings.Repeat("b", 1<<16)})- w := testWeb(t, defaultDisk)- read(t, w, "/"+f.host+"/acct/a/")- past := time.Now().Add(-time.Hour)- os.Chtimes(filepath.Join(w.root, f.host, "acct", "a", ".used"), past, past)- w.limit = w.onDisk()[0].size * 3 / 2- if rec := read(t, w, "/"+f.host+"/acct/b/"); rec.Code != http.StatusOK {- t.Fatalf("b: %d\n%s", rec.Code, rec.Body)- }- if exists(filepath.Join(w.root, f.host, "acct", "a")) {- t.Error("a, read longest ago, is still cached")- }- if rec := webGet(w, "/"+f.host+"/acct/a/README.md"); rec.Code != http.StatusAccepted {- t.Errorf("a after it went: %d, want a new read", rec.Code)- }- read(t, w, "/"+f.host+"/acct/a/README.md")- w.limit = 1- f.repo(t, "acct/c", map[string]string{"README.md": "c\n"})- rec := read(t, w, "/"+f.host+"/acct/c/")- if rec.Code != http.StatusBadGateway || !strings.Contains(rec.Body.String(), "over the cache") {- t.Errorf("a repo over the limit: %d\n%s", rec.Code, rec.Body)- }-}--// The servers on every address share the cache, so a trim leaves a repo-// whose lock another server holds, eg while it fetches the repo.-func TestWebTrimLeavesALockedRepo(t *testing.T) {- f := newForge(t, nil)- f.repo(t, "acct/a", map[string]string{"README.md": strings.Repeat("a", 1<<16)})- f.repo(t, "acct/b", map[string]string{"README.md": strings.Repeat("b", 1<<16)})- w := testWeb(t, defaultDisk)- read(t, w, "/"+f.host+"/acct/a/")- a := filepath.Join(w.root, f.host, "acct", "a")- past := time.Now().Add(-time.Hour)- os.Chtimes(filepath.Join(a, ".used"), past, past)- w.limit = w.onDisk()[0].size * 3 / 2- // A lock of its own open file stands for another server's.- unlock, err := flockDir(a, syscall.LOCK_EX)- if err != nil {- t.Fatal(err)- }- if rec := read(t, w, "/"+f.host+"/acct/b/"); rec.Code != http.StatusOK {- t.Fatalf("b: %d\n%s", rec.Code, rec.Body)- }- if !exists(filepath.Join(a, ".git", "HEAD")) {- t.Error("a went while another server held its lock")- }- unlock()- if err := w.trim(""); err != nil {- t.Fatal(err)- }- if exists(a) {- t.Error("a stayed once its lock was free")- }-}- // A path that names no repo lists the account's repos: from the forge's // A public server shows a stranger nothing of the machine and nothing of // what others read: no cache at / or in a listing, no path of the machine,@@ -403,45 +304,6 @@ func TestWebPublic(t *testing.T) { } } -// API where it answers, else from the cache.-func TestWebLists(t *testing.T) {- api := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {- if r.URL.Path == "/api/v1/users/org/repos" {- w.Write([]byte(`[{"full_name":"org/tool","description":"a tool"}]`))- return- }- http.NotFound(w, r)- })- f := newForge(t, api)- f.repo(t, "acct/r", map[string]string{"README.md": "x\n"})- w := testWeb(t, defaultDisk)- read(t, w, "/"+f.host+"/acct/r/")-- rec := read(t, w, "/"+f.host+"/org")- if body := rec.Body.String(); rec.Code != http.StatusOK || !strings.Contains(body, `href="/`+f.host+`/org/tool/"`) ||- !strings.Contains(body, "a tool") || !strings.Contains(body, "the Forgejo API") {- t.Errorf("API listing: %d\n%s", rec.Code, body)- }- rec = read(t, w, "/"+f.host+"/acct")- if body := rec.Body.String(); rec.Code != http.StatusOK || !strings.Contains(body, `href="/`+f.host+`/acct/r/"`) ||- !strings.Contains(body, "listed by the cache") {- t.Errorf("cache listing: %d\n%s", rec.Code, body)- }- if rec := read(t, w, "/"+f.host+"/nobody"); rec.Code != http.StatusNotFound {- t.Errorf("nothing there: %d\n%s", rec.Code, rec.Body)- }- // A failed clone leaves nothing in the cache. A forge that keeps- // repos at one depth clones without a probe.- depths[f.host] = 3- t.Cleanup(func() { delete(depths, f.host) })- if rec := read(t, w, "/"+f.host+"/acct/gone"); rec.Code != http.StatusBadGateway {- t.Errorf("no repo: %d\n%s", rec.Code, rec.Body)- }- if exists(filepath.Join(w.root, f.host, "acct", "gone")) {- t.Error("a failed read left its directory")- }-}- func TestWebEndpoint(t *testing.T) { ep, _ := endpointFor("", 1001) if w, ok := webEndpoint(ep, 1001); !ok || w.String() != "127.31.1.2:"+ep.String()[len("127.31.1.1:"):] {@@ -453,119 +315,6 @@ func TestWebEndpoint(t *testing.T) { } } -// A link to a repo on a forge, or a file or directory in one, maps to its-// path on the web address; other pages of a forge, and other hosts, map-// to nothing.-func TestWebPath(t *testing.T) {- for in, want := range map[string]string{- "https://github.com/junegunn/fzf": "/github.com/junegunn/fzf/",- "https://www.github.com/junegunn/fzf.git": "/github.com/junegunn/fzf/",- "https://github.com/junegunn/fzf/blob/master/README.md": "/github.com/junegunn/fzf/README.md",- "https://github.com/junegunn/fzf/tree/v0.50/shell": "/github.com/junegunn/fzf/shell/",- "https://github.com/junegunn/fzf/tree/master": "/github.com/junegunn/fzf/",- "https://codeberg.org/forgejo/forgejo/src/branch/main/go.mod": "/codeberg.org/forgejo/forgejo/go.mod",- "https://gitlab.com/a/b/c/-/blob/main/x.md": "/gitlab.com/a/b/c/x.md",- "https://gitlab.com/a/b": "/gitlab.com/a/b/",- "https://bitbucket.org/a/b/src/main/x.md": "/bitbucket.org/a/b/x.md",- "https://github.com/junegunn/fzf/blob/26ff936/BUILD.md": "/github.com/junegunn/fzf@26ff936/BUILD.md",- "https://github.com/junegunn/fzf/tree/26ff936df038b6a62ec42cf18db27b2ea2bb8ec3": "/github.com/junegunn/fzf@26ff936df038b6a62ec42cf18db27b2ea2bb8ec3/",- "https://github.com/junegunn/fzf/blob/cafe/x.md": "/github.com/junegunn/fzf/x.md",- "https://github.com/junegunn/fzf/raw/master/x.png": "/github.com/junegunn/fzf/x.png",- "https://raw.githubusercontent.com/rivo/uniseg/master/LICENSE.txt": "/github.com/rivo/uniseg/LICENSE.txt",- "https://raw.githubusercontent.com/rivo/uniseg/refs/heads/main/a/b.go": "/github.com/rivo/uniseg/a/b.go",- "https://raw.githubusercontent.com/rivo/uniseg/0123abcd/LICENSE": "/github.com/rivo/uniseg@0123abcd/LICENSE",- "https://raw.githubusercontent.com/rivo/uniseg": "",- "https://raw.githubusercontent.com/rivo/uniseg/master": "",- "https://codeberg.org/a/b/raw/branch/main/x.md": "/codeberg.org/a/b/x.md",- "https://gitlab.com/a/b/-/raw/main/x.md": "/gitlab.com/a/b/x.md",- "https://github.com/junegunn/fzf/issues/3": "",- "https://github.com/junegunn": "",- "https://github.com/sponsors/junegunn": "",- "https://example.com/a/b": "",- "ftp://github.com/a/b": "",- "https://github.com/a/../b": "",- } {- u, err := url.Parse(in)- if err != nil {- t.Fatal(err)- }- if _, got := webPath(u); got != want {- t.Errorf("%s: %q, want %q", in, got, want)- }- }-}--// A URL pasted whole after the address, or a forge's view of a file,-// redirects to the path that reads it. The mux has folded the URL's //-// into / before.-func TestWebPastedURL(t *testing.T) {- w := testWeb(t, defaultDisk)- for in, want := range map[string]string{- "/https:/git.suckless.org/dwm/": "/git.suckless.org/dwm",- "/https:/git.suckless.org/dwm": "/git.suckless.org/dwm",- "/http:/github.com/junegunn/fzf": "/github.com/junegunn/fzf/",- "/https:/github.com/junegunn/fzf/blob/master/main.go": "/github.com/junegunn/fzf/main.go",- "/github.com/junegunn/fzf/tree/master/shell": "/github.com/junegunn/fzf/shell/",- "/raw.githubusercontent.com/junegunn/fzf/master/x.md": "/github.com/junegunn/fzf/x.md",- } {- rec := webGet(w, in)- if rec.Code != http.StatusMovedPermanently || rec.Header().Get("Location") != want {- t.Errorf("%s: %d %s, want %s", in, rec.Code, rec.Header().Get("Location"), want)- }- }- for _, in := range []string{"/github.com/junegunn", "/github.com/junegunn/fzf/README.md"} {- if rec := webGet(w, in); rec.Code == http.StatusMovedPermanently {- t.Errorf("%s redirects to %s", in, rec.Header().Get("Location"))- }- }-}--// The web address's index links back to the trees on the default address.-func TestWebLinksHome(t *testing.T) {- w := testWeb(t, defaultDisk)- if strings.Contains(webGet(w, "/").Body.String(), "Trees:") {- t.Errorf("a link home without a home")- }- w.srv.homeURL = "http://127.31.1.1:8080"- if body := webGet(w, "/").Body.String(); !strings.Contains(body, `Trees: <a href="http://127.31.1.1:8080/">127.31.1.1:8080/</a>`) {- t.Errorf("no link home: %s", body)- }-}--// A page of a repo on the web address links its forge's own permanent-// URL: a file or directory at the commit shown, or a commit.-func TestForgeLink(t *testing.T) {- needGit(t)- _, tr, dir := serveTree(t)- if out, err := exec.Command("git", "init", "-q", dir).CombinedOutput(); err != nil {- t.Fatalf("git init: %v\n%s", err, out)- }- commitAll(t, dir, "one")- out, _ := exec.Command("git", "-C", dir, "rev-parse", "HEAD").Output()- head := strings.TrimSpace(string(out))- tr.web = &webRepo{key: "github.com/junegunn/fzf"}- for _, c := range []struct{ path, kind, want string }{- {"/a/b.go", "code", "https://github.com/junegunn/fzf/blob/" + head + "/a/b.go"},- {"/a/", "dir", "https://github.com/junegunn/fzf/tree/" + head + "/a"},- {"/", "dir", "https://github.com/junegunn/fzf/tree/" + head},- {"/?commit=" + head, "commit", "https://github.com/junegunn/fzf/commit/" + head},- {"/?log", "log", ""},- } {- got, lines := tr.forgeLink(httptest.NewRequest("GET", c.path, nil), c.kind)- if got != c.want || c.want != "" && lines != "github" {- t.Errorf("%s: %q %q, want %q", c.path, got, lines, c.want)- }- }- tr.web.key = "gitlab.com/a/b/c"- if got, lines := tr.forgeLink(httptest.NewRequest("GET", "/a/b.go", nil), "code"); got != "https://gitlab.com/a/b/c/-/blob/"+head+"/a/b.go" || lines != "gitlab" {- t.Errorf("gitlab: %q %q", got, lines)- }- tr.web.key = "git.suckless.org/dwm"- if got, _ := tr.forgeLink(httptest.NewRequest("GET", "/a/b.go", nil), "code"); got != "" {- t.Errorf("an unknown forge: %q", got)- }-}- // A public server counts a visitor by address, or, behind a proxy on // loopback, by the address the proxy appended; IPv6 by its /64. func TestVisitor(t *testing.T) {@@ -634,28 +383,6 @@ func TestWebPublicLimits(t *testing.T) { } } -// A clone waits for a slot, and says so on its page.-func TestWebCloneWaitsForASlot(t *testing.T) {- f := newForge(t, nil)- f.repo(t, "acct/r", map[string]string{"README.md": "x\n"})- w := testWeb(t, defaultDisk)- w.slots = make(chan struct{}, 1)- w.slots <- struct{}{}- path := "/" + f.host + "/acct/r/"- rec := webGet(w, path)- for end := time.Now().Add(10 * time.Second); !strings.Contains(rec.Body.String(), "waiting: 1 clones run at once") && time.Now().Before(end); {- time.Sleep(20 * time.Millisecond)- rec = webGet(w, path)- }- if rec.Code != http.StatusAccepted || !strings.Contains(rec.Body.String(), "waiting") {- t.Fatalf("clone without a slot: %d\n%s", rec.Code, rec.Body)- }- <-w.slots- if rec := read(t, w, path); rec.Code != http.StatusOK {- t.Errorf("clone after a slot frees: %d\n%s", rec.Code, rec.Body)- }-}- // A public server reaches no address of the machine or its network, by // an address or by a name, and holds git to the addresses it checked. func TestWebPublicReach(t *testing.T) {
@@ -0,0 +1,68 @@+package main++import (+ "context"+ "net/http"+ "strings"+ "testing"+ "time"+)++// A visit after webRefresh fetches in the background, and the page moves+// to the new commit.+func TestWebRefreshes(t *testing.T) {+ f := newForge(t, nil)+ f.repo(t, "acct/r", map[string]string{"README.md": "one\n"})+ w := testWeb(t, defaultDisk)+ path := "/" + f.host + "/acct/r/README.md"+ if rec := read(t, w, path); !strings.Contains(rec.Body.String(), "one") {+ t.Fatalf("first read: %d\n%s", rec.Code, rec.Body)+ }+ head := f.repo(t, "acct/r", map[string]string{"README.md": "two\n"})+ if body := webGet(w, path).Body.String(); !strings.Contains(body, "one") {+ t.Fatalf("a fresh fetch fetched again:\n%s", body)+ }+ r := w.repos[f.host+"/acct/r"]+ r.mu.Lock()+ r.fetched, r.tried = time.Now().Add(-time.Hour), time.Now().Add(-time.Hour)+ old := r.tree.dir+ r.mu.Unlock()+ webGet(w, path)+ var body string+ for end := time.Now().Add(20 * time.Second); time.Now().Before(end); time.Sleep(20 * time.Millisecond) {+ if body = webGet(w, path).Body.String(); strings.Contains(body, "two") {+ break+ }+ }+ r.mu.Lock()+ dir := r.tree.dir+ r.mu.Unlock()+ if !strings.Contains(body, "two") || !strings.Contains(body, ">"+stardatesIn(context.Background(), dir).byHash[head]+"</a>") {+ t.Fatalf("not refreshed:\n%s", body)+ }+ if exists(old) {+ t.Errorf("the old worktree %s is still there", old)+ }+}++// A clone waits for a slot, and says so on its page.+func TestWebCloneWaitsForASlot(t *testing.T) {+ f := newForge(t, nil)+ f.repo(t, "acct/r", map[string]string{"README.md": "x\n"})+ w := testWeb(t, defaultDisk)+ w.slots = make(chan struct{}, 1)+ w.slots <- struct{}{}+ path := "/" + f.host + "/acct/r/"+ rec := webGet(w, path)+ for end := time.Now().Add(10 * time.Second); !strings.Contains(rec.Body.String(), "waiting: 1 clones run at once") && time.Now().Before(end); {+ time.Sleep(20 * time.Millisecond)+ rec = webGet(w, path)+ }+ if rec.Code != http.StatusAccepted || !strings.Contains(rec.Body.String(), "waiting") {+ t.Fatalf("clone without a slot: %d\n%s", rec.Code, rec.Body)+ }+ <-w.slots+ if rec := read(t, w, path); rec.Code != http.StatusOK {+ t.Errorf("clone after a slot frees: %d\n%s", rec.Code, rec.Body)+ }+}
@@ -0,0 +1,71 @@+package main++import (+ "net/http"+ "os"+ "path/filepath"+ "strings"+ "syscall"+ "testing"+ "time"+)++// When the cache outgrows its limit, the repo read longest ago goes.+func TestWebTrimsTheCache(t *testing.T) {+ f := newForge(t, nil)+ f.repo(t, "acct/a", map[string]string{"README.md": strings.Repeat("a", 1<<16)})+ f.repo(t, "acct/b", map[string]string{"README.md": strings.Repeat("b", 1<<16)})+ w := testWeb(t, defaultDisk)+ read(t, w, "/"+f.host+"/acct/a/")+ past := time.Now().Add(-time.Hour)+ os.Chtimes(filepath.Join(w.root, f.host, "acct", "a", ".used"), past, past)+ w.limit = w.onDisk()[0].size * 3 / 2+ if rec := read(t, w, "/"+f.host+"/acct/b/"); rec.Code != http.StatusOK {+ t.Fatalf("b: %d\n%s", rec.Code, rec.Body)+ }+ if exists(filepath.Join(w.root, f.host, "acct", "a")) {+ t.Error("a, read longest ago, is still cached")+ }+ if rec := webGet(w, "/"+f.host+"/acct/a/README.md"); rec.Code != http.StatusAccepted {+ t.Errorf("a after it went: %d, want a new read", rec.Code)+ }+ read(t, w, "/"+f.host+"/acct/a/README.md")+ w.limit = 1+ f.repo(t, "acct/c", map[string]string{"README.md": "c\n"})+ rec := read(t, w, "/"+f.host+"/acct/c/")+ if rec.Code != http.StatusBadGateway || !strings.Contains(rec.Body.String(), "over the cache") {+ t.Errorf("a repo over the limit: %d\n%s", rec.Code, rec.Body)+ }+}++// The servers on every address share the cache, so a trim leaves a repo+// whose lock another server holds, eg while it fetches the repo.+func TestWebTrimLeavesALockedRepo(t *testing.T) {+ f := newForge(t, nil)+ f.repo(t, "acct/a", map[string]string{"README.md": strings.Repeat("a", 1<<16)})+ f.repo(t, "acct/b", map[string]string{"README.md": strings.Repeat("b", 1<<16)})+ w := testWeb(t, defaultDisk)+ read(t, w, "/"+f.host+"/acct/a/")+ a := filepath.Join(w.root, f.host, "acct", "a")+ past := time.Now().Add(-time.Hour)+ os.Chtimes(filepath.Join(a, ".used"), past, past)+ w.limit = w.onDisk()[0].size * 3 / 2+ // A lock of its own open file stands for another server's.+ unlock, err := flockDir(a, syscall.LOCK_EX)+ if err != nil {+ t.Fatal(err)+ }+ if rec := read(t, w, "/"+f.host+"/acct/b/"); rec.Code != http.StatusOK {+ t.Fatalf("b: %d\n%s", rec.Code, rec.Body)+ }+ if !exists(filepath.Join(a, ".git", "HEAD")) {+ t.Error("a went while another server held its lock")+ }+ unlock()+ if err := w.trim(""); err != nil {+ t.Fatal(err)+ }+ if exists(a) {+ t.Error("a stayed once its lock was free")+ }+}
@@ -0,0 +1,47 @@+package main++import (+ "net/http"+ "path/filepath"+ "strings"+ "testing"+)++// API where it answers, else from the cache.+func TestWebLists(t *testing.T) {+ api := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {+ if r.URL.Path == "/api/v1/users/org/repos" {+ w.Write([]byte(`[{"full_name":"org/tool","description":"a tool"}]`))+ return+ }+ http.NotFound(w, r)+ })+ f := newForge(t, api)+ f.repo(t, "acct/r", map[string]string{"README.md": "x\n"})+ w := testWeb(t, defaultDisk)+ read(t, w, "/"+f.host+"/acct/r/")++ rec := read(t, w, "/"+f.host+"/org")+ if body := rec.Body.String(); rec.Code != http.StatusOK || !strings.Contains(body, `href="/`+f.host+`/org/tool/"`) ||+ !strings.Contains(body, "a tool") || !strings.Contains(body, "the Forgejo API") {+ t.Errorf("API listing: %d\n%s", rec.Code, body)+ }+ rec = read(t, w, "/"+f.host+"/acct")+ if body := rec.Body.String(); rec.Code != http.StatusOK || !strings.Contains(body, `href="/`+f.host+`/acct/r/"`) ||+ !strings.Contains(body, "listed by the cache") {+ t.Errorf("cache listing: %d\n%s", rec.Code, body)+ }+ if rec := read(t, w, "/"+f.host+"/nobody"); rec.Code != http.StatusNotFound {+ t.Errorf("nothing there: %d\n%s", rec.Code, rec.Body)+ }+ // A failed clone leaves nothing in the cache. A forge that keeps+ // repos at one depth clones without a probe.+ depths[f.host] = 3+ t.Cleanup(func() { delete(depths, f.host) })+ if rec := read(t, w, "/"+f.host+"/acct/gone"); rec.Code != http.StatusBadGateway {+ t.Errorf("no repo: %d\n%s", rec.Code, rec.Body)+ }+ if exists(filepath.Join(w.root, f.host, "acct", "gone")) {+ t.Error("a failed read left its directory")+ }+}
@@ -0,0 +1,18 @@+package main++import (+ "strings"+ "testing"+)++// The web address's index links back to the trees on the default address.+func TestWebLinksHome(t *testing.T) {+ w := testWeb(t, defaultDisk)+ if strings.Contains(webGet(w, "/").Body.String(), "Trees:") {+ t.Errorf("a link home without a home")+ }+ w.srv.homeURL = "http://127.31.1.1:8080"+ if body := webGet(w, "/").Body.String(); !strings.Contains(body, `Trees: <a href="http://127.31.1.1:8080/">127.31.1.1:8080/</a>`) {+ t.Errorf("no link home: %s", body)+ }+}