diff --git a/web/chrome.go b/web/chrome.go
new file mode 100644
index 0000000000000000000000000000000000000000..dedcf8796cc8421cb76ccfdd36e060f33acaf1ab
--- /dev/null
+++ b/web/chrome.go
@@ -0,0 +1,121 @@
+package web
+
+import (
+ "net/http"
+ "net/url"
+ "sort"
+ "strings"
+
+ "git.sr.ht/~sircmpwn/core-go/config"
+ "github.com/vaughan0/go-ini"
+
+ "go.bigb.es/sourcehut-compare/authz"
+)
+
+// navCanonical is the SourceHut service-switcher order. Services not listed here
+// (including our own compare) sort alphabetically after these.
+var navCanonical = []string{"hub", "git", "hg", "lists", "todo", "builds", "man", "meta"}
+
+// navExcluded are service sections that never appear in the switcher: paste and
+// pages have no top-level UI worth linking, and hub is rendered as the brand.
+var navExcluded = map[string]bool{"paste": true, "pages": true, "hub": true}
+
+// navItem is one entry in the service switcher.
+type navItem struct {
+ Name string // short service name, e.g. "git"
+ Origin string // external origin URL
+ Active bool // true for compare.sr.ht (this service)
+}
+
+// buildNav derives the service switcher from the shared config: every section
+// whose name ends in ".sr.ht" (with a configured origin) except paste/pages/hub,
+// ordered by navCanonical then alphabetically, with compare.sr.ht marked active.
+func buildNav(conf ini.File) []navItem {
+ var items []navItem
+ for section := range conf {
+ if !strings.HasSuffix(section, ".sr.ht") {
+ continue
+ }
+ short := strings.TrimSuffix(section, ".sr.ht")
+ if navExcluded[short] {
+ continue
+ }
+ origin := config.GetOrigin(conf, section, true)
+ if origin == "" {
+ continue
+ }
+ items = append(items, navItem{
+ Name: short,
+ Origin: origin,
+ Active: section == "compare.sr.ht",
+ })
+ }
+ sort.SliceStable(items, func(i, j int) bool {
+ ci, cj := canonIndex(items[i].Name), canonIndex(items[j].Name)
+ if ci != cj {
+ return ci < cj
+ }
+ return items[i].Name < items[j].Name
+ })
+ return items
+}
+
+// canonIndex returns a service's position in navCanonical, or a sentinel past
+// the end for services that are not canonically ordered.
+func canonIndex(name string) int {
+ for i, n := range navCanonical {
+ if n == name {
+ return i
+ }
+ }
+ return len(navCanonical)
+}
+
+// viewData is the root value every template is executed against: the chrome
+// fields are common to all pages; Data carries the page-specific payload.
+type viewData struct {
+ Title string
+ SiteName string
+ HubOrigin string // non-empty ⇒ brand links to hub instead of "/"
+ Nav []navItem
+ Username string // "" for an anonymous viewer
+ LoginURL string
+ LogoutURL string
+ RegisterURL string
+ ProfileURL string
+ CSSHref string
+ Environment string
+ ShowBanner bool
+
+ Data any
+}
+
+// chrome builds the common chrome fields for a request. Login return_to is the
+// current full URL (so the viewer lands back where they were); logout return_to
+// is this service's origin.
+func (s *Server) chrome(r *http.Request) viewData {
+ username := authz.ForContext(r.Context())
+
+ current := s.compareOrigin + r.URL.RequestURI()
+ loginURL := s.metaOrigin + "/login?return_to=" + url.QueryEscape(current)
+ logoutURL := s.metaOrigin + "/logout?return_to=" + url.QueryEscape(s.compareOrigin)
+
+ profileURL := s.metaOrigin + "/profile"
+ if s.hubOrigin != "" && username != "" {
+ profileURL = s.hubOrigin + "/~" + username
+ }
+
+ return viewData{
+ SiteName: s.siteName,
+ HubOrigin: s.hubOrigin,
+ Nav: s.nav,
+ Username: username,
+ LoginURL: loginURL,
+ LogoutURL: logoutURL,
+ RegisterURL: s.metaOrigin,
+ ProfileURL: profileURL,
+ CSSHref: s.cssHref,
+ Environment: strings.ToUpper(s.environment),
+ ShowBanner: s.environment != "" && s.environment != "production",
+ }
+}
diff --git a/web/handlers.go b/web/handlers.go
new file mode 100644
index 0000000000000000000000000000000000000000..b23cfa17db7d9168f37da12fc69e21a033bad792
--- /dev/null
+++ b/web/handlers.go
@@ -0,0 +1,429 @@
+package web
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "html/template"
+ "net/http"
+ "strings"
+
+ "github.com/go-chi/chi/v5"
+ "github.com/sirupsen/logrus"
+
+ "go.bigb.es/sourcehut-compare/authz"
+ "go.bigb.es/sourcehut-compare/core"
+ "go.bigb.es/sourcehut-compare/gitx"
+)
+
+// recentCommitLimit bounds the first-parent history shown on the repo page.
+const recentCommitLimit = 20
+
+// compareLogLimit bounds the commit list on the compare page.
+const compareLogLimit = 50
+
+// ---- JSON transport (consumed by the front-end bundle) --------------------
+
+// jsonFile mirrors one gitx.FileChange for the browser. path is the plain repo
+// path with NO a/ or b/ prefix.
+type jsonFile struct {
+ Path string `json:"path"`
+ OldPath string `json:"oldPath"`
+ Status string `json:"status"`
+ Additions int `json:"additions"`
+ Deletions int `json:"deletions"`
+ Binary bool `json:"binary"`
+}
+
+type jsonSpec struct {
+ Base string `json:"base"`
+ Head string `json:"head"`
+ ThreeDot bool `json:"threeDot"`
+}
+
+type compareData struct {
+ Mode string `json:"mode"`
+ Patch string `json:"patch"`
+ Truncated bool `json:"truncated"`
+ Files []jsonFile `json:"files"`
+ Spec jsonSpec `json:"spec"`
+}
+
+// buildCompareJSON marshals the browser payload. json.Marshal escapes <, > and &
+// (Go's default HTML-safe mode), so the result is safe to drop verbatim inside a
+// ". The bytes are
+// returned as template.JS: any
+{{end}}
+
+{{define "scripts"}}
+
+{{end}}
diff --git a/web/templates/compare.html b/web/templates/compare.html
new file mode 100644
index 0000000000000000000000000000000000000000..ca07e5b98ae9a5496f8add33c7cf7969d159c01a
--- /dev/null
+++ b/web/templates/compare.html
@@ -0,0 +1,83 @@
+{{define "content"}}
+{{$owner := .Data.Owner}}
+{{$repo := .Data.RepoName}}
+{{$sep := "..."}}{{if not .Data.Spec.ThreeDot}}{{$sep = ".."}}{{end}}
+
+
+
+ ~{{$owner}}/{{$repo}}:
+ {{.Data.Spec.Base}}{{$sep}}{{.Data.Spec.Head}}
+
+
+ {{if .Data.Spec.ThreeDot}}
+ Three-dot comparison (symmetric difference from the merge base).
+ {{if .Data.MergeBase}}Merge base: {{shortsha .Data.MergeBase}}.{{end}}
+ {{else}}
+ Two-dot comparison (direct range {{.Data.Spec.Base}}..{{.Data.Spec.Head}}).
+ {{end}}
+
+
+
+
+{{if .Data.Truncated}}
+
+{{end}}
+
+
+
+
+ {{len .Data.Commits}} commit(s)
+
+ {{range .Data.Commits}}
+ -
+
{{.ShortSHA}}
+ {{.Subject}}
+ — {{.AuthorName}}, {{date .Date}}
+
+ {{end}}
+
+
+
+
+
+
+
+
{{len .Data.Files}} changed file(s)
+
+
+ | File | Status | + | − |
+
+
+ {{range .Data.Files}}
+
+
+ {{if and .OldPath (ne .OldPath .Path)}}{{.OldPath}} → {{end}}{{.Path}}
+ {{if .Binary}}BIN{{end}}
+ |
+ {{.Status}} |
+ {{if .Additions}}+{{.Additions}}{{end}} |
+ {{if .Deletions}}-{{.Deletions}}{{end}} |
+
+ {{end}}
+
+
+
+
+
+
+
+
+
+
+
+{{end}}
+
+{{define "scripts"}}
+
+{{end}}
diff --git a/web/templates/error.html b/web/templates/error.html
new file mode 100644
index 0000000000000000000000000000000000000000..52baa57535cda3a941aa121a6ea817cc23b11aed
--- /dev/null
+++ b/web/templates/error.html
@@ -0,0 +1,9 @@
+{{define "content"}}
+
+{{end}}
diff --git a/web/templates/index.html b/web/templates/index.html
new file mode 100644
index 0000000000000000000000000000000000000000..dff25a542542c395748bf7266a4179cc52eb4062
--- /dev/null
+++ b/web/templates/index.html
@@ -0,0 +1,54 @@
+{{define "content"}}
+
+
+
{{.SiteName}} compare
+
+ Compare two references — branches, tags or commits — of any git repository
+ on this instance, and review the diff file by file.
+
+
+
+
+{{if .Data.LoggedIn}}
+
+
+
Your repositories
+ {{if .Data.Repos}}
+
+ {{range .Data.Repos}}
+ -
+ ~{{$.Username}}/{{.Name}}
+ {{.Visibility}}
+ {{if .Description}} — {{.Description}}{{end}}
+
+ {{end}}
+
+ {{else}}
+
You have no repositories yet.
+ {{end}}
+
+
+{{else}}
+
+
+
+ Log in to list your own
+ repositories, or jump directly to any repository below.
+
+
+
+
+{{end}}
+{{end}}
diff --git a/web/templates/layout.html b/web/templates/layout.html
new file mode 100644
index 0000000000000000000000000000000000000000..8e57b7e5053030444672803c622eee51d29b61bc
--- /dev/null
+++ b/web/templates/layout.html
@@ -0,0 +1,61 @@
+
+
+
+
+
+ {{.Title}}
+
+
+ {{block "head" .}}{{end}}
+
+
+ {{if .ShowBanner}}
+
+ {{.Environment}} ENVIRONMENT
+
+ {{end}}
+
+
+ {{template "content" .}}
+
+ {{block "scripts" .}}{{end}}
+
+
diff --git a/web/templates/repo.html b/web/templates/repo.html
new file mode 100644
index 0000000000000000000000000000000000000000..aa5dacbbdb8e9097e1b2df6fe450c5f9559e16d5
--- /dev/null
+++ b/web/templates/repo.html
@@ -0,0 +1,72 @@
+{{define "content"}}
+{{$owner := .Data.Owner}}
+{{$repo := .Data.Info.Name}}
+
+
+
~{{$owner}}/{{$repo}}
+ {{.Data.Info.Visibility}}
+
+ {{if .Data.Info.Description}}
{{.Data.Info.Description}}
{{end}}
+ {{if .Data.DefaultBranch}}
+
Default branch: {{.Data.DefaultBranch}}
+ {{end}}
+
+
+
+
+
+
+
+
Recent commits
+ {{if .Data.Commits}}
+
+ {{range .Data.Commits}}
+ -
+
{{.ShortSHA}}
+ {{.Subject}}
+ — {{.AuthorName}}, {{date .Date}}
+
+ {{end}}
+
+ {{else}}
+
No commits.
+ {{end}}
+
+
+{{end}}
diff --git a/web/web_test.go b/web/web_test.go
new file mode 100644
index 0000000000000000000000000000000000000000..3a43837872fd559fca31f31eff48a56e956f1121
--- /dev/null
+++ b/web/web_test.go
@@ -0,0 +1,485 @@
+package web
+
+import (
+ "context"
+ "crypto/rand"
+ "encoding/base64"
+ "encoding/json"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "git.sr.ht/~sircmpwn/core-go/config"
+ "git.sr.ht/~sircmpwn/core-go/crypto"
+ "github.com/fernet/fernet-go"
+ "github.com/go-chi/chi/v5"
+ "github.com/vaughan0/go-ini"
+
+ "go.bigb.es/sourcehut-compare/authz"
+ "go.bigb.es/sourcehut-compare/core"
+ "go.bigb.es/sourcehut-compare/gitx"
+)
+
+// testConf carries the crypto keys established in TestMain so tests can seal
+// unified-login cookies.
+var testConf ini.File
+
+func TestMain(m *testing.M) {
+ var fk fernet.Key
+ if err := fk.Generate(); err != nil {
+ panic("generate fernet key: " + err.Error())
+ }
+ seed := make([]byte, 32)
+ if _, err := rand.Read(seed); err != nil {
+ panic("generate webhook seed: " + err.Error())
+ }
+ testConf = ini.File{
+ "sr.ht": ini.Section{"network-key": fk.Encode()},
+ "webhooks": ini.Section{"private-key": base64.StdEncoding.EncodeToString(seed)},
+ }
+ crypto.InitCrypto(testConf)
+ os.Exit(m.Run())
+}
+
+// ---- fixtures -------------------------------------------------------------
+
+// stubAuthorizer is a fixed-map Authorizer with optional error injection.
+type stubAuthorizer struct {
+ repos map[string]authz.RepoInfo // key "owner/name"
+ my []authz.RepoInfo
+ err error // when set, every call fails with this (transport-style) error
+}
+
+func (s *stubAuthorizer) Repo(_ context.Context, _, owner, name string) (*authz.RepoInfo, error) {
+ if s.err != nil {
+ return nil, s.err
+ }
+ owner = strings.TrimPrefix(owner, "~")
+ if info, ok := s.repos[owner+"/"+name]; ok {
+ return &info, nil
+ }
+ return nil, core.ErrNotFound
+}
+
+func (s *stubAuthorizer) MyRepos(_ context.Context, _ string) ([]authz.RepoInfo, error) {
+ if s.err != nil {
+ return nil, s.err
+ }
+ return s.my, nil
+}
+
+// gitFixture drives the git CLI to build a bare repo at /~alice/demo:
+//
+// c1 (main): add a.txt
+// c2 (main): add b.txt, edit a.txt <- main HEAD
+// feature off c1: add feature.txt <- branch "feature"
+//
+// It returns the repos root and the full SHA of main's HEAD.
+func gitFixture(t *testing.T) (root, mainSHA string) {
+ t.Helper()
+ if _, err := exec.LookPath("git"); err != nil {
+ t.Skipf("git not available: %v", err)
+ }
+ root = t.TempDir()
+ work := t.TempDir()
+
+ git := func(date string, args ...string) string {
+ cmd := exec.Command("git", args...)
+ cmd.Dir = work
+ cmd.Env = append(os.Environ(),
+ "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null",
+ "GIT_TERMINAL_PROMPT=0", "LC_ALL=C",
+ "GIT_AUTHOR_NAME=Alice", "GIT_AUTHOR_EMAIL=alice@example.com",
+ "GIT_COMMITTER_NAME=Alice", "GIT_COMMITTER_EMAIL=alice@example.com",
+ "GIT_AUTHOR_DATE="+date, "GIT_COMMITTER_DATE="+date,
+ )
+ out, err := cmd.CombinedOutput()
+ if err != nil {
+ t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out)
+ }
+ return string(out)
+ }
+ write := func(name, data string) {
+ if err := os.WriteFile(filepath.Join(work, name), []byte(data), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }
+
+ d1, d2, d3 := "2024-01-01T00:00:00Z", "2024-01-02T00:00:00Z", "2024-01-03T00:00:00Z"
+ git(d1, "init", "-b", "main")
+ write("a.txt", "hello\nworld\n")
+ git(d1, "add", "a.txt")
+ git(d1, "commit", "-m", "add a.txt")
+ git(d2, "branch", "feature")
+ write("a.txt", "hello\nworld\nmore\n")
+ write("b.txt", "bee\n")
+ git(d2, "add", "a.txt", "b.txt")
+ git(d2, "commit", "-m", "add b, edit a")
+ git(d3, "checkout", "feature")
+ write("feature.txt", "feature\n")
+ git(d3, "add", "feature.txt")
+ git(d3, "commit", "-m", "add feature.txt")
+ git(d3, "checkout", "main")
+
+ if err := os.MkdirAll(filepath.Join(root, "~alice"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ bare := filepath.Join(root, "~alice", "demo")
+ git(d3, "clone", "--bare", work, bare)
+
+ mainSHA = strings.TrimSpace(runGit(t, bare, "rev-parse", "main"))
+ return root, mainSHA
+}
+
+func runGit(t *testing.T, dir string, args ...string) string {
+ t.Helper()
+ cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
+ out, err := cmd.CombinedOutput()
+ if err != nil {
+ t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out)
+ }
+ return string(out)
+}
+
+// testServer wires a Server (fixture repo + given authorizer) behind the same
+// middleware the cmd layer installs, and returns the handler.
+func testServer(t *testing.T, root string, az authz.Authorizer) http.Handler {
+ t.Helper()
+ conf := ini.File{
+ "sr.ht": ini.Section{
+ "network-key": testConf.Section("sr.ht")["network-key"],
+ "site-name": "sourcehut",
+ "environment": "development",
+ },
+ "webhooks": ini.Section{"private-key": testConf.Section("webhooks")["private-key"]},
+ "compare.sr.ht": ini.Section{"origin": "https://compare.example"},
+ "meta.sr.ht": ini.Section{"origin": "https://meta.example"},
+ "git.sr.ht": ini.Section{"origin": "https://git.example", "repos": root},
+ // Extra service sections to exercise nav ordering/exclusions.
+ "todo.sr.ht": ini.Section{"origin": "https://todo.example"},
+ "builds.sr.ht": ini.Section{"origin": "https://builds.example"},
+ "lists.sr.ht": ini.Section{"origin": "https://lists.example"},
+ "paste.sr.ht": ini.Section{"origin": "https://paste.example"},
+ "pages.sr.ht": ini.Section{"origin": "https://pages.example"},
+ "hub.sr.ht": ini.Section{"origin": "https://hub.example"},
+ }
+ srv, err := New(conf, az)
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ r := chi.NewRouter()
+ r.Use(config.Middleware(conf, "compare.sr.ht"))
+ r.Use(authz.Middleware())
+ srv.Register(r)
+ return r
+}
+
+// login seals a unified-login cookie for the given user onto a request.
+func login(req *http.Request, user string) {
+ payload, _ := json.Marshal(map[string]string{"name": user})
+ req.AddCookie(&http.Cookie{Name: authz.CookieName, Value: string(crypto.Encrypt(payload))})
+}
+
+func demoAuthorizer() *stubAuthorizer {
+ return &stubAuthorizer{
+ repos: map[string]authz.RepoInfo{
+ "alice/demo": {ID: 1, Name: "demo", Description: "the demo repo", Visibility: "PUBLIC"},
+ },
+ my: []authz.RepoInfo{
+ {ID: 1, Name: "demo", Description: "the demo repo", Visibility: "PUBLIC"},
+ {ID: 2, Name: "secret", Description: "", Visibility: "PRIVATE"},
+ },
+ }
+}
+
+func get(t *testing.T, h http.Handler, target string, user string) *httptest.ResponseRecorder {
+ t.Helper()
+ req := httptest.NewRequest(http.MethodGet, target, nil)
+ if user != "" {
+ login(req, user)
+ }
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ return rec
+}
+
+// ---- tests ----------------------------------------------------------------
+
+func TestComparePage(t *testing.T) {
+ root, _ := gitFixture(t)
+ h := testServer(t, root, demoAuthorizer())
+
+ rec := get(t, h, "/~alice/demo/compare/main...feature", "")
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200\n%s", rec.Code, rec.Body.String())
+ }
+ body := rec.Body.String()
+ if !strings.Contains(body, `id="compare-data"`) {
+ t.Fatal("missing compare-data script")
+ }
+ if !strings.Contains(body, `src="/static/bundle.js"`) {
+ t.Fatal("missing bundle.js script tag")
+ }
+
+ cd := extractCompareData(t, body)
+ if cd.Mode != "compare" {
+ t.Fatalf("mode = %q, want compare", cd.Mode)
+ }
+ if cd.Spec.Base != "main" || cd.Spec.Head != "feature" || !cd.Spec.ThreeDot {
+ t.Fatalf("spec = %+v, want base=main head=feature threeDot=true", cd.Spec)
+ }
+ // feature adds feature.txt relative to the merge base (c1).
+ found := false
+ for _, f := range cd.Files {
+ if f.Path == "feature.txt" {
+ found = true
+ if strings.HasPrefix(f.Path, "a/") || strings.HasPrefix(f.Path, "b/") {
+ t.Fatalf("file path has diff prefix: %q", f.Path)
+ }
+ }
+ }
+ if !found {
+ t.Fatalf("feature.txt not in files: %+v", cd.Files)
+ }
+}
+
+func TestTwoDotVsThreeDot(t *testing.T) {
+ root, _ := gitFixture(t)
+ h := testServer(t, root, demoAuthorizer())
+
+ two := extractCompareData(t, get(t, h, "/~alice/demo/compare/main..feature", "").Body.String())
+ if two.Spec.ThreeDot {
+ t.Fatal("main..feature parsed as three-dot")
+ }
+ three := extractCompareData(t, get(t, h, "/~alice/demo/compare/main...feature", "").Body.String())
+ if !three.Spec.ThreeDot {
+ t.Fatal("main...feature parsed as two-dot")
+ }
+}
+
+func TestComparePatchRoute(t *testing.T) {
+ root, _ := gitFixture(t)
+ h := testServer(t, root, demoAuthorizer())
+
+ rec := get(t, h, "/~alice/demo/compare/main...feature.patch", "")
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200", rec.Code)
+ }
+ if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/plain") {
+ t.Fatalf("content-type = %q, want text/plain", ct)
+ }
+ if !strings.Contains(rec.Body.String(), "diff --git") {
+ t.Fatalf("patch body missing diff header:\n%s", rec.Body.String())
+ }
+}
+
+func TestCommitPage(t *testing.T) {
+ root, mainSHA := gitFixture(t)
+ h := testServer(t, root, demoAuthorizer())
+
+ rec := get(t, h, "/~alice/demo/commit/"+mainSHA, "")
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200\n%s", rec.Code, rec.Body.String())
+ }
+ cd := extractCompareData(t, rec.Body.String())
+ if cd.Mode != "commit" {
+ t.Fatalf("mode = %q, want commit", cd.Mode)
+ }
+ // c2 modifies a.txt and adds b.txt.
+ if len(cd.Files) == 0 {
+ t.Fatal("commit page has no files")
+ }
+}
+
+func TestCommitPatchRoute(t *testing.T) {
+ root, mainSHA := gitFixture(t)
+ h := testServer(t, root, demoAuthorizer())
+ rec := get(t, h, "/~alice/demo/commit/"+mainSHA+".patch", "")
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200", rec.Code)
+ }
+ if !strings.Contains(rec.Body.String(), "diff --git") {
+ t.Fatal("commit patch missing diff header")
+ }
+}
+
+func TestUnknownRepoIs404(t *testing.T) {
+ root, _ := gitFixture(t)
+ h := testServer(t, root, demoAuthorizer())
+ rec := get(t, h, "/~alice/nope/compare/main...feature", "")
+ if rec.Code != http.StatusNotFound {
+ t.Fatalf("status = %d, want 404", rec.Code)
+ }
+}
+
+func TestPrivateRepoInvisibleIs404(t *testing.T) {
+ // Authorizer reports the repo as not-found (visibility hidden) even though
+ // the bare repo exists on disk.
+ root, _ := gitFixture(t)
+ az := &stubAuthorizer{repos: map[string]authz.RepoInfo{}}
+ h := testServer(t, root, az)
+ rec := get(t, h, "/~alice/demo", "")
+ if rec.Code != http.StatusNotFound {
+ t.Fatalf("status = %d, want 404", rec.Code)
+ }
+}
+
+func TestAuthorizerTransportErrorIs500(t *testing.T) {
+ root, _ := gitFixture(t)
+ az := &stubAuthorizer{err: errors.New("graphql unreachable")}
+ h := testServer(t, root, az)
+ rec := get(t, h, "/~alice/demo", "")
+ if rec.Code != http.StatusInternalServerError {
+ t.Fatalf("status = %d, want 500 (transport error must not be 404)", rec.Code)
+ }
+}
+
+func TestBadRefIs400(t *testing.T) {
+ root, _ := gitFixture(t)
+ h := testServer(t, root, demoAuthorizer())
+ rec := get(t, h, "/~alice/demo/compare/..bad", "")
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("status = %d, want 400", rec.Code)
+ }
+}
+
+func TestIndexAnonymous(t *testing.T) {
+ root, _ := gitFixture(t)
+ h := testServer(t, root, demoAuthorizer())
+ rec := get(t, h, "/", "")
+ body := rec.Body.String()
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d", rec.Code)
+ }
+ if !strings.Contains(body, `action="/jump"`) {
+ t.Fatal("anonymous index missing jump form")
+ }
+ if !strings.Contains(body, "return_to=") {
+ t.Fatal("login URL missing return_to")
+ }
+}
+
+func TestIndexLoggedIn(t *testing.T) {
+ root, _ := gitFixture(t)
+ h := testServer(t, root, demoAuthorizer())
+ rec := get(t, h, "/", "bigbes")
+ body := rec.Body.String()
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d", rec.Code)
+ }
+ if !strings.Contains(body, "/~bigbes/demo") {
+ t.Fatal("logged-in index missing repo link from MyRepos")
+ }
+ if !strings.Contains(body, "PRIVATE") {
+ t.Fatal("logged-in index missing visibility badge")
+ }
+}
+
+func TestNavExclusionsAndActive(t *testing.T) {
+ root, _ := gitFixture(t)
+ h := testServer(t, root, demoAuthorizer())
+ // Nav switcher only renders for a logged-in viewer.
+ body := get(t, h, "/", "bigbes").Body.String()
+
+ if !strings.Contains(body, "https://git.example") || !strings.Contains(body, "https://todo.example") {
+ t.Fatal("nav missing expected services")
+ }
+ if strings.Contains(body, "https://paste.example") || strings.Contains(body, "https://pages.example") {
+ t.Fatal("nav must exclude paste/pages")
+ }
+ // hub is the brand, never a switcher item.
+ nav := body[strings.Index(body, `")]
+ if strings.Contains(nav, "hub.example") {
+ t.Fatal("hub must not appear in the switcher list")
+ }
+ if !strings.Contains(nav, `nav-item active`) {
+ t.Fatal("compare should be the active nav item")
+ }
+}
+
+func TestHealthz(t *testing.T) {
+ root, _ := gitFixture(t)
+ h := testServer(t, root, demoAuthorizer())
+ rec := get(t, h, "/healthz", "")
+ if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "ok") {
+ t.Fatalf("healthz = %d %q", rec.Code, rec.Body.String())
+ }
+}
+
+func TestStaticBundleAndCSS(t *testing.T) {
+ root, _ := gitFixture(t)
+ srvHandler := testServer(t, root, demoAuthorizer())
+
+ rec := get(t, srvHandler, "/static/bundle.js", "")
+ if rec.Code != http.StatusOK {
+ t.Fatalf("bundle.js status = %d", rec.Code)
+ }
+ if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "javascript") {
+ t.Fatalf("bundle.js content-type = %q", ct)
+ }
+
+ css := cssName(t)
+ rec = get(t, srvHandler, "/static/"+css, "")
+ if rec.Code != http.StatusOK {
+ t.Fatalf("css status = %d", rec.Code)
+ }
+ if cc := rec.Header().Get("Cache-Control"); !strings.Contains(cc, "immutable") {
+ t.Fatalf("hashed css cache-control = %q, want immutable", cc)
+ }
+}
+
+// TestCompareJSONNoScriptBreakout verifies a file path containing ""
+// cannot break out of the embedded .txt", Status: "A", Additions: 1}}
+ html, err := buildCompareJSON("compare", patch, files, jsonSpec{Base: "a", Head: "b"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ s := string(html)
+ // The '<' of "" must be escaped, so no literal "`
+ i := strings.Index(body, open)
+ if i < 0 {
+ t.Fatalf("no compare-data script in body:\n%s", body)
+ }
+ rest := body[i+len(open):]
+ j := strings.Index(rest, "")
+ if j < 0 {
+ t.Fatal("compare-data script not closed")
+ }
+ var cd compareData
+ if err := json.Unmarshal([]byte(rest[:j]), &cd); err != nil {
+ t.Fatalf("decode compare-data: %v\nraw: %s", err, rest[:j])
+ }
+ return cd
+}