package web import ( "bytes" "context" "encoding/json" "errors" "log/slog" "net/http" "net/http/httptest" "os" "os/exec" "path/filepath" "strings" "testing" "github.com/go-chi/chi/v5" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "sourcecraft.dev/bigbes/sr-ht-core/config" "sourcecraft.dev/bigbes/sr-ht-core/crypto" "sourcecraft.dev/bigbes/sr-ht-ecore/assets" "sourcecraft.dev/bigbes/sr-ht-ecore/csrf" "sourcecraft.dev/bigbes/sr-ht-ecore/ecoretest" "sourcecraft.dev/bigbes/sr-ht-ecore/login" "sourcecraft.dev/bigbes/sr-ht-ecore/pages" "sourcecraft.dev/bigbes/sr-ht-compare/authz" "sourcecraft.dev/bigbes/sr-ht-compare/core" "sourcecraft.dev/bigbes/sr-ht-compare/gitx" ) // TestMain seeds core-go's process-global crypto with ecore's fixed test // keyset, which is what lets logIn below seal a unified-login cookie the login // middleware can open again. func TestMain(m *testing.M) { ecoretest.InitCrypto() 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 } // panicAuthorizer fails the way a bug fails: not by returning an error, but by // panicking inside a handler. type panicAuthorizer struct{ stubAuthorizer } func (p *panicAuthorizer) Repo(context.Context, string, string, string) (*authz.RepoInfo, error) { panic("the authorizer exploded") } // 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() require.NoErrorf(t, err, "git %s:\n%s", strings.Join(args, " "), out) return string(out) } write := func(name, data string) { require.NoError(t, os.WriteFile(filepath.Join(work, name), []byte(data), 0o644)) } 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") require.NoError(t, os.MkdirAll(filepath.Join(root, "~alice"), 0o755)) 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() require.NoErrorf(t, err, "git %s:\n%s", strings.Join(args, " "), 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() // The synthetic instance of ecoretest, with the two keys this service adds // to it: the bare-repository root gitx reads, and a non-production // environment so the banner is on the page TestChromeIsRendered inspects. conf := ecoretest.Config(configSection, ecoretest.Set("git.sr.ht", "repos", root), ecoretest.Set("sr.ht", "environment", "development"), ) srv, err := New(conf, az) require.NoError(t, err, "New") r := chi.NewRouter() r.Use(config.Middleware(conf, "compare.sr.ht")) r.Use(login.Optional()) srv.Register(r) return r } // logIn seals a unified-login cookie for the given user onto a request. It is // not called login because that is the package that opens the cookie again. func logIn(req *http.Request, user string) { payload, _ := json.Marshal(map[string]string{"name": user}) req.AddCookie(&http.Cookie{Name: login.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", "") require.Equalf(t, http.StatusOK, rec.Code, "body:\n%s", rec.Body.String()) body := rec.Body.String() assert.Contains(t, body, `id="compare-data"`, "missing compare-data script") assert.Contains(t, body, `data-diff-wrap`, "missing long-line wrapping control") assert.Contains(t, body, `src="/static/`+bundleName(t)+`"`, "missing hashed bundle script tag") cd := extractCompareData(t, body) assert.Equal(t, "compare", cd.Mode) assert.Equal(t, jsonSpec{Base: "main", Head: "feature", ThreeDot: true}, cd.Spec) // feature adds feature.txt relative to the merge base (c1), and paths carry // no a/ or b/ diff prefix. found := false for _, f := range cd.Files { if f.Path == "feature.txt" { found = true } assert.NotRegexp(t, `^[ab]/`, f.Path, "file path has a diff prefix") } assert.Truef(t, found, "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()) assert.False(t, two.Spec.ThreeDot, "main..feature parsed as three-dot") three := extractCompareData(t, get(t, h, "/~alice/demo/compare/main...feature", "").Body.String()) assert.True(t, three.Spec.ThreeDot, "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", "") require.Equal(t, http.StatusOK, rec.Code) assert.True(t, strings.HasPrefix(rec.Header().Get("Content-Type"), "text/plain"), "content-type = %q, want text/plain", rec.Header().Get("Content-Type")) assert.Contains(t, rec.Body.String(), "diff --git", "patch body missing diff header") } func TestCommitPage(t *testing.T) { root, mainSHA := gitFixture(t) h := testServer(t, root, demoAuthorizer()) rec := get(t, h, "/~alice/demo/commit/"+mainSHA, "") require.Equalf(t, http.StatusOK, rec.Code, "body:\n%s", rec.Body.String()) assert.Contains(t, rec.Body.String(), `data-diff-wrap`, "missing long-line wrapping control") cd := extractCompareData(t, rec.Body.String()) assert.Equal(t, "commit", cd.Mode) // c2 modifies a.txt and adds b.txt. assert.NotEmpty(t, cd.Files, "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", "") require.Equal(t, http.StatusOK, rec.Code) assert.Contains(t, rec.Body.String(), "diff --git", "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", "") assert.Equal(t, http.StatusNotFound, 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) h := testServer(t, root, &stubAuthorizer{repos: map[string]authz.RepoInfo{}}) rec := get(t, h, "/~alice/demo", "") assert.Equal(t, http.StatusNotFound, rec.Code) } func TestAuthorizerTransportErrorIs500(t *testing.T) { root, _ := gitFixture(t) h := testServer(t, root, &stubAuthorizer{err: errors.New("graphql unreachable")}) rec := get(t, h, "/~alice/demo", "") assert.Equal(t, http.StatusInternalServerError, rec.Code, "a transport error must not be reported as 404") } func TestBadRefIs400(t *testing.T) { root, _ := gitFixture(t) h := testServer(t, root, demoAuthorizer()) rec := get(t, h, "/~alice/demo/compare/..bad", "") assert.Equal(t, http.StatusBadRequest, rec.Code) } func TestIndexAnonymous(t *testing.T) { root, _ := gitFixture(t) h := testServer(t, root, demoAuthorizer()) rec := get(t, h, "/", "") require.Equal(t, http.StatusOK, rec.Code) body := rec.Body.String() assert.Contains(t, body, `action="/jump"`, "anonymous index missing jump form") assert.Contains(t, body, "return_to=", "login URL missing return_to") } func TestIndexLoggedIn(t *testing.T) { root, _ := gitFixture(t) h := testServer(t, root, demoAuthorizer()) rec := get(t, h, "/", "bigbes") require.Equal(t, http.StatusOK, rec.Code) body := rec.Body.String() assert.Contains(t, body, "/~bigbes/demo", "logged-in index missing repo link from MyRepos") assert.Contains(t, body, `private`, "logged-in index missing the visibility label on the event card") } // TestChromeIsRendered checks that the layout really draws the shared partials // of sr-ht-ecore — the brand's red service label and the login block. What the // switcher contains and how it is ordered is ecore's business and ecore's test; // this one only asserts that this service's layout invokes the chrome at all, // which is the wiring a bad merge here would break. func TestChromeIsRendered(t *testing.T) { root, _ := gitFixture(t) h := testServer(t, root, demoAuthorizer()) anon := get(t, h, "/", "").Body.String() assert.Contains(t, anon, `compare`, "the brand's service label is missing") assert.Contains(t, anon, "Log in", "an anonymous viewer must be offered the login") // The environment is "development" in the test config, so the banner shows. assert.Contains(t, anon, "DEVELOPMENT ENVIRONMENT", "missing the non-production banner") viewer := get(t, h, "/", "bigbes").Body.String() assert.Contains(t, viewer, "Logged in as", "the login block does not name the viewer") assert.Contains(t, viewer, "https://hub.example/~bigbes", "the profile link should prefer hub's ~username page") assert.Contains(t, viewer, "https://todo.example", "the switcher is missing a sibling service") } // TestDiffPagesAreFullBleed pins the one chrome decision compare makes for // itself: the diff views ask for the full window, because a side-by-side diff in // the centered container is a column of code half the page wide. func TestDiffPagesAreFullBleed(t *testing.T) { root, mainSHA := gitFixture(t) h := testServer(t, root, demoAuthorizer()) for _, tc := range []struct { name, target, want string }{ {"index", "/", `
`}, {"repo", "/~alice/demo", `
`}, {"compare", "/~alice/demo/compare/main...feature", `
`}, {"commit", "/~alice/demo/commit/" + mainSHA, `
`}, } { t.Run(tc.name, func(t *testing.T) { rec := get(t, h, tc.target, "") require.Equal(t, http.StatusOK, rec.Code) assert.Contains(t, rec.Body.String(), tc.want) }) } } func TestHealthz(t *testing.T) { root, _ := gitFixture(t) h := testServer(t, root, demoAuthorizer()) rec := get(t, h, "/healthz", "") require.Equal(t, http.StatusOK, rec.Code) assert.Contains(t, rec.Body.String(), "ok") } func TestStaticBundleAndCSS(t *testing.T) { root, _ := gitFixture(t) h := testServer(t, root, demoAuthorizer()) bundle := bundleName(t) rec := get(t, h, "/static/"+bundle, "") require.Equalf(t, http.StatusOK, rec.Code, "%s", bundle) assert.Contains(t, rec.Header().Get("Content-Type"), "javascript") assert.Contains(t, rec.Header().Get("Cache-Control"), "immutable", "a hashed bundle must be cacheable forever") css := cssName(t) rec = get(t, h, "/static/"+css, "") require.Equalf(t, http.StatusOK, rec.Code, "%s", css) assert.Contains(t, rec.Header().Get("Cache-Control"), "immutable", "a hashed stylesheet must be cacheable forever") assert.Empty(t, rec.Header().Get("Vary"), "an asset served identically to everybody must not be declared to vary on Cookie") // An asset whose name carries no hash cannot be immutable: the next deploy // serves different bytes at the same URL. rec = get(t, h, "/static/logo.svg", "") require.Equal(t, http.StatusOK, rec.Code) assert.Equal(t, "public, max-age=3600", rec.Header().Get("Cache-Control")) } // TestStaticListingRefused pins what the hand-rolled static route published // before this service adopted assets.Handler: an http.FileServer answers a // directory with a listing, so /static/ was the whole inventory of the binary — // every vendored artefact and the hashed names that fingerprint the build — as // a public, hour-cacheable page. func TestStaticListingRefused(t *testing.T) { root, _ := gitFixture(t) h := testServer(t, root, demoAuthorizer()) for _, target := range []string{"/static/", "/static/nothing-here.css"} { t.Run(target, func(t *testing.T) { rec := get(t, h, target, "") assert.Equal(t, http.StatusNotFound, rec.Code) // A Go file-server listing is a list of name; the // stylesheet's own in the chrome is not, which is why the // entry and not the name is what this looks for. assert.NotContains(t, rec.Body.String(), ``, "the response is a directory listing") // And it is a page, not net/http's plain-text dead end. assert.Contains(t, rec.Body.String(), "navbar-brand") }) } } // TestPanicIsAnErrorPage checks the recovery middleware through the wiring // Register actually installs, not through a router assembled by the test: a // panicking handler must produce this service's chrome-wrapped 500 rather than // the dropped connection net/http answers a panic with. // // It also pins where the report goes. ecore's middleware logs the panic through // slog's *default* logger, so a service that never calls slog.SetDefault sends // its stack traces to Go's plain stderr handler; the capture below is the same // seam initLogging writes to in the cmd layer. func TestPanicIsAnErrorPage(t *testing.T) { root, _ := gitFixture(t) h := testServer(t, root, &panicAuthorizer{}) var captured bytes.Buffer previous := slog.Default() slog.SetDefault(slog.New(slog.NewTextHandler(&captured, nil))) t.Cleanup(func() { slog.SetDefault(previous) }) rec := get(t, h, "/~alice/demo", "") require.Equal(t, http.StatusInternalServerError, rec.Code) assert.Contains(t, rec.Body.String(), pages.InternalMessage) assert.Contains(t, rec.Body.String(), "navbar-brand", "the 500 has no chrome") assert.NotContains(t, rec.Body.String(), "the authorizer exploded", "the panic value must not reach the viewer") logged := captured.String() assert.Contains(t, logged, "the authorizer exploded", "the panic was not reported") assert.Contains(t, logged, "/~alice/demo", "the report does not name the request") } // TestPagesAreNotCacheable pins the policy every page behind the login cookie // carries: these URLs say nothing about the viewer, so a cache with no // instruction would hand one account's page to the next request. func TestPagesAreNotCacheable(t *testing.T) { root, _ := gitFixture(t) h := testServer(t, root, demoAuthorizer()) rec := get(t, h, "/", "bigbes") require.Equal(t, http.StatusOK, rec.Code) assert.Equal(t, "private, no-store", rec.Header().Get("Cache-Control")) assert.Equal(t, "Cookie, Authorization", rec.Header().Get("Vary")) } // TestNotFoundIsAPage checks that a URL the router does not have lands on the // shared error page rather than chi's plain-text 404, and that it says the // instance's standard sentence — the same one a repository the viewer may not // see produces, which is what keeps the two indistinguishable. func TestNotFoundIsAPage(t *testing.T) { root, _ := gitFixture(t) h := testServer(t, root, demoAuthorizer()) for name, target := range map[string]string{ "unrouted": "/no/such/page", "hidden repo": "/~alice/nope", } { t.Run(name, func(t *testing.T) { rec := get(t, h, target, "") require.Equal(t, http.StatusNotFound, rec.Code) assert.Contains(t, rec.Body.String(), pages.NotFoundMessage) assert.Contains(t, rec.Body.String(), "navbar-brand", "the 404 has no chrome") }) } } // TestBadSpecKeepsItsOwnMessage is the other half of that rule: a 400 describes // something the viewer just typed, so it must not be replaced by a house phrase // that sends them back to the form with nothing to change. func TestBadSpecKeepsItsOwnMessage(t *testing.T) { root, _ := gitFixture(t) h := testServer(t, root, demoAuthorizer()) rec := get(t, h, "/~alice/demo/compare/..bad", "") require.Equal(t, http.StatusBadRequest, rec.Code) assert.Contains(t, rec.Body.String(), core.ErrBadRef.Error()) } // TestUnsafeMethodRefused pins the same-origin guard. compare serves no POST // today, so what this asserts is that the guard is installed at all — the day a // form arrives it is already covered. // // The probe is a path with no route, because that is the one unsafe request // this router hands to the guard: chi resolves a method before a group's // middleware runs, so a POST to a GET-only path is its own 405, while a POST to // nothing at all reaches the not-found handler through the whole chain. The // consequence to know about is in the first arm — such a request is now refused // 403 rather than answered 404. func TestUnsafeMethodRefused(t *testing.T) { root, _ := gitFixture(t) h := testServer(t, root, demoAuthorizer()) post := func(origin string) *httptest.ResponseRecorder { req := httptest.NewRequest(http.MethodPost, "/no/such/page", nil) if origin != "" { req.Header.Set("Origin", origin) } rec := httptest.NewRecorder() h.ServeHTTP(rec, req) return rec } rec := post("") assert.Equal(t, http.StatusForbidden, rec.Code, "a request carrying neither Origin nor Referer cannot be shown to have come from us") assert.Contains(t, rec.Body.String(), csrf.Message) rec = post("https://evil.example") assert.Equal(t, http.StatusForbidden, rec.Code, "a cross-origin POST must be refused") rec = post(ecoretest.Origin(configSection)) assert.Equal(t, http.StatusNotFound, rec.Code, "a same-origin POST passes the guard and meets the router's own answer") } // 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"}) require.NoError(t, err) s := string(html) // The '<' of "" must be escaped, so no literal ".css) // out of the embedded tree, through the same call the server resolves it with. func cssName(t *testing.T) string { t.Helper() return assetName(t, cssGlob) } // bundleName resolves the content-hashed frontend bundle filename (bundle..js). func bundleName(t *testing.T) string { t.Helper() return assetName(t, bundleGlob) } func assetName(t *testing.T, glob string) string { t.Helper() href, err := assets.Resolve(staticFS, glob, assets.DefaultPrefix) require.NoError(t, err) require.NotEmptyf(t, href, "no artefact matching %s in the embedded tree", glob) return strings.TrimPrefix(href, assets.DefaultPrefix) } // extractCompareData pulls and decodes the embedded JSON payload from a page. func extractCompareData(t *testing.T, body string) compareData { t.Helper() const open = `id="compare-data" type="application/json">` i := strings.Index(body, open) require.GreaterOrEqualf(t, i, 0, "no compare-data script in body:\n%s", body) rest := body[i+len(open):] j := strings.Index(rest, "") require.GreaterOrEqual(t, j, 0, "compare-data script not closed") var cd compareData require.NoErrorf(t, json.Unmarshal([]byte(rest[:j]), &cd), "raw: %s", rest[:j]) return cd }