Download
217 lines 8.5 KiBGoModified 2026-10-27 raw
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217
package render_test

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

	"code.bruce-hill.com/repo-renderer/internal/queue"
)

func TestIndexSections(t *testing.T) {
	cfgWith := func(siteExtra string) string {
		return `
site:
  title: Bruce's code
  tagline: Things I made
  unlisted_title: Misc
` + siteExtra + `
sections:
  - title: "[Tools](https://example.com/tools)"
    description: Small *programs*, <b>raw</b>.
    repos: [beta, alpha, missing]
`
	}
	e := newEnv(t, "alpha", cfgWith(""))
	e.seed()
	e.render()
	for _, extra := range []string{"beta", "gamma"} {
		other := &env{t: t, root: e.root, host: e.host, repo: extra, site: e.site, home: e.home}
		bare := e.site.Lay.GitDir(extra)
		other.git("", "init", "-q", "--bare", "-b", "main", bare)
		if err := os.WriteFile(filepath.Join(bare, "description"), []byte("desc of "+extra+"\n"), 0o644); err != nil {
			t.Fatal(err)
		}
		other.work = t.TempDir()
		other.git(other.work, "init", "-q", "-b", "main")
		other.git(other.work, "remote", "add", "origin", bare)
		other.write("README.md", "# "+extra+"\n")
		other.commit("init")
		other.push(false)
		if _, err := e.site.RenderRepo(extra, false); err != nil {
			t.Fatal(err)
		}
	}
	if err := e.site.RenderIndex(); err != nil {
		t.Fatal(err)
	}
	idx := e.read("index.html")
	mustContain(t, "index", idx, "Bruce&#39;s code", "Things I made",
		`<h2><a href="https://example.com/tools">Tools</a></h2>`, "<h2>Misc</h2>",
		"<p>Small <em>programs</em>, &lt;b&gt;raw&lt;/b&gt;.</p>",
		"desc of beta", "desc of gamma")
	mustNotContain(t, "index", idx, `href="/code/missing"`)
	// Repos within a section are alphabetical, not config order; unlisted
	// come last.
	if strings.Index(idx, `href="/code/beta"`) < strings.Index(idx, `href="/code/alpha"`) {
		t.Error("section repos not sorted by name")
	}
	if strings.Index(idx, "<h2>Misc</h2>") < strings.Index(idx, "Tools") {
		t.Error("unlisted section should come last")
	}

	e.configure(cfgWith("  unlisted: hide\n"))
	if err := e.site.RenderIndex(); err != nil {
		t.Fatal(err)
	}
	mustNotContain(t, "index", e.read("index.html"), `href="/code/gamma"`, "Misc")
}

// A custom favicon's URL is versioned by its content, not just its path:
// replacing the file in place must reach browsers that cached the old one.
func TestFaviconVersioned(t *testing.T) {
	e := newEnv(t, "proj", "")
	icon := filepath.Join(t.TempDir(), "icon.png")
	versionWith := func(data string) string {
		t.Helper()
		if err := os.WriteFile(icon, []byte(data), 0o644); err != nil {
			t.Fatal(err)
		}
		e.configure("site:\n  favicon: " + icon + "\n")
		return e.site.TmplHash
	}
	if a, b := versionWith("one"), versionWith("two"); a == b {
		t.Errorf("hash %s did not change with the favicon's content", a)
	}
	e.seed()
	e.render()
	mustContain(t, "index", e.read("index.html"), `rel="icon" href="/static/favicon.png?v=`+e.site.TmplHash+`" type="image/png"`)
}

// With unlisted: private, a repository in no section is not on the site
// at all: rendering it removes what an earlier render left, every branch
// view, tag tarball and the clone export included, and a push publishes
// nothing of it.
func TestPrivateRepo(t *testing.T) {
	repos := "repos:\n  proj:\n    branches: [dev]\n"
	e := newEnv(t, "proj", "site:\n  unlisted: show\n"+repos)
	e.seed()
	e.git(e.work, "branch", "dev")
	e.git(e.work, "push", "-q", "origin", "dev")
	e.git(e.work, "tag", "v1")
	e.git(e.work, "push", "-q", "origin", "v1")
	e.git("", "clone", "-q", "--bare", e.site.Lay.GitDir("proj"), e.site.Lay.GitDir("other"))
	if err := e.site.RenderAll(false); err != nil {
		t.Fatal(err)
	}
	gone := []string{"code/proj.html", "code/proj", "code/proj@dev.html", "code/proj@dev",
		"raw/proj", "raw/proj@dev", "search/proj", "search/proj@dev", "iframes/proj", "iframes/proj@dev",
		"types/proj", "archive/proj", "snapshot/proj", "snapshot/proj@dev", "git/proj.git", "sitemap/proj.xml"}
	e.mustExist(gone...)

	e.configure("site:\n  unlisted: private\nsections:\n  - title: Tools\n    repos: [other]\n" + repos)
	if err := e.site.RenderAll(false); err != nil {
		t.Fatal(err)
	}
	e.mustNotExist(gone...)
	e.mustExist("code/other.html", "git/other.git/HEAD", "sitemap/other.xml")
	for _, key := range []string{"proj", "proj@dev"} {
		if _, err := os.Stat(e.site.Lay.StatePath(key)); err == nil {
			t.Errorf("state of %s kept", key)
		}
	}
	mustNotContain(t, "index", e.read("index.html"), "/code/proj")
	mustNotContain(t, "sitemap", e.read("sitemap.xml"), "proj.xml")

	e.write("new.txt", "new\n")
	e.commit("more")
	e.push(false)
	e.git(e.work, "tag", "v2")
	e.git(e.work, "push", "-q", "origin", "v2")
	e.kick(
		queue.Job{Repo: "proj", Ref: "refs/heads/main", Old: zeroSHA, New: e.sha("main")},
		queue.Job{Repo: "proj", Ref: "refs/tags/v2", Old: zeroSHA, New: e.sha("v2")},
	)
	e.mustNotExist(gone...)
	if changed, err := e.site.RenderRepo("proj", true); err != nil || changed {
		t.Errorf("RenderRepo of a private repository with nothing left = %v, %v", changed, err)
	}
	e.mustNotExist(gone...)
}

// With a private site, a private repository renders there, every link
// under /private/, its clone URL its private export, and nothing of it on
// the public site, sitemap included. Moving it between the sites, by a
// render or by a push of a tag alone, takes it off the one it left.
func TestPrivateSite(t *testing.T) {
	e := newEnv(t, "proj", "")
	e.configure(e.privateConfig("other"))
	e.seed()
	e.git(e.work, "tag", "v1")
	e.git(e.work, "push", "-q", "origin", "v1")
	if err := e.site.RenderAll(false); err != nil {
		t.Fatal(err)
	}
	priv := func(rel string) string { return filepath.Join(e.site.Cfg.PrivateHtdocs, "private", rel) }
	privExists := func(rel string) bool { _, err := os.Stat(priv(rel)); return err == nil }
	for _, rel := range []string{"index.html", "code/proj.html", "code/proj/src/main.c.html", "raw/proj",
		"search/proj/corpus.json", "archive/proj/proj-v1.tar.gz", "git/proj.git/HEAD"} {
		if !privExists(rel) {
			t.Errorf("private site has no %s", rel)
		}
	}
	e.mustNotExist("code/proj.html", "code/proj", "raw/proj", "git/proj.git", "archive/proj", "sitemap/proj.xml", "private")
	if privExists("sitemap/proj.xml") || privExists("sitemap.xml") || privExists("static") {
		t.Error("private site has a sitemap or static files of its own")
	}
	page := mustReadFile(t, priv("code/proj.html"))
	mustContain(t, "private page", page, `href="/private/code/proj/src"`, `action="/private/search/proj/"`,
		`src="/private/iframes/proj/downloads.html"`, "https://"+e.host+"/private/git/proj.git",
		`<meta name="robots" content="noindex">`, `href="/private/"`, `href="/static/style.css?v=`)
	mustNotContain(t, "private page", page, `href="/code/proj`, `"/raw/proj`, `"/search/proj`)
	mustContain(t, "private index", mustReadFile(t, priv("index.html")), `href="/private/code/proj"`)
	mustNotContain(t, "public index", e.read("index.html"), "/code/proj", "/private")
	mustNotContain(t, "sitemap", e.read("sitemap.xml"), "proj")

	// The private export clones over dumb HTTP from where the private
	// site serves it.
	srv := httptest.NewServer(http.FileServer(http.Dir(e.site.Cfg.PrivateHtdocs)))
	defer srv.Close()
	dst := filepath.Join(t.TempDir(), "c")
	e.git("", "clone", "-q", srv.URL+"/private/git/proj.git", dst)
	if _, err := os.Stat(filepath.Join(dst, "src", "main.c")); err != nil {
		t.Errorf("clone of the private export: %v", err)
	}

	// Public, by a render: off the private site.
	e.configure(e.privateConfig("proj"))
	if err := e.site.RenderAll(false); err != nil {
		t.Fatal(err)
	}
	e.mustExist("code/proj.html", "git/proj.git/HEAD", "archive/proj/proj-v1.tar.gz")
	for _, rel := range []string{"code/proj.html", "raw/proj", "git/proj.git", "archive/proj"} {
		if privExists(rel) {
			t.Errorf("private site keeps %s of a public repository", rel)
		}
	}
	if _, err := os.Stat(filepath.Join(e.site.Cfg.StateDir, "private", "state", "proj")); err == nil {
		t.Error("private site keeps the state of a public repository")
	}

	// Private again, by a push of a tag alone: the whole repository moves.
	e.configure(e.privateConfig("other"))
	e.git(e.work, "tag", "v2")
	e.git(e.work, "push", "-q", "origin", "v2")
	e.kick(queue.Job{Repo: "proj", Ref: "refs/tags/v2", Old: zeroSHA, New: e.sha("v2")})
	e.mustNotExist("code/proj.html", "git/proj.git", "archive/proj", "sitemap/proj.xml")
	for _, rel := range []string{"code/proj.html", "archive/proj/proj-v2.tar.gz", "git/proj.git/HEAD"} {
		if !privExists(rel) {
			t.Errorf("private site has no %s after the tag push", rel)
		}
	}
	mustNotContain(t, "public index", e.read("index.html"), "/code/proj")
	mustContain(t, "private index", mustReadFile(t, priv("index.html")), `href="/private/code/proj"`)
}