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 }