vitrine

serve_test.go

package main

import (
	"net/http"
	"net/http/httptest"
	"os"
	"os/exec"
	"path/filepath"
	"strings"
	"testing"
)

// newTestServer builds a server around a throwaway tree.
func newTestServer(t *testing.T) *server {
	t.Helper()
	dir := t.TempDir()
	files := map[string]string{
		"README.md":     "# Hello\n\nsome *markdown* & <angle> stuff\n",
		"sub/notes.txt": "plain text here\n",
		"sub/deeper.md": "nested doc\n",
		"dup.md":        "## Twice\n\n## Twice\n",
		".git/config":   "should be hidden\n"}
	for name, content := range files {
		p := filepath.Join(dir, filepath.FromSlash(name))
		if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
			t.Fatal(err)
		}
		if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
			t.Fatal(err)
		}
	}
	srv, err := newServer(map[string]exhibit{"demo": {Path: dir}}, newTheme("testhost"))
	if err != nil {
		t.Fatal(err)
	}
	return srv
}

// get performs a request and returns the response.
func get(t *testing.T, srv *server, url string) *httptest.ResponseRecorder {
	t.Helper()
	rec := httptest.NewRecorder()
	srv.ServeHTTP(rec, httptest.NewRequest("GET", url, nil))
	return rec
}

func TestRawMarkdownIsPlaintextPassthrough(t *testing.T) {
	rec := get(t, newTestServer(t), "/demo/README.md?fmt=raw")
	if rec.Code != http.StatusOK {
		t.Fatalf("status = %d", rec.Code)
	}
	if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/plain") {
		t.Errorf("content-type = %q, want text/plain", ct)
	}
	if got := rec.Body.String(); got != "# Hello\n\nsome *markdown* & <angle> stuff\n" {
		t.Errorf("body = %q; want exact file bytes", got)
	}
}

func TestRenderedMarkdown(t *testing.T) {
	rec := get(t, newTestServer(t), "/demo/README.md")
	body := rec.Body.String()
	if !strings.Contains(body, "<h1") || !strings.Contains(body, "<em>markdown</em>") {
		t.Errorf("markdown not rendered; body:\n%s", body)
	}
	if !strings.Contains(body, `href="?fmt=raw"`) {
		t.Errorf("missing raw link; body:\n%s", body)
	}
}

func TestNonMarkdownGetsIframe(t *testing.T) {
	rec := get(t, newTestServer(t), "/demo/sub/notes.txt")
	if !strings.Contains(rec.Body.String(), `<iframe class="passthrough" src="?fmt=raw"`) {
		t.Errorf("missing iframe; body:\n%s", rec.Body.String())
	}
}

func TestDirListingHidesGit(t *testing.T) {
	rec := get(t, newTestServer(t), "/demo/")
	body := rec.Body.String()
	if !strings.Contains(body, `href="sub/"`) || !strings.Contains(body, `href="README.md"`) {
		t.Errorf("listing incomplete; body:\n%s", body)
	}
	if strings.Contains(body, ".git") {
		t.Errorf(".git leaked into listing; body:\n%s", body)
	}
}

func TestDirRedirectAddsSlash(t *testing.T) {
	rec := get(t, newTestServer(t), "/demo/sub")
	if rec.Code != http.StatusMovedPermanently || rec.Header().Get("Location") != "/demo/sub/" {
		t.Errorf("got %d -> %q", rec.Code, rec.Header().Get("Location"))
	}
}

func TestTraversalRejected(t *testing.T) {
	for _, url := range []string{
		"/demo/../../../etc/passwd",
		"/demo/..%2f..%2fetc/passwd",
		"/nosuchroot/x",
	} {
		rec := get(t, newTestServer(t), url)
		if rec.Code != http.StatusNotFound && rec.Code != http.StatusMovedPermanently {
			t.Errorf("%s: status = %d, want rejection", url, rec.Code)
		}
		if strings.Contains(rec.Body.String(), "root:") {
			t.Errorf("%s: leaked /etc/passwd", url)
		}
	}
}

func TestRootIndexListsTrees(t *testing.T) {
	rec := get(t, newTestServer(t), "/")
	if !strings.Contains(rec.Body.String(), `href="demo/"`) {
		t.Errorf("index missing tree link; body:\n%s", rec.Body.String())
	}
}

// newNestedTestServer builds a server with a mount deeper than one URL segment,
// alongside a sibling subtree that is deliberately NOT mounted.
func newNestedTestServer(t *testing.T) *server {
	t.Helper()
	dir := t.TempDir()
	for name, content := range map[string]string{
		"research/latest/README.md": "# Fresh\n",
		"research/old/secret.md":    "# Stale\n",
	} {
		p := filepath.Join(dir, filepath.FromSlash(name))
		if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
			t.Fatal(err)
		}
		if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
			t.Fatal(err)
		}
	}
	srv, err := newServer(map[string]exhibit{
		"meta/research/latest": {Path: filepath.Join(dir, "research/latest")},
	}, theme{})
	if err != nil {
		t.Fatal(err)
	}
	return srv
}

func TestNestedMountServesContent(t *testing.T) {
	rec := get(t, newNestedTestServer(t), "/meta/research/latest/README.md")
	if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "Fresh") {
		t.Errorf("status = %d; body:\n%s", rec.Code, rec.Body.String())
	}
}

func TestSyntheticListingsAboveMount(t *testing.T) {
	srv := newNestedTestServer(t)
	for url, want := range map[string]string{
		"/":               `href="meta/"`,
		"/meta/":          `href="research/"`,
		"/meta/research/": `href="latest/"`,
	} {
		rec := get(t, srv, url)
		if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), want) {
			t.Errorf("%s: status = %d, want listing containing %s; body:\n%s", url, rec.Code, want, rec.Body.String())
		}
	}
}

func TestUnmountedSiblingsInvisible(t *testing.T) {
	srv := newNestedTestServer(t)
	rec := get(t, srv, "/meta/research/")
	if strings.Contains(rec.Body.String(), "old") {
		t.Errorf("unmounted sibling leaked into listing; body:\n%s", rec.Body.String())
	}
	rec = get(t, srv, "/meta/research/old/secret.md")
	if rec.Code != http.StatusNotFound {
		t.Errorf("unmounted sibling reachable: status = %d", rec.Code)
	}
}

func TestNestedMountBreadcrumbsReachRoot(t *testing.T) {
	rec := get(t, newNestedTestServer(t), "/meta/research/latest/README.md")
	body := rec.Body.String()
	for _, want := range []string{`href="/"`, `href="/meta/"`, `href="/meta/research/"`, `href="/meta/research/latest/"`} {
		if !strings.Contains(body, want) {
			t.Errorf("missing crumb %s; body:\n%s", want, body)
		}
	}
}

func TestGitUnreachableByDefault(t *testing.T) {
	srv := newTestServer(t)
	for _, url := range []string{
		"/demo/.git/",
		"/demo/.git",
		"/demo/.git/config",
		"/demo/.git/config?fmt=raw",
	} {
		rec := get(t, srv, url)
		if rec.Code != http.StatusNotFound {
			t.Errorf("%s: status = %d, want 404", url, rec.Code)
		}
		if strings.Contains(rec.Body.String(), "should be hidden") {
			t.Errorf("%s: leaked .git contents", url)
		}
	}
}

// newExposeGitTestServer builds a server around a real scratch git repo
// (one commit) with expose-git enabled on the exhibit.
func newExposeGitTestServer(t *testing.T) (*server, string) {
	t.Helper()
	dir := t.TempDir()
	if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("# repo\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	for _, args := range [][]string{
		{"init", "-q"},
		{"-c", "user.name=t", "-c", "user.email=t@t", "add", "."},
		{"-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "-m", "init"},
	} {
		cmd := exec.Command("git", args...)
		cmd.Dir = dir
		if out, err := cmd.CombinedOutput(); err != nil {
			t.Fatalf("git %v: %v: %s", args, err, out)
		}
	}
	srv, err := newServer(map[string]exhibit{"repo": {Path: dir, ExposeGit: true}}, theme{})
	if err != nil {
		t.Fatal(err)
	}
	return srv, dir
}

func TestExposeGitListsAndServes(t *testing.T) {
	srv, _ := newExposeGitTestServer(t)
	rec := get(t, srv, "/repo/")
	if !strings.Contains(rec.Body.String(), `href=".git/"`) {
		t.Errorf(".git missing from listing; body:\n%s", rec.Body.String())
	}
	rec = get(t, srv, "/repo/.git/HEAD?fmt=raw")
	if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "ref:") {
		t.Errorf(".git/HEAD: status = %d, body = %q", rec.Code, rec.Body.String())
	}
}

func TestExposeGitRunsUpdateServerInfo(t *testing.T) {
	srv, dir := newExposeGitTestServer(t)
	// Ordinary commits never write info/refs; server construction must have.
	if _, err := os.Stat(filepath.Join(dir, ".git/info/refs")); err != nil {
		t.Fatalf("info/refs not generated: %v", err)
	}
	rec := get(t, srv, "/repo/.git/info/refs?fmt=raw")
	if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "refs/heads/") {
		t.Errorf("info/refs: status = %d, body = %q", rec.Code, rec.Body.String())
	}
}

// TestExposeGitFilesServeRaw pins the fix for the dumb-HTTP transport:
// git fetches .git files as plain GETs with no query string, so those
// paths must serve raw bytes, not the HTML-wrapped rendered view.
// (The older .git tests all appended ?fmt=raw and so never caught this.)
func TestExposeGitFilesServeRaw(t *testing.T) {
	srv, _ := newExposeGitTestServer(t)
	rec := get(t, srv, "/repo/.git/info/refs") // no ?fmt=raw, as git itself sends
	if rec.Code != http.StatusOK {
		t.Fatalf("info/refs: status = %d", rec.Code)
	}
	body := rec.Body.String()
	if strings.Contains(body, "<!doctype html>") {
		t.Errorf("info/refs served as HTML wrapper; git cannot parse this:\n%s", body)
	}
	if !strings.Contains(body, "refs/heads/") {
		t.Errorf("info/refs missing ref listing; body = %q", body)
	}
}

// TestExposeGitCloneWorks is the end-to-end proof: a real `git clone` over
// HTTP against a live server must succeed and land on the actual tip commit.
// This is the behavior expose-git exists to provide.
func TestExposeGitCloneWorks(t *testing.T) {
	if _, err := exec.LookPath("git"); err != nil {
		t.Skip("git not on PATH")
	}
	srv, srcDir := newExposeGitTestServer(t)
	ts := httptest.NewServer(srv)
	defer ts.Close()

	dst := filepath.Join(t.TempDir(), "clone")
	cmd := exec.Command("git", "clone", "-q", ts.URL+"/repo/.git", dst)
	if out, err := cmd.CombinedOutput(); err != nil {
		t.Fatalf("git clone failed: %v: %s", err, out)
	}

	// The clone must reflect the source repo's actual tip, not a stale/empty one.
	want := gitRevParse(t, srcDir, "HEAD")
	got := gitRevParse(t, dst, "HEAD")
	if got != want {
		t.Errorf("clone tip = %s, want %s", got, want)
	}
}

func gitRevParse(t *testing.T, dir, rev string) string {
	t.Helper()
	cmd := exec.Command("git", "rev-parse", rev)
	cmd.Dir = dir
	out, err := cmd.CombinedOutput()
	if err != nil {
		t.Fatalf("git rev-parse %s in %s: %v: %s", rev, dir, err, out)
	}
	return strings.TrimSpace(string(out))
}

serve_test.go · 9.8 KiB · 0d2c580698