package e2e import ( "html" "net/http" "net/url" "os/exec" "regexp" "sort" "strings" "testing" ) var hrefIn = regexp.MustCompile(`href="([^"]+)"`) var saidIn = regexp.MustCompile(`

([^<]*)

`) // Nothing barerepo draws may lead nowhere, so every link on every page is followed and has to answer. func TestNoPageLinksToSomethingThatDoesNotAnswer(t *testing.T) { if _, err := exec.LookPath("git"); err != nil { t.Skip("git is not installed") } in := newInstance(t) john := in.account("john") mark := in.account("mark") work := seed(t, in, john, "john", "johnbot") // A repository with something on every page, or the crawl walks past empty states. write(t, work, "retry.go", "package main\n\nfunc backoff(n int) int { return n * 2 }\n") // A space and a plus are legal in a path and are what break a url nobody escaped. write(t, work, "a note.md", "# a file with a space in its name\n") write(t, work, "c++.md", "# a file with a plus in its name\n") // git quotes a path outside ascii in its own output, which is a second spelling to get wrong. write(t, work, "café.md", "# a file with an accent in its name\n") // A directory, so the tree has depth and an up link, and an awkward name inside it. write(t, work, "docs/a note.md", "# a note in a directory\n") // A binary and a large file, which are the two branches the file view draws instead of lines. write(t, work, "logo.bin", "\x00\x01\x02binary\x00bytes\n") write(t, work, "huge.txt", strings.Repeat("a line of a very large file\n", 40000)) write(t, work, "doomed.md", "# this file is deleted in the next commit\n") run(t, work, "git", "add", "-A") run(t, work, "git", "commit", "-qm", "add a backoff") // A deleted file has no +++ b/ line, so its path comes from the other half of the header. run(t, work, "git", "rm", "-q", "doomed.md") run(t, work, "git", "commit", "-qm", "delete a file") run(t, work, "git", "tag", "-a", "v1.0.0", "-m", "the first release") // A slash is legal in a ref and it is what breaks a url that spends one path element on one. run(t, work, "git", "tag", "-a", "release/1.0", "-m", "a tag with a slash in it") run(t, work, "git", "branch", "feature/login") run(t, work, "git", "push", "-q", in.url(john, "/john/johnbot"), "master") run(t, work, "git", "push", "-q", in.url(john, "/john/johnbot"), "feature/login") run(t, work, "git", "push", "-q", in.url(john, "/john/johnbot"), "v1.0.0") run(t, work, "git", "push", "-q", in.url(john, "/john/johnbot"), "release/1.0") mine := clone(t, in, mark, "/john/johnbot") commit(t, mine, "package main\n\nfunc main() {}\n", "a proposal") run(t, mine, "git", "push", "-q", in.url(mark, "/john/johnbot"), "HEAD:refs/proposals/new") post(t, in, "john", "/john/johnbot/threads", url.Values{"title": {"a thread"}, "body": {"with a body"}}) // A page has as many versions as it has kinds of reader, and each draws its own links. owner, err := in.db.NewSession(t.Context(), "john") if err != nil { t.Fatal(err) } stranger, err := in.db.NewSession(t.Context(), "mark") if err != nil { t.Fatal(err) } for _, who := range []struct { name string session string least int }{{"the owner", owner, 25}, {"a stranger", stranger, 15}, {"nobody", "", 10}} { crawl(t, in, who.name, who.session, who.least, who.name == "the owner") } } // crawl follows every link one reader is shown, because a link that refuses them is not a link. func crawl(t *testing.T, in *instance, who, session string, least int, isOwner bool) { t.Helper() queue := []string{"/", "/john", "/john/johnbot", "/keys", "/inbox", "/search?q=backoff", "/signup"} // A seed is typed, so being sent to sign in is a fair answer. A drawn link is a promise. seed := map[string]bool{} for _, p := range queue { seed[p] = true } seen := map[string]bool{} var broken []string for len(queue) > 0 && len(seen) < 300 { path := queue[0] queue = queue[1:] if seen[path] { continue } seen[path] = true code, where, body := fetch(t, in, session, path) if code != http.StatusOK && code != http.StatusFound { broken = append(broken, path+" answered "+itoaCode(code)) continue } // A link that can only send this reader to the sign-in page is a link they should not see. if !seed[path] && strings.HasPrefix(where, "/signin") { broken = append(broken, path+", which only sends them to sign in") continue } // Every mock carries one sr-only sentence saying what its page is for, and so must every page. if strings.HasPrefix(body, "") { said := saidIn.FindStringSubmatch(body) if said == nil || strings.TrimSpace(said[1]) == "" { broken = append(broken, path+" says nothing about itself to a screen reader") } } for _, m := range hrefIn.FindAllStringSubmatch(body, -1) { if next, ok := internal(m[1]); ok { queue = append(queue, next) } } } if len(seen) < least { t.Fatalf("%s reached only %d pages, so the crawl is not reading links", who, len(seen)) } // A page nothing links to is a page nobody finds, whatever it answers when asked directly. if isOwner { for _, must := range []string{ "/john/johnbot", "/john/johnbot/files", "/john/johnbot/threads", "/john/johnbot/runs", "/john/johnbot/releases", "/john/johnbot/config", "/john/johnbot/thread/2", "/keys", "/inbox", } { if !seen[must] { t.Errorf("nothing links to %s, so a reader can only reach it by typing", must) } } } sort.Strings(broken) for _, b := range broken { t.Errorf("%s is shown a link to %s", who, b) } } // internal keeps the links that stay on this barerepo, since an outside url is not barerepo's to answer. func internal(href string) (string, bool) { if !strings.HasPrefix(href, "/") || strings.HasPrefix(href, "//") { return "", false } if strings.HasPrefix(href, "/static/") { return "", false } // A page writes & as & and + as +, and a crawler must read what a browser would. return html.UnescapeString(href), true } func fetch(t *testing.T, in *instance, session, path string) (int, string, string) { t.Helper() req, err := http.NewRequest(http.MethodGet, in.http.URL+path, nil) if err != nil { t.Fatal(err) } if session != "" { req.AddCookie(&http.Cookie{Name: "barerepo_session", Value: session}) } client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} resp, err := client.Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() return resp.StatusCode, resp.Header.Get("Location"), readAll(t, resp) } func itoaCode(n int) string { return string(rune('0'+n/100)) + string(rune('0'+n/10%10)) + string(rune('0'+n%10)) }