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))
}