repos
/ orchard main

orchard

mirror

Every site I host, in one repo, along with the Cloudflare Tunnel and Caddy that front them. It's all Go, Vite, and SQLite, and it runs on a desktop at home with nothing listening on an inbound port.

blogbuncaddycloudflare-tunneldockergogolanghomelabhtml-templatemonorepoself-hostedseosqlitestatic-sitetypstuptime-monitoringviteweb-analytics

31.0 KB · 1064 lines · Go Raw History
   1package main
   2
   3import (
   4	"context"
   5	"io/fs"
   6	"net/http"
   7	"net/http/httptest"
   8	"os"
   9	"os/exec"
  10	"path/filepath"
  11	"strings"
  12	"testing"
  13
  14	"repos.bythewood.me/web"
  15)
  16
  17// TestValidName is the traversal fence, so it takes the adversarial cases.
  18func TestValidName(t *testing.T) {
  19	ok := []string{
  20		"orchard", "taproot", "blog.bythewood.me", "a", "with-dash",
  21		"with_underscore", "Mixed123",
  22	}
  23	for _, name := range ok {
  24		if !validName(name) {
  25			t.Errorf("validName(%q) = false, want true", name)
  26		}
  27	}
  28
  29	bad := []string{
  30		"", ".", "..", ".hidden", "../etc", "a/b", `a\b`,
  31		"a..b",   // interior .. is still a traversal component once joined
  32		"a\x00b", // NUL, which would truncate the path in a syscall
  33		"has space", "q?x", "semi;colon", "-flag",
  34		strings.Repeat("x", 101),
  35	}
  36	for _, name := range bad {
  37		if validName(name) {
  38			t.Errorf("validName(%q) = true, want false", name)
  39		}
  40	}
  41}
  42
  43func TestRepoNameFromPath(t *testing.T) {
  44	cases := []struct {
  45		seg  string
  46		name string
  47		ok   bool
  48	}{
  49		{"orchard.git", "orchard", true},
  50		{"blog.bythewood.me.git", "blog.bythewood.me", true},
  51		{"orchard", "", false},  // no suffix: a browse path, not a wire path
  52		{".git", "", false},     // empty name
  53		{"../x.git", "", false}, // traversal survives the suffix strip
  54	}
  55	for _, c := range cases {
  56		name, ok := repoNameFromPath(c.seg)
  57		if ok != c.ok || name != c.name {
  58			t.Errorf("repoNameFromPath(%q) = (%q, %v), want (%q, %v)",
  59				c.seg, name, ok, c.name, c.ok)
  60		}
  61	}
  62}
  63
  64// TestParseDiff pins the diff parser against every shape the renderer draws.
  65func TestParseDiff(t *testing.T) {
  66	patch := `diff --git a/kept.txt b/kept.txt
  67index 1111111..2222222 100644
  68--- a/kept.txt
  69+++ b/kept.txt
  70@@ -1,3 +1,4 @@
  71 context one
  72-removed line
  73+added line
  74+second added
  75 context two
  76diff --git a/added.txt b/added.txt
  77new file mode 100644
  78index 0000000..3333333
  79--- /dev/null
  80+++ b/added.txt
  81@@ -0,0 +1 @@
  82+brand new
  83diff --git a/gone.txt b/gone.txt
  84deleted file mode 100644
  85index 4444444..0000000
  86--- a/gone.txt
  87+++ /dev/null
  88@@ -1 +0,0 @@
  89-was here
  90diff --git a/old/name.txt b/new/name.txt
  91similarity index 100%
  92rename from old/name.txt
  93rename to new/name.txt
  94diff --git a/image.png b/image.png
  95index 5555555..6666666 100644
  96Binary files a/image.png and b/image.png differ
  97`
  98
  99	d := parseDiff([]byte(patch))
 100
 101	if len(d.Files) != 5 {
 102		t.Fatalf("parsed %d files, want 5", len(d.Files))
 103	}
 104	if d.Additions != 3 || d.Deletions != 2 {
 105		t.Errorf("totals = +%d -%d, want +3 -2", d.Additions, d.Deletions)
 106	}
 107
 108	modified := d.Files[0]
 109	if modified.Status != Modified || modified.Path() != "kept.txt" {
 110		t.Errorf("file 0 = %s %s, want modified kept.txt", modified.Status, modified.Path())
 111	}
 112	if len(modified.Hunks) != 1 {
 113		t.Fatalf("file 0 has %d hunks, want 1", len(modified.Hunks))
 114	}
 115
 116	// The line numbers are why the patch is parsed rather than printed.
 117	h := modified.Hunks[0]
 118	want := []struct {
 119		kind   string
 120		oldNum int
 121		newNum int
 122	}{
 123		{"context", 1, 1},
 124		{"del", 2, 0},
 125		{"add", 0, 2},
 126		{"add", 0, 3},
 127		{"context", 3, 4},
 128	}
 129	if len(h.Lines) != len(want) {
 130		t.Fatalf("hunk has %d lines, want %d", len(h.Lines), len(want))
 131	}
 132	for i, w := range want {
 133		got := h.Lines[i]
 134		if got.Kind != w.kind || got.OldNum != w.oldNum || got.NewNum != w.newNum {
 135			t.Errorf("line %d = %s(%d,%d), want %s(%d,%d)",
 136				i, got.Kind, got.OldNum, got.NewNum, w.kind, w.oldNum, w.newNum)
 137		}
 138	}
 139
 140	if d.Files[1].Status != Added {
 141		t.Errorf("file 1 status = %s, want added", d.Files[1].Status)
 142	}
 143	// A delete has no new path, so Path falls back to the old one.
 144	if d.Files[2].Status != Deleted || d.Files[2].Path() != "gone.txt" {
 145		t.Errorf("file 2 = %s %s, want deleted gone.txt", d.Files[2].Status, d.Files[2].Path())
 146	}
 147	if r := d.Files[3]; r.Status != Renamed || r.OldPath != "old/name.txt" || r.NewPath != "new/name.txt" {
 148		t.Errorf("file 3 = %s %s -> %s, want renamed old/name.txt -> new/name.txt",
 149			r.Status, r.OldPath, r.NewPath)
 150	}
 151	if !d.Files[4].Binary {
 152		t.Error("file 4 should be binary")
 153	}
 154}
 155
 156func TestParseHunkHeader(t *testing.T) {
 157	cases := []struct {
 158		line                                   string
 159		oldStart, oldLines, newStart, newLines int
 160	}{
 161		{"@@ -1,3 +1,4 @@", 1, 3, 1, 4},
 162		{"@@ -0,0 +1 @@", 0, 0, 1, 1}, // a count-less range means one line
 163		{"@@ -12 +12 @@ func main() {", 12, 1, 12, 1},
 164	}
 165	for _, c := range cases {
 166		h := parseHunkHeader(c.line)
 167		if h.OldStart != c.oldStart || h.OldLines != c.oldLines ||
 168			h.NewStart != c.newStart || h.NewLines != c.newLines {
 169			t.Errorf("parseHunkHeader(%q) = -%d,%d +%d,%d, want -%d,%d +%d,%d",
 170				c.line, h.OldStart, h.OldLines, h.NewStart, h.NewLines,
 171				c.oldStart, c.oldLines, c.newStart, c.newLines)
 172		}
 173	}
 174}
 175
 176// core.quotePath=false is what keeps this header split parseable, including a
 177// path holding the separator it splits on.
 178func TestParseDiffGit(t *testing.T) {
 179	cases := []struct{ line, old, new string }{
 180		{"diff --git a/x.txt b/x.txt", "x.txt", "x.txt"},
 181		{"diff --git a/dir/a b/file b/dir/a b/file", "dir/a b/file", "dir/a b/file"},
 182		{"diff --git a/héllo.txt b/héllo.txt", "héllo.txt", "héllo.txt"},
 183	}
 184	for _, c := range cases {
 185		old, new := parseDiffGit(c.line)
 186		if old != c.old || new != c.new {
 187			t.Errorf("parseDiffGit(%q) = (%q, %q), want (%q, %q)",
 188				c.line, old, new, c.old, c.new)
 189		}
 190	}
 191}
 192
 193func TestHumanBytes(t *testing.T) {
 194	cases := []struct {
 195		n    int64
 196		want string
 197	}{
 198		{0, "0 B"}, {512, "512 B"}, {1024, "1.0 KB"},
 199		{1536, "1.5 KB"}, {1 << 20, "1.0 MB"}, {100 << 20, "100.0 MB"},
 200	}
 201	for _, c := range cases {
 202		if got := humanBytes(c.n); got != c.want {
 203			t.Errorf("humanBytes(%d) = %q, want %q", c.n, got, c.want)
 204		}
 205	}
 206}
 207
 208// TestPercentOf covers the meter that has to warn before a push hits
 209// Cloudflare's limit rather than after.
 210func TestPercentOf(t *testing.T) {
 211	cases := []struct {
 212		n    int64
 213		want int
 214	}{
 215		{0, 0},
 216		{50 << 20, 50},
 217		{cloudflareBodyLimit, 100},
 218		// Over the limit clamps rather than overflowing the bar.
 219		{200 << 20, 100},
 220	}
 221	for _, c := range cases {
 222		if got := percentOf(c.n, cloudflareBodyLimit); got != c.want {
 223			t.Errorf("percentOf(%d) = %d, want %d", c.n, got, c.want)
 224		}
 225	}
 226}
 227
 228func TestURLPath(t *testing.T) {
 229	cases := []struct{ in, want string }{
 230		{"a/b/c.txt", "a/b/c.txt"},
 231		{"with space.txt", "with%20space.txt"},
 232		{"has#hash.txt", "has%23hash.txt"},
 233		{"q?uery.txt", "q%3Fuery.txt"},
 234		{"dir/sub dir/f.txt", "dir/sub%20dir/f.txt"},
 235	}
 236	for _, c := range cases {
 237		if got := urlPath(c.in); got != c.want {
 238			t.Errorf("urlPath(%q) = %q, want %q", c.in, got, c.want)
 239		}
 240	}
 241}
 242
 243func TestIsDumbPath(t *testing.T) {
 244	// Serving these as static files hands out loose objects and skips the auth.
 245	for _, p := range []string{"objects/ab/cdef", "HEAD", "packed-refs", "refs/heads/main"} {
 246		if !isDumbPath(p) {
 247			t.Errorf("isDumbPath(%q) = false, want true", p)
 248		}
 249	}
 250	for _, p := range []string{"info/refs", "git-upload-pack", "git-receive-pack"} {
 251		if isDumbPath(p) {
 252			t.Errorf("isDumbPath(%q) = true, want false", p)
 253		}
 254	}
 255}
 256
 257func TestIsBinary(t *testing.T) {
 258	if IsBinary([]byte("plain text\nwith newlines\n")) {
 259		t.Error("text detected as binary")
 260	}
 261	if !IsBinary([]byte("has\x00nul")) {
 262		t.Error("NUL-containing data not detected as binary")
 263	}
 264}
 265
 266// A README is arbitrary text out of a mirrored repository nobody reviewed.
 267func TestMarkdownSanitised(t *testing.T) {
 268	src := []byte("# Title\n\n<script>alert(1)</script>\n\n" +
 269		"<img src=x onerror=alert(1)>\n\n[link](https://example.com)\n")
 270
 271	html, err := RenderMarkdown(src)
 272	if err != nil {
 273		t.Fatalf("RenderMarkdown: %v", err)
 274	}
 275	got := string(html)
 276
 277	if strings.Contains(got, "<script") {
 278		t.Error("script tag survived sanitisation")
 279	}
 280	if strings.Contains(got, "onerror") {
 281		t.Error("event handler attribute survived sanitisation")
 282	}
 283	if !strings.Contains(got, "<h1") {
 284		t.Error("heading did not render")
 285	}
 286	if !strings.Contains(got, "example.com") {
 287		t.Error("link did not render")
 288	}
 289}
 290
 291// TestGitRoundTrip runs the parsers against real git output, not a fixture.
 292func TestGitRoundTrip(t *testing.T) {
 293	if _, err := exec.LookPath("git"); err != nil {
 294		t.Skip("git not on PATH")
 295	}
 296
 297	root := t.TempDir()
 298	work := filepath.Join(root, "work")
 299	bare := filepath.Join(root, "sample.git")
 300
 301	git := func(dir string, args ...string) {
 302		t.Helper()
 303		cmd := exec.Command("git", args...)
 304		cmd.Dir = dir
 305		cmd.Env = append(os.Environ(),
 306			"GIT_AUTHOR_NAME=Test", "[email protected]",
 307			"GIT_COMMITTER_NAME=Test", "[email protected]",
 308			"GIT_CONFIG_NOSYSTEM=1", "HOME="+root)
 309		if out, err := cmd.CombinedOutput(); err != nil {
 310			t.Fatalf("git %s: %v: %s", strings.Join(args, " "), err, out)
 311		}
 312	}
 313
 314	if err := os.MkdirAll(work, 0o755); err != nil {
 315		t.Fatal(err)
 316	}
 317	git(work, "init", "-q", "-b", "main")
 318	if err := os.WriteFile(filepath.Join(work, "README.md"),
 319		[]byte("# sample\n\nhello\n"), 0o644); err != nil {
 320		t.Fatal(err)
 321	}
 322	git(work, "add", "-A")
 323	git(work, "commit", "-qm", "first commit")
 324	if err := os.WriteFile(filepath.Join(work, "second.txt"),
 325		[]byte("two\n"), 0o644); err != nil {
 326		t.Fatal(err)
 327	}
 328	git(work, "add", "-A")
 329	git(work, "commit", "-qm", "second commit")
 330	git(work, "tag", "-a", "v1.0.0", "-m", "release one")
 331	git(root, "clone", "-q", "--bare", work, bare)
 332
 333	store := NewStore(root)
 334	defer store.Close()
 335	ctx := context.Background()
 336
 337	repos, err := store.Discover()
 338	if err != nil {
 339		t.Fatalf("Discover: %v", err)
 340	}
 341	if len(repos) != 1 || repos[0].Name != "sample" {
 342		t.Fatalf("Discover = %+v, want one repo named sample", repos)
 343	}
 344	repo := repos[0]
 345
 346	if store.IsEmpty(ctx, repo) {
 347		t.Error("repository with two commits reported empty")
 348	}
 349
 350	commits, err := store.Log(ctx, repo, "main", 0, 10)
 351	if err != nil {
 352		t.Fatalf("Log: %v", err)
 353	}
 354	if len(commits) != 2 {
 355		t.Fatalf("Log returned %d commits, want 2", len(commits))
 356	}
 357	if commits[0].Subject != "second commit" {
 358		t.Errorf("newest subject = %q, want %q", commits[0].Subject, "second commit")
 359	}
 360	if len(commits[0].SHA) != 40 {
 361		t.Errorf("SHA = %q, want 40 characters", commits[0].SHA)
 362	}
 363	if commits[0].When.IsZero() {
 364		t.Error("commit date did not parse")
 365	}
 366
 367	branches, err := store.Branches(ctx, repo)
 368	if err != nil {
 369		t.Fatalf("Branches: %v", err)
 370	}
 371	if len(branches) != 1 || branches[0].Name != "main" {
 372		t.Errorf("Branches = %+v, want one named main", branches)
 373	}
 374
 375	tags, err := store.Tags(ctx, repo)
 376	if err != nil {
 377		t.Fatalf("Tags: %v", err)
 378	}
 379	if len(tags) != 1 || tags[0].Name != "v1.0.0" {
 380		t.Fatalf("Tags = %+v, want one named v1.0.0", tags)
 381	}
 382	// An annotated tag is its own object, so Target has to be the commit it
 383	// points at or every tag link 404s.
 384	if !tags[0].Annotated {
 385		t.Error("annotated tag not flagged as annotated")
 386	}
 387	if tags[0].Target == "" {
 388		t.Error("annotated tag did not resolve to a commit")
 389	}
 390
 391	entries, err := store.Tree(ctx, repo, "main", "")
 392	if err != nil {
 393		t.Fatalf("Tree: %v", err)
 394	}
 395	if len(entries) != 2 {
 396		t.Fatalf("Tree returned %d entries, want 2", len(entries))
 397	}
 398
 399	blob, size, err := store.Blob(ctx, repo, "main", "README.md")
 400	if err != nil {
 401		t.Fatalf("Blob: %v", err)
 402	}
 403	if !strings.Contains(string(blob), "# sample") {
 404		t.Errorf("Blob content = %q", blob)
 405	}
 406	if size != int64(len(blob)) {
 407		t.Errorf("Blob size = %d, want %d", size, len(blob))
 408	}
 409
 410	// Read twice, since the second read proves the cat-file stream stayed in
 411	// sync after the first payload.
 412	for i := range 2 {
 413		typ, data, err := store.Object(repo, "main:README.md")
 414		if err != nil {
 415			t.Fatalf("Object read %d: %v", i, err)
 416		}
 417		if typ != "blob" {
 418			t.Errorf("Object type = %q, want blob", typ)
 419		}
 420		if !strings.Contains(string(data), "# sample") {
 421			t.Errorf("Object content = %q", data)
 422		}
 423	}
 424
 425	sha, err := store.Resolve(ctx, repo, "main")
 426	if err != nil {
 427		t.Fatalf("Resolve: %v", err)
 428	}
 429	if _, err := store.Resolve(ctx, repo, "no-such-ref"); err == nil {
 430		t.Error("Resolve accepted a revision that does not exist")
 431	}
 432
 433	diff, err := store.Diff(ctx, repo, sha)
 434	if err != nil {
 435		t.Fatalf("Diff: %v", err)
 436	}
 437	if len(diff.Files) != 1 || diff.Files[0].Path() != "second.txt" {
 438		t.Errorf("Diff files = %+v, want one for second.txt", diff.Files)
 439	}
 440	if diff.Additions != 1 {
 441		t.Errorf("Diff additions = %d, want 1", diff.Additions)
 442	}
 443
 444	if n := store.CountCommits(ctx, repo, "main"); n != 2 {
 445		t.Errorf("CountCommits = %d, want 2", n)
 446	}
 447	if store.Size(ctx, repo) <= 0 {
 448		t.Error("Size returned nothing for a repository with content")
 449	}
 450
 451	// Overview answers the whole listing card from one ref walk, so it has to
 452	// agree with the four calls it replaced.
 453	o := store.Overview(ctx, repo)
 454	if o.Branches != len(branches) || o.Tags != len(tags) {
 455		t.Errorf("Overview = %d branches and %d tags, want %d and %d",
 456			o.Branches, o.Tags, len(branches), len(tags))
 457	}
 458	if want := store.Size(ctx, repo); o.Size != want {
 459		t.Errorf("Overview size = %d, want %d", o.Size, want)
 460	}
 461	if o.LastPush.IsZero() {
 462		t.Error("Overview LastPush is zero")
 463	}
 464	if o.Empty {
 465		t.Error("repository with two commits reported empty by Overview")
 466	}
 467}
 468
 469// TestOverviewCache proves the listing card is cached and that invalidating is
 470// what makes a push visible, since a stale card is the failure mode the cache
 471// introduces.
 472func TestOverviewCache(t *testing.T) {
 473	if _, err := exec.LookPath("git"); err != nil {
 474		t.Skip("git not on PATH")
 475	}
 476
 477	root := t.TempDir()
 478	bare := filepath.Join(root, "sample.git")
 479
 480	git := func(dir string, args ...string) {
 481		t.Helper()
 482		cmd := exec.Command("git", args...)
 483		cmd.Dir = dir
 484		cmd.Env = append(os.Environ(),
 485			"GIT_AUTHOR_NAME=Test", "[email protected]",
 486			"GIT_COMMITTER_NAME=Test", "[email protected]",
 487			"GIT_CONFIG_NOSYSTEM=1", "HOME="+root)
 488		if out, err := cmd.CombinedOutput(); err != nil {
 489			t.Fatalf("git %s: %v: %s", strings.Join(args, " "), err, out)
 490		}
 491	}
 492
 493	work := filepath.Join(root, "work")
 494	if err := os.MkdirAll(work, 0o755); err != nil {
 495		t.Fatal(err)
 496	}
 497	git(work, "init", "-q", "-b", "main")
 498	if err := os.WriteFile(filepath.Join(work, "a.txt"), []byte("a\n"), 0o644); err != nil {
 499		t.Fatal(err)
 500	}
 501	git(work, "add", "-A")
 502	git(work, "commit", "-qm", "first")
 503	git(root, "clone", "-q", "--bare", work, bare)
 504
 505	store := NewStore(root)
 506	defer store.Close()
 507	ctx := context.Background()
 508
 509	repo, ok := store.Open("sample")
 510	if !ok {
 511		t.Fatal("Open(sample) failed")
 512	}
 513
 514	if n := store.Overview(ctx, repo).Branches; n != 1 {
 515		t.Fatalf("Overview branches = %d, want 1", n)
 516	}
 517
 518	// A second branch appears on disk with nothing told to the store.
 519	git(bare, "branch", "topic", "main")
 520
 521	if n := store.Overview(ctx, repo).Branches; n != 1 {
 522		t.Errorf("Overview branches = %d after an uninvalidated write, want the cached 1", n)
 523	}
 524
 525	store.InvalidateOverview("sample")
 526
 527	if n := store.Overview(ctx, repo).Branches; n != 2 {
 528		t.Errorf("Overview branches = %d after invalidating, want 2", n)
 529	}
 530}
 531
 532// TestInitBareConfig locks in the settings that keep a pushed repository
 533// recoverable and its pushes fast.
 534func TestInitBareConfig(t *testing.T) {
 535	if _, err := exec.LookPath("git"); err != nil {
 536		t.Skip("git not on PATH")
 537	}
 538
 539	root := t.TempDir()
 540	store := NewStore(root)
 541	defer store.Close()
 542	ctx := context.Background()
 543
 544	repo, err := store.InitBare(ctx, "created")
 545	if err != nil {
 546		t.Fatalf("InitBare: %v", err)
 547	}
 548
 549	get := func(key string) string {
 550		out, err := run(ctx, repo, "config", "--get", key)
 551		if err != nil {
 552			return ""
 553		}
 554		return strings.TrimSpace(string(out))
 555	}
 556
 557	// A gc inside a push is work done while Cloudflare counts to 100.
 558	if got := get("receive.autogc"); got != "false" {
 559		t.Errorf("receive.autogc = %q, want false", got)
 560	}
 561	// Off by default in a bare repository. With it, a force push leaves the
 562	// old tip recoverable, so today's mistake is not the only copy left.
 563	if got := get("core.logAllRefUpdates"); got != "true" {
 564		t.Errorf("core.logAllRefUpdates = %q, want true", got)
 565	}
 566	// Explicitly NOT set. A false here denies the authenticated smart push
 567	// too, so push-to-create would create a repository and then reject the
 568	// push that made it.
 569	if got := get("http.receivepack"); got != "" {
 570		t.Errorf("http.receivepack = %q, want unset", got)
 571	}
 572
 573	// Idempotent: a second push to the same name must not fail.
 574	if _, err := store.InitBare(ctx, "created"); err != nil {
 575		t.Errorf("InitBare is not idempotent: %v", err)
 576	}
 577
 578	if _, err := store.InitBare(ctx, "../escape"); err == nil {
 579		t.Error("InitBare accepted a traversing name")
 580	}
 581}
 582
 583func TestTokens(t *testing.T) {
 584	db, err := OpenDB(t.TempDir())
 585	if err != nil {
 586		t.Fatalf("OpenDB: %v", err)
 587	}
 588	defer db.Close()
 589
 590	if db.HasTokens() {
 591		t.Error("fresh database reports tokens")
 592	}
 593
 594	token, err := db.CreateToken("laptop")
 595	if err != nil {
 596		t.Fatalf("CreateToken: %v", err)
 597	}
 598	if len(token) < 40 {
 599		t.Errorf("token is %d characters, want at least 40", len(token))
 600	}
 601	if !db.HasTokens() {
 602		t.Error("HasTokens false after minting one")
 603	}
 604
 605	label, err := db.VerifyToken(token)
 606	if err != nil {
 607		t.Fatalf("VerifyToken: %v", err)
 608	}
 609	if label != "laptop" {
 610		t.Errorf("label = %q, want laptop", label)
 611	}
 612
 613	if _, err := db.VerifyToken(token + "x"); err == nil {
 614		t.Error("VerifyToken accepted a token with an extra character")
 615	}
 616	if _, err := db.VerifyToken("short"); err == nil {
 617		t.Error("VerifyToken accepted a too-short value")
 618	}
 619	// A value sharing the stored prefix must still fail, the prefix is kept in
 620	// clear and is not a secret.
 621	if _, err := db.VerifyToken(token[:8] + strings.Repeat("A", 35)); err == nil {
 622		t.Error("VerifyToken accepted a value matching only the prefix")
 623	}
 624
 625	tokens, err := db.Tokens()
 626	if err != nil {
 627		t.Fatalf("Tokens: %v", err)
 628	}
 629	if len(tokens) != 1 {
 630		t.Fatalf("Tokens returned %d, want 1", len(tokens))
 631	}
 632	// The token itself must never be readable back out.
 633	if strings.Contains(tokens[0].Prefix, token[8:]) {
 634		t.Error("stored prefix leaks the token body")
 635	}
 636
 637	if err := db.RevokeToken(tokens[0].ID); err != nil {
 638		t.Fatalf("RevokeToken: %v", err)
 639	}
 640	if _, err := db.VerifyToken(token); err == nil {
 641		t.Error("revoked token still authenticates")
 642	}
 643}
 644
 645// TestRepoMeta covers the metadata git has nowhere to put.
 646func TestRepoMeta(t *testing.T) {
 647	db, err := OpenDB(t.TempDir())
 648	if err != nil {
 649		t.Fatalf("OpenDB: %v", err)
 650	}
 651	defer db.Close()
 652
 653	if err := db.EnsureRepo("orchard"); err != nil {
 654		t.Fatalf("EnsureRepo: %v", err)
 655	}
 656	// Called on every push, so it must not fail the second time.
 657	if err := db.EnsureRepo("orchard"); err != nil {
 658		t.Fatalf("EnsureRepo is not idempotent: %v", err)
 659	}
 660
 661	if err := db.SetDescription("orchard", "the monorepo",
 662		[]string{"go", "self-hosted"}, "https://example.com"); err != nil {
 663		t.Fatalf("SetDescription: %v", err)
 664	}
 665
 666	meta, err := db.Repo("orchard")
 667	if err != nil {
 668		t.Fatalf("Repo: %v", err)
 669	}
 670	if meta.Description != "the monorepo" {
 671		t.Errorf("description = %q", meta.Description)
 672	}
 673	if len(meta.Topics) != 2 || meta.Topics[0] != "go" {
 674		t.Errorf("topics = %v, want [go self-hosted]", meta.Topics)
 675	}
 676	if meta.Mirror {
 677		t.Error("a pushed repository should not be flagged as a mirror")
 678	}
 679
 680	// A mirror refuses pushes, so the flag is what the wire checks.
 681	if err := db.MarkMirror("pinry", "https://github.com/overshard/pinry.git", true); err != nil {
 682		t.Fatalf("MarkMirror: %v", err)
 683	}
 684	mirror, err := db.Repo("pinry")
 685	if err != nil {
 686		t.Fatalf("Repo: %v", err)
 687	}
 688	if !mirror.Mirror || !mirror.Archived {
 689		t.Errorf("mirror flags = mirror:%v archived:%v, want both true",
 690			mirror.Mirror, mirror.Archived)
 691	}
 692
 693	if err := db.MarkUpstreamGone("pinry", true); err != nil {
 694		t.Fatalf("MarkUpstreamGone: %v", err)
 695	}
 696	if m, _ := db.Repo("pinry"); !m.UpstreamGone {
 697		t.Error("upstream_gone did not persist")
 698	}
 699
 700	all, err := db.AllRepos()
 701	if err != nil {
 702		t.Fatalf("AllRepos: %v", err)
 703	}
 704	if len(all) != 2 {
 705		t.Errorf("AllRepos returned %d, want 2", len(all))
 706	}
 707}
 708
 709// TestAdopt covers converting a pushed repository into a mirror, the one path
 710// that rewrites a repository this site may hold the only copy of.
 711func TestAdopt(t *testing.T) {
 712	if _, err := exec.LookPath("git"); err != nil {
 713		t.Skip("git not on PATH")
 714	}
 715
 716	root := t.TempDir()
 717	git := func(dir string, args ...string) {
 718		t.Helper()
 719		cmd := exec.Command("git", args...)
 720		cmd.Dir = dir
 721		cmd.Env = append(os.Environ(),
 722			"GIT_AUTHOR_NAME=Test", "[email protected]",
 723			"GIT_COMMITTER_NAME=Test", "[email protected]",
 724			"GIT_CONFIG_NOSYSTEM=1", "HOME="+root)
 725		if out, err := cmd.CombinedOutput(); err != nil {
 726			t.Fatalf("git %s: %v: %s", strings.Join(args, " "), err, out)
 727		}
 728	}
 729	commit := func(dir, name string) {
 730		t.Helper()
 731		if err := os.WriteFile(filepath.Join(dir, name), []byte(name+"\n"), 0o644); err != nil {
 732			t.Fatal(err)
 733		}
 734		git(dir, "add", "-A")
 735		git(dir, "commit", "-qm", name)
 736	}
 737
 738	// The stand-in for GitHub: a work tree with two commits, cloned bare.
 739	work := filepath.Join(root, "work")
 740	if err := os.MkdirAll(work, 0o755); err != nil {
 741		t.Fatal(err)
 742	}
 743	git(work, "init", "-q", "-b", "main")
 744	commit(work, "one")
 745	commit(work, "two")
 746	upstream := filepath.Join(root, "upstream.git")
 747	git(root, "clone", "-q", "--bare", work, upstream)
 748
 749	ctx := context.Background()
 750	db, err := OpenDB(t.TempDir())
 751	if err != nil {
 752		t.Fatalf("OpenDB: %v", err)
 753	}
 754	defer db.Close()
 755
 756	// setup builds a pushed repository at rev under a fresh store root.
 757	setup := func(t *testing.T, name, rev string) (*Mirror, Repo) {
 758		t.Helper()
 759		storeRoot := t.TempDir()
 760		bare := filepath.Join(storeRoot, name+".git")
 761		git(root, "clone", "-q", "--bare", upstream, bare)
 762		// A pushed repository has no origin, the clone gave it one.
 763		git(bare, "remote", "remove", "origin")
 764		if rev != "" {
 765			git(bare, "update-ref", "refs/heads/main", rev)
 766		}
 767		store := NewStore(storeRoot)
 768		t.Cleanup(store.Close)
 769		repo, ok := store.Open(name)
 770		if !ok {
 771			t.Fatalf("store.Open(%q) = false", name)
 772		}
 773		return NewMirror(store, db), repo
 774	}
 775
 776	head := func(dir string) string {
 777		out, err := exec.Command("git", "--git-dir", dir, "rev-parse", "refs/heads/main").Output()
 778		if err != nil {
 779			t.Fatal(err)
 780		}
 781		return strings.TrimSpace(string(out))
 782	}
 783	tip := head(upstream)
 784	first := func() string {
 785		out, err := exec.Command("git", "--git-dir", upstream, "rev-parse", "refs/heads/main~1").Output()
 786		if err != nil {
 787			t.Fatal(err)
 788		}
 789		return strings.TrimSpace(string(out))
 790	}()
 791
 792	gh := GitHubRepo{Name: "sample", CloneURL: upstream}
 793
 794	t.Run("identical is adopted", func(t *testing.T) {
 795		m, repo := setup(t, "sample", "")
 796		adopted, err := m.adopt(ctx, gh, repo)
 797		if err != nil {
 798			t.Fatalf("adopt: %v", err)
 799		}
 800		if !adopted {
 801			t.Fatal("adopted = false, want true")
 802		}
 803		// The mirror refspec is what makes syncOne rewrite refs, not add to them.
 804		out, err := run(ctx, repo, "config", "--get", "remote.origin.fetch")
 805		if err != nil || strings.TrimSpace(string(out)) != "+refs/*:refs/*" {
 806			t.Errorf("remote.origin.fetch = %q (err %v), want +refs/*:refs/*", out, err)
 807		}
 808		if out, err := run(ctx, repo, "config", "--get", "gc.reflogExpire"); err != nil ||
 809			strings.TrimSpace(string(out)) != "never" {
 810			t.Errorf("gc.reflogExpire = %q, want never", out)
 811		}
 812		// The probe namespace must not survive a successful adoption.
 813		if out, err := run(ctx, repo, "for-each-ref", "refs/adopt"); err != nil ||
 814			strings.TrimSpace(string(out)) != "" {
 815			t.Errorf("refs/adopt left behind: %q", out)
 816		}
 817	})
 818
 819	t.Run("behind upstream is adopted", func(t *testing.T) {
 820		m, repo := setup(t, "sample", first)
 821		if head(repo.Path) != first {
 822			t.Fatalf("setup: head = %s, want %s", head(repo.Path), first)
 823		}
 824		adopted, err := m.adopt(ctx, gh, repo)
 825		if err != nil {
 826			t.Fatalf("adopt: %v", err)
 827		}
 828		if !adopted {
 829			t.Fatal("adopted = false, want true: behind fast-forwards")
 830		}
 831	})
 832
 833	t.Run("ahead of upstream is refused and left untouched", func(t *testing.T) {
 834		m, repo := setup(t, "sample", "")
 835		// A commit only in the pushed copy, the case the skip in Sync protects.
 836		local := filepath.Join(root, "local")
 837		if err := os.MkdirAll(local, 0o755); err != nil {
 838			t.Fatal(err)
 839		}
 840		git(root, "clone", "-q", repo.Path, local)
 841		commit(local, "only-here")
 842		git(local, "push", "-q", "origin", "main")
 843		onlyHere := head(repo.Path)
 844		if onlyHere == tip {
 845			t.Fatal("setup: local push did not move main")
 846		}
 847
 848		adopted, err := m.adopt(ctx, gh, repo)
 849		if err != nil {
 850			t.Fatalf("adopt: %v", err)
 851		}
 852		if adopted {
 853			t.Fatal("adopted = true, want false: local holds a commit upstream lacks")
 854		}
 855		// Refusal has to leave the repository untouched.
 856		if got := head(repo.Path); got != onlyHere {
 857			t.Errorf("refs moved on refusal: head = %s, want %s", got, onlyHere)
 858		}
 859		if out, _ := run(ctx, repo, "config", "--get", "remote.origin.url"); strings.TrimSpace(string(out)) != "" {
 860			t.Errorf("origin left configured after refusal: %q", out)
 861		}
 862		if out, err := run(ctx, repo, "for-each-ref", "refs/adopt"); err != nil ||
 863			strings.TrimSpace(string(out)) != "" {
 864			t.Errorf("refs/adopt left behind after refusal: %q", out)
 865		}
 866	})
 867}
 868
 869// The slash is the entire grammar of the one field the settings form has, so
 870// the cases either side of it are what matter.
 871func TestParseMirrorSource(t *testing.T) {
 872	ok := []struct {
 873		in    string
 874		kind  string
 875		owner string
 876		name  string
 877	}{
 878		{"overshard", sourceAccount, "overshard", ""},
 879		{"  overshard  ", sourceAccount, "overshard", ""},
 880		{"overshard/newtab", sourceRepo, "overshard", "newtab"},
 881		{"overshard/blog.bythewood.me", sourceRepo, "overshard", "blog.bythewood.me"},
 882		// Pasting the URL is the obvious mistake, so it is accepted.
 883		{"https://github.com/overshard", sourceAccount, "overshard", ""},
 884		{"https://github.com/overshard/newtab", sourceRepo, "overshard", "newtab"},
 885		{"https://github.com/overshard/newtab.git", sourceRepo, "overshard", "newtab"},
 886		{"github.com/overshard/newtab/", sourceRepo, "overshard", "newtab"},
 887		// A trailing slash is what a copied URL carries, so it is forgiven.
 888		{"overshard/", sourceAccount, "overshard", ""},
 889	}
 890	for _, c := range ok {
 891		got, err := ParseMirrorSource(c.in)
 892		if err != nil {
 893			t.Errorf("ParseMirrorSource(%q) errored: %v", c.in, err)
 894			continue
 895		}
 896		if got.Kind != c.kind || got.Owner != c.owner || got.Name != c.name {
 897			t.Errorf("ParseMirrorSource(%q) = %+v, want %s %s/%s",
 898				c.in, got, c.kind, c.owner, c.name)
 899		}
 900	}
 901
 902	bad := []string{
 903		"", "   ", "/", "/newtab", "-bad", "over shard",
 904		"a/b/c", "over;shard", "overshard/../etc", strings.Repeat("x", 101),
 905	}
 906	for _, in := range bad {
 907		if got, err := ParseMirrorSource(in); err == nil {
 908			t.Errorf("ParseMirrorSource(%q) = %+v, want an error", in, got)
 909		}
 910	}
 911}
 912
 913// TestMirrorSourceLabel checks that what somebody types is what comes back.
 914func TestMirrorSourceLabel(t *testing.T) {
 915	for _, in := range []string{"overshard", "overshard/newtab"} {
 916		src, err := ParseMirrorSource(in)
 917		if err != nil {
 918			t.Fatalf("ParseMirrorSource(%q): %v", in, err)
 919		}
 920		if src.Label() != in {
 921			t.Errorf("Label() = %q, want %q", src.Label(), in)
 922		}
 923		if want := "https://github.com/" + in; src.URL() != want {
 924			t.Errorf("URL() = %q, want %q", src.URL(), want)
 925		}
 926	}
 927}
 928
 929// TestMirrorSources covers the CRUD plus the seed guard, since a setting that
 930// comes back after being deleted is worse than one that was never editable.
 931func TestMirrorSources(t *testing.T) {
 932	dir := t.TempDir()
 933	db, err := OpenDB(dir)
 934	if err != nil {
 935		t.Fatalf("OpenDB: %v", err)
 936	}
 937	defer db.Close()
 938
 939	if err := db.SeedMirrorSources("overshard"); err != nil {
 940		t.Fatalf("SeedMirrorSources: %v", err)
 941	}
 942	got, err := db.MirrorSources()
 943	if err != nil {
 944		t.Fatalf("MirrorSources: %v", err)
 945	}
 946	if len(got) != 1 || got[0].Owner != "overshard" || got[0].Kind != sourceAccount {
 947		t.Fatalf("after seed: %+v, want one account source for overshard", got)
 948	}
 949
 950	// Re-seeding is what a restart does, and it must not duplicate.
 951	if err := db.SeedMirrorSources("overshard"); err != nil {
 952		t.Fatalf("re-seed: %v", err)
 953	}
 954	if got, _ = db.MirrorSources(); len(got) != 1 {
 955		t.Fatalf("re-seed added a row: %+v", got)
 956	}
 957
 958	// Adding the same source twice is a no-op, so a double submit is harmless.
 959	src := MirrorSource{Kind: sourceRepo, Owner: "other", Name: "thing"}
 960	for range 2 {
 961		if err := db.AddMirrorSource(src); err != nil {
 962			t.Fatalf("AddMirrorSource: %v", err)
 963		}
 964	}
 965	if got, _ = db.MirrorSources(); len(got) != 2 {
 966		t.Fatalf("after add: %d sources, want 2: %+v", len(got), got)
 967	}
 968
 969	// Deleting the last source must stay deleted across a restart.
 970	for _, s := range got {
 971		if err := db.DeleteMirrorSource(s.ID); err != nil {
 972			t.Fatalf("DeleteMirrorSource: %v", err)
 973		}
 974	}
 975	if err := db.SeedMirrorSources("overshard"); err != nil {
 976		t.Fatalf("seed after delete: %v", err)
 977	}
 978	if got, _ = db.MirrorSources(); len(got) != 0 {
 979		t.Errorf("seed resurrected a deleted source: %+v", got)
 980	}
 981}
 982
 983// TestCoveredBySource scopes the upstream_gone flag. Getting it wrong means
 984// removing a source claims everything it brought in vanished from GitHub.
 985func TestCoveredBySource(t *testing.T) {
 986	sources := []MirrorSource{
 987		{Kind: sourceAccount, Owner: "overshard"},
 988		{Kind: sourceRepo, Owner: "other", Name: "thing"},
 989	}
 990	covered := []string{
 991		"https://github.com/overshard/orchard.git",
 992		"https://github.com/overshard/newtab.git",
 993		"https://github.com/OverShard/orchard.git", // GitHub logins are case insensitive
 994		"https://github.com/other/thing.git",
 995	}
 996	for _, u := range covered {
 997		if !coveredBySource(u, sources) {
 998			t.Errorf("coveredBySource(%q) = false, want true", u)
 999		}
1000	}
1001	notCovered := []string{
1002		"https://github.com/other/different.git",
1003		"https://github.com/somebodyelse/orchard.git",
1004		"", "not-a-url", "https://github.com/overshard",
1005	}
1006	for _, u := range notCovered {
1007		if coveredBySource(u, sources) {
1008			t.Errorf("coveredBySource(%q) = true, want false", u)
1009		}
1010	}
1011}
1012
1013// TestAnalyticsWiring guards the four places the collector lives, since they
1014// are edited separately and any one alone fails silently.
1015func TestAnalyticsWiring(t *testing.T) {
1016	// The id is identity and not a credential, and a typo files this site's
1017	// traffic under nothing.
1018	const want = "49f89ef6-b0b2-4b47-879e-7e252a067d0c"
1019	if analyticsID != want {
1020		t.Errorf("analyticsID = %q, want %q", analyticsID, want)
1021	}
1022
1023	base, err := templateFS.ReadFile("templates/base.html")
1024	if err != nil {
1025		t.Fatal(err)
1026	}
1027	for _, need := range []string{"collectorId", ".AnalyticsID",
1028		"https://analytics.bythewood.me"} {
1029		if !strings.Contains(string(base), need) {
1030			t.Errorf("base.html is missing %q, so nothing is collected", need)
1031		}
1032	}
1033
1034	policy := csp()
1035	for _, need := range []string{"'unsafe-inline'", "https://analytics.bythewood.me"} {
1036		if !strings.Contains(policy, need) {
1037			t.Errorf("CSP is missing %s, so the collector would be blocked: %s",
1038				need, policy)
1039		}
1040	}
1041
1042	p := (&site{}).page(httptest.NewRequest(http.MethodGet, "/", nil), "t", "d", nil)
1043	if !p.Analytics {
1044		t.Error("page.Analytics is false on the real hostname, so the snippet never renders")
1045	}
1046	if p.AnalyticsID != analyticsID {
1047		t.Errorf("page.AnalyticsID = %q, want %q", p.AnalyticsID, analyticsID)
1048	}
1049}
1050
1051// Every template the server asks for has to exist. NewRenderer resolves the
1052// list at boot rather than at build, so a page left listed after its file was
1053// deleted compiles, ships, and then crash-loops the container on startup, which
1054// is how it was found.
1055func TestEveryListedTemplateParses(t *testing.T) {
1056	templates, err := fs.Sub(templateFS, "templates")
1057	if err != nil {
1058		t.Fatal(err)
1059	}
1060	if _, err := web.NewRenderer(templates, templateFuncs, layoutTemplates, pageTemplates); err != nil {
1061		t.Fatalf("the template set does not parse: %v", err)
1062	}
1063}