package web import ( "context" "crypto/rand" "encoding/base64" "encoding/json" "errors" "net/http" "net/http/httptest" "os" "os/exec" "path/filepath" "strings" "testing" "github.com/fernet/fernet-go" "github.com/go-chi/chi/v5" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/vaughan0/go-ini" "sourcecraft.dev/bigbes/sr-ht-core/config" "sourcecraft.dev/bigbes/sr-ht-core/crypto" "sourcecraft.dev/bigbes/sr-ht-compare/authz" "sourcecraft.dev/bigbes/sr-ht-compare/core" "sourcecraft.dev/bigbes/sr-ht-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() 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() 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}, "todo.sr.ht": ini.Section{"origin": "https://todo.example"}, "hub.sr.ht": ini.Section{"origin": "https://hub.example"}, } srv, err := New(conf, az) require.NoError(t, err, "New") 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", "") 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") } // 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 ".js). func bundleName(t *testing.T) string { t.Helper() href, err := resolveBundleHref() require.NoError(t, err) return strings.TrimPrefix(href, "/static/") } // 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 }