package httpd import ( "fmt" "strings" "testing" "time" "github.com/barerepo/server/internal/gitread" "github.com/barerepo/server/internal/run" "github.com/barerepo/server/internal/thread" "html/template" ) // renderCase is one template and what its page must say, shared by the render and field checks. type renderCase struct { data any want []string } // Every template runs here, because a typo otherwise fails when a user asks for the page. func TestTemplatesRender(t *testing.T) { for name, c := range renderCases() { tpl, ok := pages[name] if !ok { t.Errorf("no template named %q", name) continue } var out strings.Builder if err := tpl.ExecuteTemplate(&out, "layout", c.data); err != nil { t.Errorf("%s: %v", name, err) continue } got := out.String() if !strings.HasPrefix(got, "") || !strings.Contains(got, "") { t.Errorf("%s: not a whole document", name) } for _, want := range c.want { if !strings.Contains(got, want) { t.Errorf("%s: missing %q", name, want) } } } // Every template, not only the ones somebody remembered, or a new page ships untested. for name := range pages { if _, ok := renderCases()[name]; !ok { t.Errorf("template %q has no case here, so nothing renders it before a user asks", name) } } } func renderCases() map[string]renderCase { page := repoPage{ chrome: newChrome("barerepo · john/johnbot", "a summary"), Owner: "john", Name: "johnbot", Branch: "master", CloneURL: "git@barerepo:john/johnbot", Tabs: repoTabs("john", "johnbot", "log", "master", 3), } commits := viewCommits("john", "johnbot", []gitread.Commit{{ SHA: "a3f9c2d", Short: "a3f9c2", Author: "lisa", When: time.Now().Add(-2 * time.Hour), Subject: "fix panic when config is empty", Add: 4, Del: 1, Files: []gitread.FileDiff{{Path: "config.go", Add: 4, Del: 1, Hunks: []gitread.Hunk{{ Header: "@@ -41,7 +41,10 @@ func Load", Lines: []gitread.Line{ {Kind: ' ', Text: "f, err := os.Open(path)"}, {Kind: '-', Text: "return cfg"}, {Kind: '+', Text: "if len(raw) == 0 {"}, }, }}}}, }}) return map[string]renderCase{ "repo-log": { struct { repoPage Commits []commitView ReadmeHref string OnlyPath string FileHref string }{page, commits, "/john/johnbot/file/a3f9c2/README.md", "", ""}, []string{"john / ", "johnbot", "a3f9c2", "fix panic", "barerepo 0.1.0"}, }, "push-rejected": { struct { repoPage Ref string Head string Push string You string MayPush bool }{page, "refs/heads/master", "a3f9c2 · 2h", `["john", "lisa"]`, "mark", false}, // html/template writes a quote as " in text, so the assertion says what a reader sees. []string{"you pushed to refs/heads/master", ""john", "lisa"", "you are mark", "git push origin HEAD:refs/proposals/new", "printed in your terminal"}, }, "repo-empty": { struct { repoPage Public bool Description string Visibility string }{page, false, "", "private"}, []string{"git init", "git@barerepo:john/johnbot", ".barerepo/config", "visibility"}, }, "repo-files": { struct { repoPage Path string Up string Entries []entryView More string }{page, "", "", viewEntries("john", "johnbot", "master", []gitread.Entry{ {Name: "irc", Path: "irc", Dir: true, Author: "john", Subject: "split the daemon out"}, {Name: "config.go", Path: "config.go", Author: "lisa", Subject: "fix panic when config is empty"}, }), ""}, []string{"irc/", "config.go", "split the daemon out", "/john/johnbot/files/master/irc"}, }, "repo-file": { struct { repoPage Path string File *gitread.File Lines []fileLineView Meta string SizeText string RawHref string HistoryHref string Shown int Truncated bool RenderedHref string }{page, "config.go", &gitread.File{Path: "config.go", Size: 1400}, viewFileLines([]gitread.FileLine{{Number: 41, Text: "f, err := os.Open(path)", Short: "a3f9c2", Author: "lisa", When: time.Now().Add(-2 * time.Hour)}}), "61 lines · 1.4kb · master", "1.4kb", "/john/johnbot/raw/master/config.go", "/john/johnbot?path=config.go", 0, false, ""}, []string{"config.go", "a3f9c2 lisa 2h", "class=\"num\"", "os.Open(path)", "raw", "history"}, }, "repo-commit": { struct { repoPage Commit commitView FileCount string Reachable bool Parent string ParentHref string FilesHref string Signature string SigBad bool SignerHref string Signer string Verify string }{page, commits[0], "1 file", true, "8b1d44", "/john/johnbot/commit/8b1d44", "/john/johnbot/file/a3f9c2/", "signed by lisa", false, "/lisa", "lisa", "git verify-commit a3f9c2d"}, []string{"a3f9c2", "fix panic", "reachable from", `href="/lisa">lisa`, `href="/john/johnbot">master`, `href="/john/johnbot/commit/8b1d44">8b1d44`, `href="/john/johnbot/file/a3f9c2/config.go"`}, }, "repo-compare": { struct { repoPage Comparison *gitread.Comparison Files []fileView Stats string Action string FilesHref string Refs []refLink }{page, &gitread.Comparison{A: "master", B: "refs/proposals/47"}, viewFiles(commits[0].Commit.Files, nil), "3 commits · 2 files · +81 -12 · no conflicts", "/john/johnbot/compare", "/john/johnbot/file/refs/proposals/47/", []refLink{{Name: "topic", Kind: "branch", Href: "/john/johnbot/compare/master...topic"}}}, []string{"master", "refs/proposals/47", "no conflicts", "class=\"rev\"", `href="/john/johnbot/file/refs/proposals/47/config.go"`, `href="/john/johnbot/compare/master...topic"`}, }, "404": { struct { chrome Path string Near string Href string Missing string }{newChrome("barerepo · not found", "s"), "/john/johnbot/nope", "john/johnbot", "/john/johnbot", ""}, []string{"404", "john/johnbot", "exists. that path in it does not."}, }, "readme": { struct { repoPage Path string HTML template.HTML SourceHref string RawHref string }{page, "README.md", template.HTML("

barerepo

"), "/john/johnbot/file/a3f9c2/README.md", "/john/johnbot/raw/a3f9c2/README.md"}, []string{"README.md", "

barerepo

", "rendered", `href="/john/johnbot/file/a3f9c2/README.md"`, ">source<", `href="/john/johnbot/raw/a3f9c2/README.md"`}, }, "threads": { struct { repoPage Threads []threadRow Counts string Viewer string Empty string }{page, []threadRow{{N: 47, Title: "panic when config file is empty", Author: "lisa", AuthorHref: "/lisa", Ago: "2h", Href: "/john/johnbot/thread/47", Detail: []bit{plain("has proposal"), {Text: "+81 -12", Href: "/john/johnbot/compare"}, {Text: "build failed", Danger: true}, plain("2 replies")}}}, "3 open · 41 closed", "john", "no threads yet."}, // html/template writes + as + in text, which is why this checks the digits. []string{"panic when config file is empty", "3 open · 41 closed", "81 -12", `href="/lisa"`, `class="danger"`}, }, "thread-new": { struct { repoPage newThreadPage }{page, newThreadPage{Heading: "a title", Body: "a body", Ref: "refs/proposals/47"}}, []string{"refs/proposals/47", "a body"}, }, "comment-line": { struct { repoPage N int Path string Line int Rev string Blob string Context []contextLine Error string }{page, 47, "config.go", 43, "refs/proposals/47", "a3f9c2d", []contextLine{{Number: 42, Text: "func Default() Config {"}, {Number: 43, Text: "\treturn Config{}", Here: true}}, ""}, []string{"config.go", "43", "func Default() Config {"}, }, "runs": { struct { repoPage Runs []runRow BuildOn bool Workflows []string }{page, []runRow{{Short: "a3f9c2", Name: "test (os ubuntu-latest)", Href: "/john/johnbot/run/a3f9c2", CommitHref: "/john/johnbot/commit/a3f9c2", Result: "build ok · test ok", Took: "18s", Ago: "40m", Subject: "fix panic", Ref: "refs/proposals/47", RefHref: "/john/johnbot/compare", Runner: "uproar.local", RunnerHref: "/john/johnbot/runners"}}, true, []string{".github/workflows/ci.yml"}}, []string{"build ok · test ok", "uproar.local", `href="/john/johnbot/commit/a3f9c2"`, // Chapter 15A: a matrix builds one commit several times, so the row says which. "test (os ubuntu-latest)", // A page that says builds are off while builds run is contradicting itself. "building from", ".github/workflows/ci.yml"}, }, "run": { struct { repoPage SHA string Short string Meta string Ref string Author string Runs []runRow CanRerun bool }{page, "a3f9c2d", "a3f9c2", "1 run", "refs/proposals/47", "lisa", []runRow{{Result: "build ok", Took: "18s", Runner: "uproar.local", RunnerHref: "/john/johnbot/runners", Output: "all tests passed", RawHref: "/john/johnbot/run/a3f9c2d/log", Size: "1kb"}}, true}, []string{"build ok", "all tests passed", "raw log"}, }, "runners": { struct { repoPage Runners []runnerRow Attached string CanAdd bool }{page, []runnerRow{{ID: 1, Hostname: "uproar.local", State: "idle", Seen: "2m", Detail: "linux/amd64"}}, "1 runner", true}, []string{"uproar.local", "idle", "linux/amd64"}, }, "runner-setup": { struct { repoPage Token string ExternalURL string }{page, "rt_live_7Kq2mXe", "https://barerepo.example"}, // Chapter 15: the token is in the copied line, and all three platforms show at once. []string{"rt_live_7Kq2mXe"}, }, "releases": { struct { repoPage Releases []releaseRow }{page, []releaseRow{{Tag: "v1.2.0", Href: "/john/johnbot/files/v1.2.0", Tagger: "john", TaggerHref: "/john", Ago: "3d", Subject: "fixes the empty config panic"}}}, []string{"v1.2.0", `href="/john/johnbot/files/v1.2.0"`, `href="/john"`}, }, "repo-config": { struct { repoPage Config string Edited string Editor string EditorHref string EditHref string EditShort string RawHref string HistoryHref string BlameHref string IsOwner bool Hooks []hookRow Error string CopyTo string CopyHave string CopyURL string }{page, "[repo]\nvisibility = \"public\"", "3d", "john", "/john", "/john/johnbot/commit/a3f9c2", "a3f9c2", "/john/johnbot/raw/master/.barerepo/config", "/john/johnbot?path=.barerepo/config", "/john/johnbot/file/master/.barerepo/config", true, []hookRow{{URL: "https://example.com/hook", Events: "push", State: "stopped after 20 failures in a row · 500 Internal Server Error", Danger: true}}, "", "/lisa/johnbot", "", "git@barerepo:lisa/johnbot"}, []string{"visibility", ".barerepo/config", "https://example.com/hook", "stopped after 20 failures in a row", "copy to /lisa/johnbot", "git@barerepo:lisa/johnbot", "proposal refs do not"}, }, "inbox": { struct { chrome Account string Events []eventRow Seen string }{newChrome("barerepo · inbox", "s"), "john", []eventRow{{Text: "lisa replied on john/johnbot thread 47", Ago: "2h", Href: "/john/johnbot/thread/47"}}, "last visit"}, []string{"lisa replied", "/john/johnbot/thread/47", "atom"}, }, "search": { struct { chrome Account string Query string Count string Results []searchRow }{newChrome("barerepo · search", "s"), "john", "Default", "3 results", []searchRow{ {Kind: "code", Where: template.HTML("john / johnbot / config.go:43"), Href: "/john/johnbot/file/master/config.go", HasText: true, Text: template.HTML("func Default() Config {")}, {Kind: "thread", Where: template.HTML("john / johnbot 44 · does this work behind a proxy?"), Href: "/john/johnbot/thread/44", HasCtx: true, Context: template.HTML("mark mentions it twice")}, {Kind: "repo", Where: template.HTML("john / johnbot"), Href: "/john/johnbot", HasCtx: true, Context: template.HTML("irc bot that refuses to leave")}, }}, []string{"Default", "3 results", "config.go:43", "44 · does this work behind a proxy?", "irc bot that refuses to leave"}, }, "profile": { struct { chrome Who string Account string Me bool Initials string Joined string KeyCount int SigningKeys int RepoCount string Repos []repoStatView Shown string AllHref string }{newChrome("barerepo · john", "s"), "john", "john", true, "jo", "mar 2026", 2, 1, "2 repositories", []repoStatView{{Name: "johnbot", Public: true, Ago: "2h", Meta: "go · 4mb · master"}}, "", ""}, []string{"johnbot", "joined mar 2026", `href="/john/johnbot"`, `href="/john.keys"`, `href="/john.gpg"`, ">feed<"}, }, "new-repo": { struct { chrome Account string Name string Description string Branch string CloneBase string Error string }{newChrome("barerepo · new repo", "s"), "john", "", "an irc bot", "master", "git@localhost:john", ""}, []string{"master", "git@localhost:john"}, }, "keys": { keysPage{chrome: newChrome("barerepo · keys", "s"), Account: "john", Keys: []keyView{{ID: 1, Algo: "ed25519", Fingerprint: "SHA256:ngsY", Comment: "john@laptop", Added: "mar 2026", LastUsed: "2h"}}, Tokens: []tokenView{{ID: 1, Name: "runner", Detail: "john/johnbot", Created: "apr 2026"}}}, []string{"ed25519", "SHA256:ngsY", "mar 2026", "runner"}, }, "signin": { signinPage{chrome: newChrome("barerepo · sign in", "s"), Name: "john", Nonce: "abc123"}, []string{"john", "abc123"}, }, "signup": { signupPage{chrome: newChrome("barerepo · signup", "s"), Name: "john", PubKey: "ssh-ed25519 AAAA", Nonce: "abc123"}, []string{"john", "abc123"}, }, "thread": { struct { repoPage N int Heading string Detail []bit NotesRef string Comments []commentRow Viewer string CanClose bool Closed bool Error string }{page, 47, "panic when config file is empty", []bit{plain("opened by lisa")}, "refs/notes/threads/47", []commentRow{{Author: "lisa", AuthorHref: "/lisa", Ago: "2h", HTML: template.HTML("

reproduces every time.

")}}, "john", true, false, ""}, []string{"panic when config file is empty", "refs/notes/threads/47", "reproduces every time.", `href="/lisa"`, ">close<"}, }, } } // Untrusted content reaches every page, and html/template escapes by context. Said out loud. func TestTemplatesEscape(t *testing.T) { nasty := `` commits := viewLog("john", "johnbot", []gitread.Commit{{ Short: "a3f9c2", Author: nasty, Subject: nasty, Files: []gitread.FileDiff{{Path: nasty, Hunks: []gitread.Hunk{{ Header: nasty, Lines: []gitread.Line{{Kind: '+', Text: nasty}}, }}}}, }}) page := repoPage{chrome: newChrome(nasty, nasty), Owner: nasty, Name: nasty, CloneURL: nasty, Tabs: repoTabs("john", "johnbot", "log", "master", 0)} var out strings.Builder err := pages["repo-log"].ExecuteTemplate(&out, "layout", struct { repoPage Commits []commitView ReadmeHref string OnlyPath string FileHref string }{page, commits, "", nasty, nasty}) if err != nil { t.Fatal(err) } if strings.Contains(out.String(), "`, "backoff")) if strings.Contains(got, "