orchard
mirrorEvery 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
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}