package server
import ( "io" "net/http" "net/http/httptest" "os" "os/exec" "path/filepath" "strings" "testing"
"gitweb/internal/store"
)
// newTestServer builds a Server over a LocalStore namespace containing a bare // repo at u@d.com/demo.git and a plain dir u@d.com/docs. func newTestServer(t *testing.T) (httptest.Server, func()) { t.Helper() if _, err := exec.LookPath("git"); err != nil { t.Skip("git not available") } ns := t.TempDir() if err := os.MkdirAll(filepath.Join(ns, "u@d.com", "docs"), 0o755); err != nil { t.Fatal(err) } os.WriteFile(filepath.Join(ns, "u@d.com", "docs", "note.txt"), []byte("hi"), 0o644) // Browsing is gated by Access files; make this whole namespace world-readable // so the anonymous browse tests below succeed. os.WriteFile(filepath.Join(ns, "u@d.com", "Access"), []byte("read,list: all\n: u@d.com\n"), 0o644)
work := t.TempDir()
git := func(dir string, a ...string) {
t.Helper()
c := exec.Command("git", a...)
c.Dir = dir
c.Env = append(os.Environ(),
"GIT_AUTHOR_NAME=T", "GIT_AUTHOR_EMAIL=t@e.com",
"GIT_COMMITTER_NAME=T", "GIT_COMMITTER_EMAIL=t@e.com")
if out, err := c.CombinedOutput(); err != nil {
t.Fatalf("git %v: %v\n%s", a, err, out)
}
}
git(work, "init", "-q", "-b", "main")
os.WriteFile(filepath.Join(work, "README.md"), []byte("# Demo\n\nhello\n"), 0o644)
os.WriteFile(filepath.Join(work, "main.go"), []byte("package main\n\nfunc main() {}\n"), 0o644)
// An Access file committed into the repo tree, to prove it is hidden there too.
os.WriteFile(filepath.Join(work, "Access"), []byte("read,list: all\n*: secret@d.com\n"), 0o644)
git(work, "add", "-A")
git(work, "commit", "-qm", "initial")
git(work, "checkout", "-q", "-b", "feature/x")
os.WriteFile(filepath.Join(work, "main.go"), []byte("package main\n\nfunc main() { _ = 1 }\n"), 0o644)
git(work, "commit", "-qam", "tweak")
git(work, "tag", "v1.0.0")
git(work, "checkout", "-q", "main")
git(work, "clone", "-q", "--bare", work, filepath.Join(ns, "u@d.com", "demo.git"))
web := os.DirFS("../../web")
srv, err := New(Config{Store: store.NewLocalStore(ns), Web: http.FS(web), CacheDir: t.TempDir()})
if err != nil {
t.Fatal(err)
}
ts := httptest.NewServer(srv.Handler())
return ts, ts.Close
}
// TestAccessFilesHidden verifies that upspin Access files are never listed or // rendered — neither in the plain namespace browse nor in a git repo tree — so // the set of users/groups with access is not exposed to browsers. func TestAccessFilesHidden(t *testing.T) { ts, done := newTestServer(t) defer done()
get := func(p string) (int, string) {
resp, err := http.Get(ts.URL + p)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
return resp.StatusCode, string(b)
}
// Namespace listing must not link to the Access file...
if code, body := get("/u@d.com/"); code != 200 {
t.Fatalf("namespace browse code %d", code)
} else if strings.Contains(body, `href="Access"`) {
t.Error("namespace listing exposed the Access file")
}
// ...and its contents must not be servable directly.
if code, _ := get("/u@d.com/Access"); code != 404 {
t.Errorf("GET /u@d.com/Access = %d, want 404", code)
}
// Git tree listing must not link to a committed Access file...
if code, body := get("/u@d.com/demo.git/tree/main/"); code != 200 {
t.Fatalf("tree code %d", code)
} else if strings.Contains(body, "/blob/main/Access") {
t.Error("git tree listing exposed a committed Access file")
}
// ...and neither blob nor raw view will render it.
if code, body := get("/u@d.com/demo.git/blob/main/Access"); code != 404 {
t.Errorf("blob Access = %d, want 404", code)
} else if strings.Contains(body, "secret@d.com") {
t.Error("blob view leaked Access contents")
}
if code, _ := get("/u@d.com/demo.git/raw/main/Access"); code != 404 {
t.Errorf("raw Access = %d, want 404", code)
}
}
func TestGitVerbs(t *testing.T) { ts, done := newTestServer(t) defer done()
get := func(p string) (int, string) {
resp, err := http.Get(ts.URL + p)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
return resp.StatusCode, string(b)
}
cases := []struct {
path string
wantCode int
contains []string
}{
{"/u@d.com/docs/", 200, []string{"Index of", "note.txt"}},
{"/u@d.com/demo.git/", 200, []string{"feature/x", "v1.0.0", "README", "Demo"}},
{"/u@d.com/demo.git/refs", 200, []string{"Branches", "Tags", "feature/x", "v1.0.0"}},
{"/u@d.com/demo.git/tree/main/", 200, []string{"main.go", "README.md"}},
{"/u@d.com/demo.git/blob/main/main.go", 200, []string{"chroma", "func"}},
{"/u@d.com/demo.git/blob/feature/x/main.go", 200, []string{"chroma"}},
{"/u@d.com/demo.git/log/feature/x", 200, []string{"tweak", "/commit/"}},
{"/u@d.com/demo.git/bogus", 404, nil},
}
for _, c := range cases {
t.Run(c.path, func(t *testing.T) {
code, body := get(c.path)
if code != c.wantCode {
t.Fatalf("code = %d, want %d", code, c.wantCode)
}
for _, want := range c.contains {
if !strings.Contains(body, want) {
t.Fatalf("body missing %q", want)
}
}
})
}
// Renderer switch: default README.md is highlighted source, with a
// Markdown switch offered; ?render=markdown renders the markdown.
{
_, src := get("/u@d.com/demo.git/blob/main/README.md")
if !strings.Contains(src, "chroma") {
t.Fatal("README.md default view should be highlighted source")
}
if !strings.Contains(src, `href="/u@d.com/demo.git/blob/main/README.md?render=markdown"`) {
t.Fatal("README.md should offer a Markdown switch link")
}
_, md := get("/u@d.com/demo.git/blob/main/README.md?render=markdown")
if !strings.Contains(md, "<h1") {
t.Fatal("?render=markdown should render markdown (expected <h1>)")
}
}
// Non-markdown file offers only the default; force exposes & runs markdown.
{
_, src := get("/u@d.com/demo.git/blob/main/main.go")
if strings.Contains(src, "?render=markdown\"") {
t.Fatal("main.go should not offer markdown without force")
}
if !strings.Contains(src, "force=1") {
t.Fatal("main.go should offer a force toggle")
}
code, _ := get("/u@d.com/demo.git/blob/main/main.go?render=markdown&force=1")
if code != 200 {
t.Fatalf("forced markdown on main.go: code %d", code)
}
}
// CSP header is present on git pages. Scripts are restricted to same-origin
// with no 'unsafe-inline', so markdown-injected inline script cannot run
// (mermaid is served as a same-origin file).
resp, _ := http.Get(ts.URL + "/u@d.com/demo.git/")
resp.Body.Close()
csp := resp.Header.Get("Content-Security-Policy")
if !strings.Contains(csp, "script-src 'self'") || strings.Contains(csp, "script-src 'self' 'unsafe-inline'") {
t.Fatalf("missing/weak script CSP on git page: %q", csp)
}
// Commit page renders a diff.
_, logBody := get("/u@d.com/demo.git/log/feature/x")
i := strings.Index(logBody, "/u@d.com/demo.git/commit/")
if i < 0 {
t.Fatal("no commit link in log")
}
url := logBody[i : i+len("/u@d.com/demo.git/commit/")+40]
code, body := get(url)
if code != 200 || !strings.Contains(body, "diff-add") || !strings.Contains(body, "diff-del") {
t.Fatalf("commit page: code %d, diff classes present=%v", code,
strings.Contains(body, "diff-add"))
}
}