From 714752996fc44e8b173a369b66e8648a74da5656 Mon Sep 17 00:00:00 2001 From: bigbes Date: Sat, 18 Jul 2026 21:15:04 +0300 Subject: [PATCH] web: http server, sourcehut chrome, compare/commit pages Implement the compare.sr.ht HTTP layer over the committed core/gitx/authz packages: - Server assembly (New) from shared SourceHut config: [git.sr.ht] repos, [meta.sr.ht]/[compare.sr.ht] origins, [sr.ht] site-name/environment, and the hashed stylesheet resolved by globbing the embedded static FS. - Routes: landing, repo page, compare (base..base...head, .patch escape hatch), single-commit (+.patch), embedded static assets, healthz. Compare form GET canonicalizes to a clean URL via 302. - SourceHut chrome port to html/template: nav service switcher (canonical order, paste/pages/hub excluded, compare active), login/logout with return_to, environment banner. - Embedded compare-data JSON (template.JS so html/template's script-context escaper leaves it verbatim; json.Marshal's HTML escaping blocks a breakout) plus the vendored bundle.js scaffold. - httptest coverage with a stub Authorizer and a real git-CLI fixture repo. The cmd layer must wire, in order: RealIP, Recoverer, Logger, config.Middleware(conf,"compare.sr.ht"), authz.Middleware. --- web/chrome.go | 121 +++++++++ web/handlers.go | 429 ++++++++++++++++++++++++++++++++ web/router.go | 58 +++++ web/server.go | 120 +++++++++ web/templates.go | 94 +++++++ web/templates/commit.html | 81 +++++++ web/templates/compare.html | 83 +++++++ web/templates/error.html | 9 + web/templates/index.html | 54 +++++ web/templates/layout.html | 61 +++++ web/templates/repo.html | 72 ++++++ web/web_test.go | 485 +++++++++++++++++++++++++++++++++++++ 12 files changed, 1667 insertions(+) create mode 100644 web/chrome.go create mode 100644 web/handlers.go create mode 100644 web/router.go create mode 100644 web/server.go create mode 100644 web/templates.go create mode 100644 web/templates/commit.html create mode 100644 web/templates/compare.html create mode 100644 web/templates/error.html create mode 100644 web/templates/index.html create mode 100644 web/templates/layout.html create mode 100644 web/templates/repo.html create mode 100644 web/web_test.go 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}} +
+ This diff is too large to render in full. + Download the raw patch to see everything. +
+{{end}} + +
+
+
+ {{len .Data.Commits}} commit(s) +
    + {{range .Data.Commits}} +
  • + {{.ShortSHA}} + {{.Subject}} + — {{.AuthorName}}, {{date .Date}} +
  • + {{end}} +
+
+
+
+ +
+
+

{{len .Data.Files}} changed file(s)

+ + + + + + {{range .Data.Files}} + + + + + + + {{end}} + +
FileStatus+
+ {{if and .OldPath (ne .OldPath .Path)}}{{.OldPath}} → {{end}}{{.Path}} + {{if .Binary}}BIN{{end}} + {{.Status}}{{if .Additions}}+{{.Additions}}{{end}}{{if .Deletions}}-{{.Deletions}}{{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"}} +
+
+

{{.Data.Status}} — {{.Data.StatusText}}

+ {{if .Data.Message}}

{{.Data.Message}}

{{end}} +

Return to the landing page.

+
+
+{{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}} +
+
+ +
+
+

Compare

+
+
+ + +
+ ... +
+ + +
+
+ +
+ +
+
+
+ +
+
+

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