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 <root>/~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, `<small class="pull-right">private</small>`,
"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, `<span class="text-danger">compare</span>`,
"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", "/", `<div class="container">`},
{"repo", "/~alice/demo", `<div class="container">`},
{"compare", "/~alice/demo/compare/main...feature", `<div class="container-fluid">`},
{"commit", "/~alice/demo/commit/" + mainSHA, `<div class="container-fluid">`},
} {
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 "</script>"
// cannot break out of the embedded <script> element.
func TestCompareJSONNoScriptBreakout(t *testing.T) {
patch := &gitx.Patch{Text: "diff --git a/x b/x\n"}
files := []gitx.FileChange{{Path: "evil</script><script>alert(1)</script>.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 "</script>" must be escaped, so no literal "</script" tag can
// appear to close the embedding element...
assert.NotContainsf(t, s, "</script", "raw </script in the JSON (breakout possible): %s", s)
// ...and it must appear in its escaped form instead, proving the marshaler
// HTML-escaped the '<'.
assert.Containsf(t, s, "\\u003c/script", "expected an escaped \\u003c/script, got: %s", s)
}
func cssName(t *testing.T) string {
t.Helper()
href, err := resolveCSSHref()
require.NoError(t, err)
return strings.TrimPrefix(href, "/static/")
}
// bundleName resolves the content-hashed frontend bundle filename (bundle.<hash>.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, "</script>")
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
}