Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/vole.git

Write over SSH with a registered key:

git clone ssh://git@rodent.software:2222/vole.git

A free and open-source forge that lives in your Git repo.

Code

395 commit(s) on main

update readme Keenan Nemetz · 30m ago 32290114
history blame raw 149.1 KiB
1// End-to-end tests for the read-only-web / SSH-write architecture.
2package server_test
3
4import (
5 "bytes"
6 "crypto/ed25519"
7 "crypto/rand"
8 "encoding/pem"
9 "errors"
10 "fmt"
11 "io"
12 "log/slog"
13 "net"
14 "net/http"
15 "net/http/httptest"
16 "os"
17 "os/exec"
18 "path/filepath"
19 "runtime/debug"
20 "strings"
21 "sync"
22 "syscall"
23 "testing"
24 "time"
25
26 "golang.org/x/crypto/ssh"
27
28 "rodent.software/vole/ci"
29 "rodent.software/vole/ci/runs"
30 "rodent.software/vole/core/git"
31 "rodent.software/vole/forge/access"
32 "rodent.software/vole/forge/config"
33 "rodent.software/vole/forge/identity"
34 "rodent.software/vole/forge/index"
35 "rodent.software/vole/forge/repo"
36 "rodent.software/vole/metadata"
37 "rodent.software/vole/server"
38)
39
40// binPath is the binary under test, built once by TestMain. It is read by every test
41// and written by none, which is what makes it safe for them to run concurrently.
42var binPath string
43
44// fakeRuntimeDir holds a docker-shaped script for the integration tests. The real
45// runner now requires an OCI runtime, but these tests are about the queue, SSH
46// boundary and records, not about isolation. The script runs a step in the job tree
47// and otherwise answers the runtime commands the agent uses.
48var fakeRuntimeDir string
49
50// fakeRuntimeState is where the fake runtime remembers the volumes a container was
51// started with, so exec can resolve a container path (/workspace, /run/vole) to the
52// host directory the agent actually wrote. Without it a step could not read the
53// files the runner prepares, and the tests that exercise them would prove nothing.
54var fakeRuntimeState string
55
56const fakeDocker = `#!/bin/sh
57# A Docker-shaped test runtime. It does not isolate anything; it runs the step in
58# the job tree so integration tests can exercise the runner without a daemon.
59cmd="$1"
60[ $# -gt 0 ] && shift
61case "$cmd" in
62 ps)
63 exit 0
64 ;;
65 image)
66 exit 0
67 ;;
68 build)
69 exit 0
70 ;;
71 run)
72 name=""
73 volumes=""
74 while [ $# -gt 0 ]; do
75 case "$1" in
76 --name)
77 name="$2"
78 shift 2
79 ;;
80 --volume)
81 volumes="$volumes$2"
82 volumes="$volumes
83"
84 shift 2
85 ;;
86 --*)
87 shift
88 ;;
89 -*)
90 shift
91 ;;
92 *)
93 shift
94 ;;
95 esac
96 done
97 if [ -n "$name" ] && [ -n "$FAKE_RUNTIME_STATE" ]; then
98 printf '%s' "$volumes" > "$FAKE_RUNTIME_STATE/$name"
99 fi
100 exit 0
101 ;;
102 exec)
103 while [ $# -gt 0 ]; do
104 case "$1" in
105 -w) shift 2 ;;
106 --*) shift ;;
107 -*) shift ;;
108 *) break ;;
109 esac
110 done
111 container="$1"
112 [ $# -gt 0 ] && shift
113 [ $# -gt 0 ] && shift
114 [ $# -gt 0 ] && shift
115 script="$1"
116 if [ -n "$FAKE_RUNTIME_STATE" ] && [ -f "$FAKE_RUNTIME_STATE/$container" ]; then
117 while IFS= read -r pair; do
118 [ -n "$pair" ] || continue
119 host="${pair%%:*}"
120 rest="${pair#*:}"
121 where="${rest%%:*}"
122 [ -n "$where" ] || continue
123 script=$(printf '%s' "$script" | sed "s|$where|$host|g")
124 done < "$FAKE_RUNTIME_STATE/$container"
125 fi
126 CI=true VOLE_CI=1 HOME=/tmp GIT_TERMINAL_PROMPT=0 /bin/sh -c "$script"
127 exit $?
128 ;;
129 rm)
130 exit 0
131 ;;
132 *)
133 exit 0
134 ;;
135esac
136`
137
138// binaryBuildArgs returns the tags this test binary was built with, so the
139// binary it starts is built the same way. `go test -tags sqlite_fts5` passes its
140// tags to the test binary but not to a `go build` it runs; without this the
141// child has no FTS5 and a test that rebuilds the index fails.
142func binaryBuildArgs() []string {
143 info, ok := debug.ReadBuildInfo()
144 if !ok {
145 return nil
146 }
147 for _, setting := range info.Settings {
148 if setting.Key == "-tags" && setting.Value != "" {
149 return []string{"-tags", setting.Value}
150 }
151 }
152 return nil
153}
154
155// Every test here calls t.Parallel(). These are integration tests: each one starts a
156// server, pushes over SSH and runs `git` subprocesses, so it spends almost all of its
157// time waiting on child processes and almost none of it on CPU. Serially that is ~150
158// seconds; sixteen at a time it is ~16. Anything added here must keep that possible:
159//
160// - no t.Setenv — it panics in a parallel test, and it is the wrong tool anyway.
161// Pass environment through cmd.Env (see ciEnv) so tests cannot see each other.
162// - no fixed paths. t.TempDir() and 127.0.0.1:0 give each test its own instance.
163// - no shared package-level state beyond the read-only binPath.
164// - no ordering assumptions between tests. `go test -shuffle=on` must pass.
165//
166// A genuinely slow *wait* is the one thing parallelism cannot hide, because it sets the
167// floor for the whole package: prefer the shortest sleep a fixture can get away with,
168// and say in a comment what it is waiting for.
169func TestMain(m *testing.M) {
170 dir, err := os.MkdirTemp("", "vole-int-bin-")
171 if err != nil {
172 fmt.Fprintln(os.Stderr, err)
173 os.Exit(1)
174 }
175 binPath = filepath.Join(dir, "vole")
176 args := append([]string{"build"}, binaryBuildArgs()...)
177 args = append(args, "-o", binPath, "rodent.software/vole/cmd/vole")
178 build := exec.Command("go", args...)
179 build.Stdout, build.Stderr = os.Stdout, os.Stderr
180 if err := build.Run(); err != nil {
181 fmt.Fprintln(os.Stderr, "build vole:", err)
182 os.Exit(1)
183 }
184 fakeRuntimeDir = filepath.Join(dir, "runtime")
185 fakeRuntimeState = filepath.Join(dir, "runtime-state")
186 if err := os.MkdirAll(fakeRuntimeState, 0o755); err != nil {
187 fmt.Fprintln(os.Stderr, err)
188 os.Exit(1)
189 }
190 if err := os.MkdirAll(fakeRuntimeDir, 0o755); err != nil {
191 fmt.Fprintln(os.Stderr, err)
192 os.Exit(1)
193 }
194 if err := os.WriteFile(filepath.Join(fakeRuntimeDir, "docker"), []byte(fakeDocker), 0o755); err != nil {
195 fmt.Fprintln(os.Stderr, err)
196 os.Exit(1)
197 }
198 // Every fixture talks to a throwaway instance on a random port whose host key
199 // nobody has pinned. Host-key verification is on by default — the runner's
200 // control channel is a security boundary — so the test package uses the explicit
201 // override rather than teaching the tests to trust everything implicitly.
202 os.Setenv("VOLE_INSECURE_HOST_KEY", "1")
203 code := m.Run()
204 os.RemoveAll(dir)
205 os.Exit(code)
206}
207
208type fixture struct {
209 t *testing.T
210 cfg *config.Config
211 srv *server.Server
212 http *httptest.Server
213 sshAddr string
214 keyPath string
215 keyPub string
216 repo string
217 ownerID string
218 writerID string
219}
220
221func newFixture(t *testing.T) *fixture { return newFixtureWith(t, nil) }
222
223// newFixtureWith allows a test to adjust configuration before the server starts,
224// which is how the CI tests get a short lease.
225func newFixtureWith(t *testing.T, tweak func(*config.Config)) *fixture {
226 t.Helper()
227 cfg, err := config.Load("")
228 if err != nil {
229 t.Fatal(err)
230 }
231 cfg.DataDir = t.TempDir()
232 if tweak != nil {
233 tweak(cfg)
234 }
235 if err := os.MkdirAll(cfg.ReposDir(), 0o755); err != nil {
236 t.Fatal(err)
237 }
238 if err := identity.Init(cfg.UsersDB()); err != nil {
239 t.Fatal(err)
240 }
241 pub, priv := keypair(t, filepath.Join(cfg.DataDir, "id_test"))
242 ident, err := identity.Open(cfg.UsersDB())
243 if err != nil {
244 t.Fatal(err)
245 }
246 ident.AuditPath = cfg.AuditLog()
247 owner, err := ident.Create("keenan", pub, "test")
248 if err != nil {
249 t.Fatal(err)
250 }
251
252 log := slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError}))
253 srv, err := server.New(cfg, binPath, log)
254 if err != nil {
255 t.Fatal(err)
256 }
257 ln, err := net.Listen("tcp", "127.0.0.1:0")
258 if err != nil {
259 t.Fatal(err)
260 }
261 go srv.SSH.Serve(ln)
262 ts := httptest.NewServer(srv.Handler())
263
264 f := &fixture{
265 t: t, cfg: cfg, srv: srv, http: ts, sshAddr: ln.Addr().String(),
266 keyPath: priv, keyPub: pub, repo: "demo", ownerID: owner.Name,
267 }
268 t.Cleanup(func() {
269 ts.Close()
270 srv.SSH.Close()
271 srv.Close()
272 })
273
274 m, err := srv.Repos.Create(f.repo, owner.Name, "demo repo")
275 if err != nil {
276 t.Fatal(err)
277 }
278 if err := srv.Index.Upsert(m); err != nil {
279 t.Fatal(err)
280 }
281 return f
282}
283
284func keypair(t *testing.T, path string) (pubLine, privPath string) {
285 t.Helper()
286 pub, priv, err := ed25519.GenerateKey(rand.Reader)
287 if err != nil {
288 t.Fatal(err)
289 }
290 blk, err := ssh.MarshalPrivateKey(priv, "test")
291 if err != nil {
292 t.Fatal(err)
293 }
294 if err := os.WriteFile(path, pem.EncodeToMemory(blk), 0o600); err != nil {
295 t.Fatal(err)
296 }
297 sshPub, err := ssh.NewPublicKey(pub)
298 if err != nil {
299 t.Fatal(err)
300 }
301 return strings.TrimSpace(string(ssh.MarshalAuthorizedKey(sshPub))), path
302}
303
304// instanceRepo creates the instance's customization repository with files and
305// reloads the snapshot, so a test can seed a theme without a push. It is the
306// fixture equivalent of cloning the repository and committing.
307func (f *fixture) instanceRepo(t *testing.T, files map[string][]byte) {
308 t.Helper()
309 name := f.cfg.InstanceRepo
310 if name == "" {
311 name = "vole-instance"
312 f.cfg.InstanceRepo = name
313 }
314 if _, err := f.srv.Repos.Meta(name); err != nil {
315 m, err := f.srv.Repos.Create(name, f.ownerID, "instance customization")
316 if err != nil {
317 t.Fatalf("create instance repo: %v", err)
318 }
319 if f.srv.Index != nil {
320 if err := f.srv.Index.Upsert(m); err != nil {
321 t.Fatalf("index instance repo: %v", err)
322 }
323 }
324 }
325 m, err := f.srv.Repos.Open(name)
326 if err != nil {
327 t.Fatalf("open instance repo: %v", err)
328 }
329 g := m.Git()
330 if len(files) > 0 {
331 tree, err := git.WriteFlatTree(g, files)
332 if err != nil {
333 t.Fatalf("write instance tree: %v", err)
334 }
335 parent, _ := g.RevParseRaw("refs/heads/" + m.DefaultBranch)
336 commit, err := g.CommitTreeAs(tree, parent, "seed instance repository", f.ownerID)
337 if err != nil {
338 t.Fatalf("commit instance tree: %v", err)
339 }
340 if err := g.UpdateRef("refs/heads/"+m.DefaultBranch, commit); err != nil {
341 t.Fatalf("move instance branch: %v", err)
342 }
343 }
344 if f.srv.InstanceRepo != nil {
345 if err := f.srv.InstanceRepo.Reload(); err != nil {
346 t.Fatalf("reload instance repo: %v", err)
347 }
348 }
349}
350
351// addWriter creates a second user with maintain access and returns their key path.
352func (f *fixture) addWriter(t *testing.T) string {
353 t.Helper()
354 pub, priv := keypair(t, filepath.Join(f.cfg.DataDir, "id_writer"))
355 writer, err := f.srv.Identity.Create("wilbur", pub, "test")
356 if err != nil {
357 t.Fatal(err)
358 }
359 m := mustRepo(t, f)
360 a, err := m.ACL()
361 if err != nil {
362 t.Fatal(err)
363 }
364 // ACL entries are keyed by account id, not by name.
365 if err := a.SetEntry(writer.Name, "maintain"); err != nil {
366 t.Fatal(err)
367 }
368 if err := m.SetACL(a, "test"); err != nil {
369 t.Fatal(err)
370 }
371 f.writerID = writer.Name
372 return priv
373}
374
375// accountName checks that a fixture account is active and returns its name, the
376// identifier ACLs and approvals store.
377func (f *fixture) accountName(name string) string {
378 f.t.Helper()
379 a, err := f.srv.Identity.Find(name)
380 if err != nil || !a.Active() {
381 f.t.Fatalf("no active account %q", name)
382 }
383 return a.Name
384}
385
386// gitWithKey is f.git with a specific identity.
387func (f *fixture) gitWithKey(key, dir string, args ...string) (string, string, error) {
388 f.t.Helper()
389 cmd := exec.Command("git", args...)
390 cmd.Dir = dir
391 cmd.Env = append(os.Environ(),
392 "GIT_TERMINAL_PROMPT=0",
393 "GIT_CONFIG_GLOBAL=/dev/null",
394 "GIT_CONFIG_SYSTEM=/dev/null",
395 "GIT_SSH_COMMAND=ssh -i "+key+" -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR",
396 "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.com",
397 "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.com",
398 )
399 var out, errb bytes.Buffer
400 cmd.Stdout, cmd.Stderr = &out, &errb
401 err := cmd.Run()
402 return out.String(), errb.String(), err
403}
404
405func (f *fixture) sshURL(name string) string {
406 return "ssh://git@" + f.sshAddr + "/" + name + ".git"
407}
408
409func (f *fixture) httpURL(name string) string {
410 return f.http.URL + "/" + name + ".git"
411}
412
413func (f *fixture) git(dir string, args ...string) (string, string, error) {
414 f.t.Helper()
415 cmd := exec.Command("git", args...)
416 cmd.Dir = dir
417 cmd.Env = append(os.Environ(),
418 "GIT_TERMINAL_PROMPT=0",
419 "GIT_CONFIG_GLOBAL=/dev/null",
420 "GIT_CONFIG_SYSTEM=/dev/null",
421 "GIT_SSH_COMMAND=ssh -i "+f.keyPath+" -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR",
422 "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.com",
423 "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.com",
424 )
425 var out, errb bytes.Buffer
426 cmd.Stdout, cmd.Stderr = &out, &errb
427 err := cmd.Run()
428 return out.String(), errb.String(), err
429}
430
431func workRepo(t *testing.T, files map[string]string) string {
432 t.Helper()
433 dir := t.TempDir()
434 if _, errs, err := runGit(t, dir, "init", "-q", "-b", "main"); err != nil {
435 t.Fatalf("init: %v: %s", err, errs)
436 }
437 for name, content := range files {
438 p := filepath.Join(dir, name)
439 if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
440 t.Fatal(err)
441 }
442 if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
443 t.Fatal(err)
444 }
445 }
446 if _, errs, err := runGit(t, dir, "add", "-A"); err != nil {
447 t.Fatalf("add: %v: %s", err, errs)
448 }
449 if _, errs, err := runGit(t, dir, "commit", "-qm", "initial"); err != nil {
450 t.Fatalf("commit: %v: %s", err, errs)
451 }
452 return dir
453}
454
455func runGit(t *testing.T, dir string, args ...string) (string, string, error) {
456 t.Helper()
457 cmd := exec.Command("git", args...)
458 cmd.Dir = dir
459 cmd.Env = append(os.Environ(),
460 "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null",
461 "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.com",
462 "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.com",
463 )
464 var out, errb bytes.Buffer
465 cmd.Stdout, cmd.Stderr = &out, &errb
466 err := cmd.Run()
467 return out.String(), errb.String(), err
468}
469
470func (f *fixture) publish(name string) {
471 f.t.Helper()
472 m, err := f.srv.Repos.Open(name)
473 if err != nil {
474 f.t.Fatal(err)
475 }
476 a, err := m.ACL()
477 if err != nil {
478 f.t.Fatal(err)
479 }
480 a.Default = "read"
481 if err := m.SetACL(a, "test"); err != nil {
482 f.t.Fatal(err)
483 }
484 if err := f.srv.Index.Upsert(m); err != nil {
485 f.t.Fatal(err)
486 }
487}
488
489func (f *fixture) get(path string) (int, string) {
490 f.t.Helper()
491 resp, err := http.Get(f.http.URL + path)
492 if err != nil {
493 f.t.Fatal(err)
494 }
495 defer resp.Body.Close()
496 body, _ := io.ReadAll(resp.Body)
497 return resp.StatusCode, string(body)
498}
499
500// cli runs the built binary in dir as the owner. A null global git config means
501// a developer's own identity cannot change what a test sees.
502func (f *fixture) cli(dir string, args ...string) (string, string, error) {
503 f.t.Helper()
504 return f.cliWithKey(f.keyPath, dir, args...)
505}
506
507// cliWithKey is cli for another account. The key is what the instance resolves,
508// so a test can ask a different role the same question.
509func (f *fixture) cliWithKey(key, dir string, args ...string) (string, string, error) {
510 f.t.Helper()
511 cmd := exec.Command(binPath, args...)
512 cmd.Dir = dir
513 cmd.Env = append(os.Environ(),
514 "GIT_SSH_COMMAND=ssh -i "+key+" -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR",
515 "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null",
516 "USER=keenan")
517 var out, errb bytes.Buffer
518 cmd.Stdout, cmd.Stderr = &out, &errb
519 err := cmd.Run()
520 return out.String(), errb.String(), err
521}
522
523// TestPushOverSSHThenReadOverBothTransports is the core loop: writes need a key,
524// reads do not.
525func TestPushOverSSHThenReadOverBothTransports(t *testing.T) {
526 t.Parallel()
527 f := newFixture(t)
528 f.publish(f.repo)
529
530 work := workRepo(t, map[string]string{"readme.md": "# hello from vole\n"})
531 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
532 t.Fatalf("push: %v\n%s", err, errs)
533 }
534
535 // Anonymous HTTP clone must work once the repo is public.
536 dst := filepath.Join(t.TempDir(), "clone")
537 if _, errs, err := runGit(t, "", "clone", "-q", f.httpURL(f.repo), dst); err != nil {
538 t.Fatalf("anonymous http clone: %v\n%s", err, errs)
539 }
540 got, err := os.ReadFile(filepath.Join(dst, "readme.md"))
541 if err != nil {
542 t.Fatal(err)
543 }
544 if string(got) != "# hello from vole\n" {
545 t.Fatalf("cloned content = %q", got)
546 }
547
548 // The web shows it without any login.
549 if code, body := f.get("/" + f.repo); code != 200 || !strings.Contains(body, "readme.md") {
550 t.Fatalf("overview page: %d", code)
551 }
552 if code, body := f.get("/" + f.repo + "/blob/main/readme.md"); code != 200 || !strings.Contains(body, "hello from vole") {
553 t.Fatalf("blob page: %d", code)
554 }
555 if code, _ := f.get("/"); code != 200 {
556 t.Fatalf("index: %d", code)
557 }
558}
559
560// TestPrivateRepoIsInvisible covers the visibility rule: private means absent,
561// not forbidden.
562func TestPrivateRepoIsInvisible(t *testing.T) {
563 t.Parallel()
564 f := newFixture(t)
565 work := workRepo(t, map[string]string{"secret.md": "classified\n"})
566 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
567 t.Fatalf("push: %v\n%s", err, errs)
568 }
569
570 for _, path := range []string{"/" + f.repo, "/" + f.repo + "/blob/main/secret.md"} {
571 if code, _ := f.get(path); code != http.StatusNotFound {
572 t.Fatalf("%s: status %d, want 404", path, code)
573 }
574 }
575 if _, body := f.get("/"); strings.Contains(body, f.repo) {
576 t.Fatal("private repo was listed on the index page")
577 }
578 // And anonymous git must not see it either.
579 if _, _, err := runGit(t, "", "clone", "x", f.httpURL(f.repo), filepath.Join(t.TempDir(), "p")); err == nil {
580 // Clone of an empty/nonexistent repo may exit 0 with a warning; check the
581 // HTTP layer directly instead.
582 resp, err := http.Get(f.http.URL + "/" + f.repo + ".git/info/refs?service=git-upload-pack")
583 if err == nil {
584 defer resp.Body.Close()
585 if resp.StatusCode != http.StatusNotFound {
586 t.Fatalf("anonymous http on a private repo: %d", resp.StatusCode)
587 }
588 }
589 }
590
591 // The owner can still clone it over SSH, because that path is authenticated.
592 dst := filepath.Join(t.TempDir(), "own-clone")
593 if _, errs, err := f.git("", "clone", "-q", f.sshURL(f.repo), dst); err != nil {
594 t.Fatalf("owner ssh clone of a private repo: %v\n%s", err, errs)
595 }
596}
597
598func TestPushOverHTTPRefused(t *testing.T) {
599 t.Parallel()
600 f := newFixture(t)
601 f.publish(f.repo)
602 work := workRepo(t, map[string]string{"a.txt": "a\n"})
603 if _, _, err := runGit(t, work, "push", f.httpURL(f.repo), "main:refs/heads/main"); err == nil {
604 t.Fatal("push over HTTP succeeded")
605 }
606}
607
608func TestRebuildIndexAfterDelete(t *testing.T) {
609 t.Parallel()
610 f := newFixture(t)
611 f.publish(f.repo)
612 before, err := f.srv.Index.List()
613 if err != nil {
614 t.Fatal(err)
615 }
616 if len(before) != 1 {
617 t.Fatalf("want 1 indexed repo, got %d", len(before))
618 }
619
620 f.srv.SSH.Close()
621 f.http.Close()
622 f.srv.Close()
623 for _, p := range []string{f.cfg.IndexDB(), f.cfg.IndexDB() + "-wal", f.cfg.IndexDB() + "-shm"} {
624 if err := os.Remove(p); err != nil && !os.IsNotExist(err) {
625 t.Fatal(err)
626 }
627 }
628
629 cfgPath := filepath.Join(t.TempDir(), "vole.toml")
630 if err := os.WriteFile(cfgPath, []byte(fmt.Sprintf("data_dir = %q\n", f.cfg.DataDir)), 0o644); err != nil {
631 t.Fatal(err)
632 }
633 cmd := exec.Command(binPath, "index", "rebuild", "--config", cfgPath)
634 out, err := cmd.CombinedOutput()
635 if err != nil {
636 t.Fatalf("rebuild: %v\n%s", err, out)
637 }
638 ix, err := index.Open(f.cfg.IndexDB())
639 if err != nil {
640 t.Fatal(err)
641 }
642 defer ix.Close()
643 after, err := ix.List()
644 if err != nil {
645 t.Fatal(err)
646 }
647 if len(after) != len(before) || after[0].Name != before[0].Name || after[0].ID != before[0].ID {
648 t.Fatalf("rebuild mismatch: %+v vs %+v", before, after)
649 }
650}
651
652// TestRosterIsOneDatabaseFile proves the backup story end to end: the roster is
653// one SQLite file, a consistent copy of it is complete, and no system repository
654// or key directory exists beside it.
655func TestRosterIsOneDatabaseFile(t *testing.T) {
656 t.Parallel()
657 f := newFixture(t)
658 f.publish(f.repo)
659 work := workRepo(t, map[string]string{"readme.md": "x\n"})
660 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
661 t.Fatalf("push: %s", errs)
662 }
663
664 copied := filepath.Join(t.TempDir(), "users.db")
665 if err := f.srv.Identity.Backup(copied); err != nil {
666 t.Fatal(err)
667 }
668 ident, err := identity.Open(copied)
669 if err != nil {
670 t.Fatal(err)
671 }
672 defer ident.Close()
673 // This copy is only read here, but a store without a sink would refuse a
674 // mutation silently the next time a test used it to write.
675 ident.AuditPath = f.cfg.AuditLog()
676 if _, ok := ident.Snapshot().ByName["keenan"]; !ok {
677 t.Fatal("copied roster has no users")
678 }
679 if len(ident.Snapshot().ByKey) != 1 {
680 t.Fatalf("copied roster has %d keys", len(ident.Snapshot().ByKey))
681 }
682
683 for _, absent := range []string{"auth.db", "system.git", "trusted_keys"} {
684 if _, err := os.Stat(filepath.Join(f.cfg.DataDir, absent)); err == nil {
685 t.Fatalf("%s exists; the roster is supposed to be one database file", absent)
686 }
687 }
688}
689
690// TestRejectsKeyAddedOutsideTheCli is the property a plain directory trades away:
691// a hand-edited key is accepted, because there is no signed history to compare
692// against. What we keep is that the change is unambiguous and reloadable.
693func TestRosterIsReloadable(t *testing.T) {
694 t.Parallel()
695 f := newFixture(t)
696 // A key dropped in by hand is picked up on reload.
697 pub, _ := keypair(t, filepath.Join(f.cfg.DataDir, "id_second"))
698 if err := f.srv.Identity.Reload(); err != nil {
699 t.Fatal(err)
700 }
701 if _, err := f.srv.Identity.AddKey("keenan", pub, "test"); err != nil {
702 t.Fatal(err)
703 }
704 if got := len(f.srv.Identity.Snapshot().ByKey); got != 2 {
705 t.Fatalf("keys = %d, want 2", got)
706 }
707 audit, err := os.ReadFile(f.cfg.AuditLog())
708 if err != nil {
709 t.Fatal(err)
710 }
711 if !strings.Contains(string(audit), "identity.add-key") {
712 t.Fatalf("audit log missing the change:\n%s", audit)
713 }
714}
715
716const testIssueID = "01M5JR2JK29KKMAA1T9MDNTPBP"
717
718func (f *fixture) issuesRepo(t *testing.T, files map[string]string) string {
719 t.Helper()
720 dir := t.TempDir()
721 // Work on a scratch branch, never on vole itself: git refuses to fetch into
722 // the checked-out branch, which is exactly how a real user works too.
723 if _, errs, err := runGit(t, dir, "init", "-q", "-b", "scratch"); err != nil {
724 t.Fatalf("init: %v: %s", err, errs)
725 }
726 for name, content := range files {
727 p := filepath.Join(dir, name)
728 if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
729 t.Fatal(err)
730 }
731 if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
732 t.Fatal(err)
733 }
734 }
735 if _, errs, err := runGit(t, dir, "add", "-A"); err != nil {
736 t.Fatalf("add: %v: %s", err, errs)
737 }
738 if _, errs, err := runGit(t, dir, "commit", "-qm", "issues"); err != nil {
739 t.Fatalf("commit: %v: %s", err, errs)
740 }
741 return dir
742}
743
744func issueFiles(id, title, desc string) map[string]string {
745 return map[string]string{
746 "issues/" + id + "/issue.md": issueFile(title, "keenan", "open", "2026-10-22T21:00:00Z", []string{"bug"}, desc),
747 }
748}
749
750// TestIssuePushedThenRendered is the whole option-A loop: a user with an editor
751// and a key files a bug, and the web shows it.
752func TestIssuePushedThenRendered(t *testing.T) {
753 t.Parallel()
754 f := newFixture(t)
755 f.publish(f.repo)
756
757 desc := "Steps:\n\n1. push an issue\n2. read it back\n\n**bold claim** and `code`\n"
758 work := f.issuesRepo(t, issueFiles(testIssueID, "Push-based issues work", desc))
759 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "HEAD:refs/heads/vole"); err != nil {
760 t.Fatalf("push issues: %v\n%s", err, errs)
761 }
762
763 code, body := f.get("/" + f.repo + "/issues")
764 if code != 200 {
765 t.Fatalf("issue list: %d", code)
766 }
767 if !strings.Contains(body, "Push-based issues work") {
768 t.Fatalf("issue list missing the title:\n%s", body)
769 }
770 if !strings.Contains(body, "bug") {
771 t.Fatal("issue list missing the label")
772 }
773
774 code, body = f.get("/" + f.repo + "/issues/" + testIssueID)
775 if code != 200 {
776 t.Fatalf("issue view: %d", code)
777 }
778 if !strings.Contains(body, "<strong>bold claim</strong>") {
779 t.Fatalf("markdown was not rendered:\n%s", body)
780 }
781 if !strings.Contains(body, "<code>code</code>") {
782 t.Fatal("inline code was not rendered")
783 }
784 // An empty thread says so, and leaves the reader at the composer rather than
785 // sending them to a clone for a command the page can take.
786 if !strings.Contains(body, "No comments yet.") {
787 t.Fatalf("the empty comment state does not say it is empty:\n%s", body)
788 }
789 if strings.Contains(body, "vole issue comment") {
790 t.Fatalf("the empty comment state prints a CLI command:\n%s", body)
791 }
792 // The repo page advertises the count.
793 if _, body := f.get("/" + f.repo); !strings.Contains(body, "Issues") {
794 t.Fatal("repo page has no issues link")
795 }
796}
797
798// TestIssueMarkdownIsSanitised: issue bodies arrive by push, so an issue is
799// untrusted input rendered into a public page.
800func TestIssueMarkdownIsSanitised(t *testing.T) {
801 t.Parallel()
802 f := newFixture(t)
803 f.publish(f.repo)
804 desc := "hello\n\n<script>alert('xss')</script>\n\n[click](javascript:alert(1))\n"
805 work := f.issuesRepo(t, issueFiles(testIssueID, "XSS attempt", desc))
806 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "HEAD:refs/heads/vole"); err != nil {
807 t.Fatalf("push: %s", errs)
808 }
809 code, body := f.get("/" + f.repo + "/issues/" + testIssueID)
810 if code != 200 {
811 t.Fatalf("status %d", code)
812 }
813 if strings.Contains(body, "<script>alert") {
814 t.Fatal("script tag survived sanitisation")
815 }
816 if strings.Contains(body, "javascript:alert") {
817 t.Fatal("javascript: URL survived sanitisation")
818 }
819}
820
821// TestInvalidIssuePushRejected proves validation happens at the boundary: the
822// push fails, the message says why, and the branch does not move.
823func TestInvalidIssuePushRejected(t *testing.T) {
824 t.Parallel()
825 f := newFixture(t)
826 f.publish(f.repo)
827
828 good := f.issuesRepo(t, issueFiles(testIssueID, "fine", "body\n"))
829 if _, errs, err := f.git(good, "push", f.sshURL(f.repo), "HEAD:refs/heads/vole"); err != nil {
830 t.Fatalf("good push: %s", errs)
831 }
832 before, _, _ := runGit(t, filepath.Join(f.cfg.ReposDir(), f.repo+".git"),
833 "rev-parse", "--verify", "--quiet", "refs/heads/vole")
834
835 // Same history, but with a title-less issue added.
836 bad := issueFiles("01M5JR2JK29KKMAA1T9MDNTPBZ", "", "no title\n")
837 p := filepath.Join(good, "issues", "01M5JR2JK29KKMAA1T9MDNTPBZ")
838 if err := os.MkdirAll(p, 0o755); err != nil {
839 t.Fatal(err)
840 }
841 for name, content := range bad {
842 if err := os.WriteFile(filepath.Join(good, name), []byte(content), 0o644); err != nil {
843 t.Fatal(err)
844 }
845 }
846 if _, errs, err := runGit(t, good, "add", "-A"); err != nil {
847 t.Fatalf("add: %s", errs)
848 }
849 if _, errs, err := runGit(t, good, "commit", "-qm", "bad issue"); err != nil {
850 t.Fatalf("commit: %s", errs)
851 }
852 _, errs, err := f.git(good, "push", f.sshURL(f.repo), "HEAD:refs/heads/vole")
853 if err == nil {
854 t.Fatal("push with a title-less issue was accepted")
855 }
856 if !strings.Contains(errs, "title is required") {
857 t.Fatalf("rejection did not explain why:\n%s", errs)
858 }
859 after, _, _ := runGit(t, filepath.Join(f.cfg.ReposDir(), f.repo+".git"),
860 "rev-parse", "--verify", "--quiet", "refs/heads/vole")
861 if strings.TrimSpace(before) != strings.TrimSpace(after) {
862 t.Fatalf("issues branch moved on a rejected push: %s -> %s", before, after)
863 }
864}
865
866// TestIssuesSurviveIndexRebuild: issue rows are derived, so deleting the index
867// must not lose them.
868func TestIssuesSurviveIndexRebuild(t *testing.T) {
869 t.Parallel()
870 f := newFixture(t)
871 f.publish(f.repo)
872 work := f.issuesRepo(t, issueFiles(testIssueID, "persisted", "body\n"))
873 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "HEAD:refs/heads/vole"); err != nil {
874 t.Fatalf("push: %s", errs)
875 }
876 if err := f.srv.Index.EnsureIssues(mustRepo(t, f)); err != nil {
877 t.Fatal(err)
878 }
879 before, err := f.srv.Index.Issues(repoID(t, f), index.Filter{})
880 if err != nil {
881 t.Fatal(err)
882 }
883 if len(before) != 1 {
884 t.Fatalf("indexed issues = %d, want 1", len(before))
885 }
886
887 if _, err := f.srv.RebuildIndex(); err != nil {
888 t.Fatal(err)
889 }
890 after, err := f.srv.Index.Issues(repoID(t, f), index.Filter{})
891 if err != nil {
892 t.Fatal(err)
893 }
894 if len(after) != 1 || after[0].Title != before[0].Title || after[0].ID != before[0].ID {
895 t.Fatalf("rebuild lost issues: %+v vs %+v", before, after)
896 }
897}
898
899// TestIssueCLIEndToEnd drives the sugar path: a clone with a remote, the CLI
900// writing files and committing, and the push landing on the server.
901func TestIssueCLIEndToEnd(t *testing.T) {
902 t.Parallel()
903 f := newFixture(t)
904 f.publish(f.repo)
905 // A commit on main so the clone is not empty and has a default branch.
906 work := workRepo(t, map[string]string{"readme.md": "hi\n"})
907 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
908 t.Fatalf("push main: %s", errs)
909 }
910
911 clone := filepath.Join(t.TempDir(), "clone")
912 if _, errs, err := f.git("", "clone", "-q", f.sshURL(f.repo), clone); err != nil {
913 t.Fatalf("clone: %s", errs)
914 }
915
916 run := func(args ...string) (string, string, error) {
917 cmd := exec.Command(binPath, args...)
918 cmd.Dir = clone
919 cmd.Env = append(os.Environ(),
920 "GIT_SSH_COMMAND=ssh -i "+f.keyPath+" -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR",
921 "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null",
922 "USER=keenan")
923 var out, errb bytes.Buffer
924 cmd.Stdout, cmd.Stderr = &out, &errb
925 err := cmd.Run()
926 return out.String(), errb.String(), err
927 }
928
929 out, errs, err := run("issue", "new", "--title", "Filed from the CLI", "-m", "Body from the CLI.", "-l", "bug", "-a", "keenan")
930 if err != nil {
931 t.Fatalf("issue new: %v\n%s", err, errs)
932 }
933 fields := strings.Fields(out)
934 if len(fields) == 0 {
935 t.Fatalf("no id printed: %q", out)
936 }
937 id := fields[0]
938
939 // The assignment is in the file the server holds, not only in the local
940 // commit: this is the format the web reads.
941 serverFile := func(path string) string {
942 out, _, _ := runGit(t, filepath.Join(f.cfg.ReposDir(), f.repo+".git"), "show", "refs/heads/vole:"+path)
943 return out
944 }
945 if got := serverFile("issues/" + id + "/issue.md"); !strings.Contains(got, "assignees:\n - keenan\n") {
946 t.Fatalf("assignment did not reach the branch:\n%s", got)
947 }
948 // Assign and unassign are normal edits of the same file. An assignment that
949 // changes nothing is not a commit: a rerun must leave the tip alone.
950 tip := f.serverIssues(t)
951 if _, errs, err := run("issue", "assign", id, "keenan"); err != nil {
952 t.Fatalf("assign (already assigned): %v\n%s", err, errs)
953 }
954 if f.serverIssues(t) != tip {
955 t.Fatal("re-assigning the same person moved the branch (empty commit)")
956 }
957 if _, errs, err := run("issue", "unassign", id, "keenan"); err != nil {
958 t.Fatalf("unassign: %v\n%s", err, errs)
959 }
960 if got := serverFile("issues/" + id + "/issue.md"); strings.Contains(got, "assignees:") {
961 t.Fatalf("unassign left the assignee behind:\n%s", got)
962 }
963 // A typed @ is accepted the way people write it.
964 if _, errs, err := run("issue", "assign", id, "@wilbur"); err != nil {
965 t.Fatalf("assign @wilbur: %v\n%s", err, errs)
966 }
967 if got := serverFile("issues/" + id + "/issue.md"); !strings.Contains(got, " - wilbur") {
968 t.Fatalf("@ was not stripped:\n%s", got)
969 }
970 if _, body := f.get("/" + f.repo + "/issues?assignee=wilbur"); !strings.Contains(body, "Filed from the CLI") {
971 t.Fatal("the assignment is not filterable on the web")
972 }
973
974 if !strings.HasPrefix(f.serverIssues(t), "") {
975 t.Fatal("issues branch missing on the server")
976 }
977 if _, body := f.get("/" + f.repo + "/issues"); !strings.Contains(body, "Filed from the CLI") {
978 t.Fatalf("issue not rendered after CLI push:\n%s", body)
979 }
980 if code, _ := f.get("/" + f.repo + "/issues/" + id); code != 200 {
981 t.Fatalf("issue page: %d", code)
982 }
983 // The page names the `#42` alias, and `/n/1` — the kind-agnostic route a `#1`
984 // in a body links to — lands on the same issue.
985 if _, body := f.get("/" + f.repo + "/issues/" + id); !strings.Contains(body, "#1") {
986 t.Fatal("the issue page does not show its number")
987 }
988 if _, body := f.get("/" + f.repo + "/n/1"); !strings.Contains(body, "Filed from the CLI") {
989 t.Fatal("#1 does not resolve to the issue")
990 }
991
992 // `issue list` prints the `#42` alias, so the alias has to resolve. The 8-character
993 // prefix still works too: it is what an older script would have parsed.
994 short := id
995 if len(short) > 8 {
996 short = short[:8]
997 }
998 if out, errs, err := run("issue", "list"); err != nil || !strings.Contains(out, "#1") {
999 t.Fatalf("issue list did not show the number: %v %q %s", err, out, errs)
1000 }
1001 if _, errs, err := run("issue", "comment", "-m", "A reply from the CLI.", "#1"); err != nil {
1002 t.Fatalf("comment by number: %v\n%s", err, errs)
1003 }
1004 if _, errs, err := run("issue", "comment", "-m", "A reply from the CLI.", short); err != nil {
1005 t.Fatalf("comment: %v\n%s", err, errs)
1006 }
1007 if _, body := f.get("/" + f.repo + "/issues/" + id); !strings.Contains(body, "A reply from the CLI.") {
1008 t.Fatal("comment not rendered")
1009 }
1010
1011 // The comment id is on stdout so the editing commands have something to take.
1012 // (A prefix works too, but two comments written in the same millisecond share
1013 // their ULID timestamp, so this test uses the whole id.)
1014 cmtOut, errs, err := run("issue", "comment", "-m", "A typo-ridden reply.", short)
1015 if err != nil {
1016 t.Fatalf("comment: %v\n%s", err, errs)
1017 }
1018 cid := strings.TrimSpace(cmtOut)
1019 if len(cid) < 8 {
1020 t.Fatalf("no comment id printed: %q", cmtOut)
1021 }
1022 replyOut, errs, err := run("issue", "comment", "-m", "A threaded answer.", "--reply-to", cid, short)
1023 if err != nil {
1024 t.Fatalf("reply: %v\n%s", err, errs)
1025 }
1026 replyID := strings.TrimSpace(replyOut)
1027 if len(replyID) < 8 {
1028 t.Fatalf("no reply id printed: %q", replyOut)
1029 }
1030 _, issuePage := f.get("/" + f.repo + "/issues/" + id)
1031 if !strings.Contains(issuePage, `class="comment-replies"`) || !strings.Contains(issuePage, `id="c-`+replyID+`"`) ||
1032 !strings.Contains(issuePage, "A threaded answer.") {
1033 t.Fatalf("the CLI reply is not nested:\n%s", issuePage)
1034 }
1035 if _, errs, err := run("issue", "rm-comment", short, replyID); err != nil {
1036 t.Fatalf("rm reply: %v\n%s", err, errs)
1037 }
1038 if _, errs, err := run("issue", "edit-comment", short, cid, "-m", "A corrected reply."); err != nil {
1039 t.Fatalf("edit-comment: %v\n%s", err, errs)
1040 }
1041 _, body := f.get("/" + f.repo + "/issues/" + id)
1042 if !strings.Contains(body, "A corrected reply.") || strings.Contains(body, "A typo-ridden reply.") {
1043 t.Fatalf("the edit did not replace the comment:\n%s", body)
1044 }
1045 // The issue's own fields are editable in the same way, without touching the
1046 // comment thread.
1047 if _, errs, err := run("issue", "edit", short, "--title", "Retitled from the CLI", "-l", "bug", "-l", "cli"); err != nil {
1048 t.Fatalf("edit: %v\n%s", err, errs)
1049 }
1050 if _, body := f.get("/" + f.repo + "/issues/" + id); !strings.Contains(body, "Retitled from the CLI") ||
1051 !strings.Contains(body, "A corrected reply.") {
1052 t.Fatalf("the issue edit lost the title or the thread:\n%s", body)
1053 }
1054 if _, errs, err := run("issue", "rm-comment", short, cid); err != nil {
1055 t.Fatalf("rm-comment: %v\n%s", err, errs)
1056 }
1057 if _, body := f.get("/" + f.repo + "/issues/" + id); strings.Contains(body, "A corrected reply.") {
1058 t.Fatal("the deleted comment is still rendered")
1059 }
1060
1061 // A parent can be deleted while its replies remain. The parent becomes a
1062 // tombstone, so the replies keep the thread they answer.
1063 rootOut, errs, err := run("issue", "comment", "-m", "Tombstone root.", short)
1064 if err != nil {
1065 t.Fatalf("tombstone root: %v\n%s", err, errs)
1066 }
1067 tombstoneRoot := strings.TrimSpace(rootOut)
1068 replyOut, errs, err = run("issue", "comment", "-m", "Still here.", "--reply-to", tombstoneRoot, short)
1069 if err != nil {
1070 t.Fatalf("tombstone reply: %v\n%s", err, errs)
1071 }
1072 tombstoneReply := strings.TrimSpace(replyOut)
1073 if _, errs, err := run("issue", "rm-comment", short, tombstoneRoot); err != nil {
1074 t.Fatalf("delete parent: %v\n%s", err, errs)
1075 }
1076 _, body = f.get("/" + f.repo + "/issues/" + id)
1077 if !strings.Contains(body, "Comment deleted") || !strings.Contains(body, `id="c-`+tombstoneReply+`"`) ||
1078 !strings.Contains(body, "Still here.") || !strings.Contains(body, "a deleted comment") {
1079 t.Fatalf("the deleted parent left no tombstone for its reply:\n%s", body)
1080 }
1081 if _, errs, err := run("issue", "rm-comment", short, tombstoneReply); err != nil {
1082 t.Fatalf("rm tombstone reply: %v\n%s", err, errs)
1083 }
1084
1085 if _, errs, err := run("issue", "close", short); err != nil {
1086 t.Fatalf("close: %v\n%s", err, errs)
1087 }
1088 if _, body := f.get("/" + f.repo + "/issues/" + id); !strings.Contains(body, "closed") {
1089 t.Fatal("issue did not close")
1090 }
1091
1092 // And a local validation failure never reaches the server.
1093 if _, errs, err := run("issue", "new", "--title", "", "-m", "x"); err == nil {
1094 t.Fatalf("empty title accepted by the CLI: %s", errs)
1095 }
1096
1097 // CLI conventions: git accepts options after arguments, so Vole does too.
1098 if _, errs, err := run("issue", "comment", short, "-m", "late option"); err != nil {
1099 t.Fatalf("option after argument was refused, but git accepts these: %v\n%s", err, errs)
1100 }
1101
1102 // A genuine usage mistake still exits 129 with a message naming the option.
1103 cmd := exec.Command(binPath, "issue", "comment", short, "--nonsense")
1104 cmd.Dir = clone
1105 var errb bytes.Buffer
1106 cmd.Stderr = &errb
1107 err = cmd.Run()
1108 if err == nil {
1109 t.Fatal("unknown option was accepted")
1110 }
1111 var ee *exec.ExitError
1112 if errors.As(err, &ee) && ee.ExitCode() != 129 {
1113 t.Fatalf("usage error exit code = %d, want 129 (%s)", ee.ExitCode(), errb.String())
1114 }
1115 if !strings.Contains(errb.String(), "unknown option --nonsense") {
1116 t.Fatalf("unhelpful usage error: %s", errb.String())
1117 }
1118}
1119
1120// TestWhoAmIAnswersFromTheInstance is the fix for a name guessed from git config.
1121// `user.name` on a real developer machine is "Keenan Nemetz", which sanitises to
1122// `keenan-nemetz` — an account that does not exist — and the old CLI stamped that
1123// on every issue it filed, silently. The key is the identity; the instance is the
1124// only thing that knows which account it belongs to.
1125func TestWhoAmIAnswersFromTheInstance(t *testing.T) {
1126 t.Parallel()
1127 f := newFixtureWith(t, func(c *config.Config) { c.PublicURL = "http://forge.test" })
1128 f.publish(f.repo)
1129 work := workRepo(t, map[string]string{"readme.md": "hi\n"})
1130 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
1131 t.Fatalf("push main: %s", errs)
1132 }
1133 clone := filepath.Join(t.TempDir(), "clone")
1134 if _, errs, err := f.git("", "clone", "-q", f.sshURL(f.repo), clone); err != nil {
1135 t.Fatalf("clone: %s", errs)
1136 }
1137 // The trap, exactly as it looks on a normal machine.
1138 if _, errs, err := runGit(t, clone, "config", "user.name", "Keenan Nemetz"); err != nil {
1139 t.Fatalf("config: %s", errs)
1140 }
1141
1142 out, errs, err := f.cli(clone, "whoami")
1143 if err != nil {
1144 t.Fatalf("whoami: %v\n%s", err, errs)
1145 }
1146 if got := strings.TrimSpace(out); got != "keenan" {
1147 t.Fatalf("whoami = %q, want the account name keenan", got)
1148 }
1149 // A filed issue is authored by the account, not by the guess, and the command
1150 // says where to read it.
1151 out, errs, err = f.cli(clone, "issue", "new", "--title", "who am I")
1152 if err != nil {
1153 t.Fatalf("issue new: %v\n%s", err, errs)
1154 }
1155 id := strings.Fields(out)[0]
1156 raw, _, err := runGit(t, filepath.Join(f.cfg.ReposDir(), f.repo+".git"),
1157 "show", "refs/heads/vole:issues/"+id+"/issue.md")
1158 if err != nil {
1159 t.Fatalf("read the issue from the server: %v\n%s", err, raw)
1160 }
1161 if !strings.Contains(raw, "author: keenan\n") {
1162 t.Fatalf("the issue was not authored by the account:\n%s", raw)
1163 }
1164 if strings.Contains(raw, "keenan-nemetz") {
1165 t.Fatalf("the git-config guess reached the branch:\n%s", raw)
1166 }
1167 want := "http://forge.test/" + f.repo + "/issues/" + id
1168 if !strings.Contains(errs, want) {
1169 t.Fatalf("the CLI did not print where to read it (%q):\n%s", want, errs)
1170 }
1171
1172 // A comment prints a permalink that resolves: the page carries the anchor.
1173 out, errs, err = f.cli(clone, "issue", "comment", id, "-m", "a reply")
1174 if err != nil {
1175 t.Fatalf("comment: %v\n%s", err, errs)
1176 }
1177 cid := strings.TrimSpace(out)
1178 if !strings.Contains(errs, want+"#c-"+cid) {
1179 t.Fatalf("the comment permalink is wrong:\n%s", errs)
1180 }
1181 _, page := f.get("/" + f.repo + "/issues/" + id)
1182 if !strings.Contains(page, `id="c-`+cid+`"`) {
1183 t.Fatalf("the page has no anchor for %s:\n%s", cid, page)
1184 }
1185
1186 // The typed name is gone: a name you can type is a name you can get wrong.
1187 if _, errs, err := f.cli(clone, "issue", "new", "--title", "x", "--author", "wilbur"); err == nil {
1188 t.Fatalf("--author is still accepted: %s", errs)
1189 }
1190}
1191
1192// TestWhoAmIRoleAnswersFromTheInstance checks the explicit role query. The role
1193// is per repository, so the answer comes from the ACL behind the remote, not from
1194// the account's instance role alone. A private repository the key cannot read
1195// gives the same answer as a clone: not found.
1196func TestWhoAmIRoleAnswersFromTheInstance(t *testing.T) {
1197 t.Parallel()
1198 f := newFixture(t)
1199 work := workRepo(t, map[string]string{"readme.md": "hi\n"})
1200 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
1201 t.Fatalf("push main: %s", errs)
1202 }
1203 clone := filepath.Join(t.TempDir(), "clone")
1204 if _, errs, err := f.git("", "clone", "-q", f.sshURL(f.repo), clone); err != nil {
1205 t.Fatalf("clone: %s", errs)
1206 }
1207
1208 out, errs, err := f.cli(clone, "whoami", "--role")
1209 if err != nil {
1210 t.Fatalf("whoami --role: %v\n%s", err, errs)
1211 }
1212 if got := strings.TrimSpace(out); got != "admin" {
1213 t.Fatalf("whoami --role = %q, want admin for the repository owner", got)
1214 }
1215
1216 writerKey := f.addWriter(t)
1217 out, errs, err = f.cliWithKey(writerKey, clone, "whoami", "--role")
1218 if err != nil {
1219 t.Fatalf("writer whoami --role: %v\n%s", err, errs)
1220 }
1221 if got := strings.TrimSpace(out); got != "maintain" {
1222 t.Fatalf("writer whoami --role = %q, want maintain", got)
1223 }
1224
1225 // A stranger on a private repository learns nothing about it, just like a
1226 // fetch that the read rule refuses.
1227 pub, priv := keypair(t, filepath.Join(f.cfg.DataDir, "id_stranger"))
1228 if _, err := f.srv.Identity.Create("stranger", pub, "test"); err != nil {
1229 t.Fatal(err)
1230 }
1231 if _, errs, err := f.cliWithKey(priv, clone, "whoami", "--role"); err == nil {
1232 t.Fatalf("a stranger read a private repository's role: %s", errs)
1233 } else if !strings.Contains(errs, "not found") {
1234 t.Fatalf("the refusal does not hide the private repository: %s", errs)
1235 }
1236
1237 // Published, the repository's default role is the account's role.
1238 f.publish(f.repo)
1239 out, errs, err = f.cliWithKey(priv, clone, "whoami", "--role")
1240 if err != nil {
1241 t.Fatalf("stranger whoami --role on a public repository: %v\n%s", err, errs)
1242 }
1243 if got := strings.TrimSpace(out); got != "read" {
1244 t.Fatalf("stranger whoami --role = %q, want read after publishing", got)
1245 }
1246}
1247
1248// TestReadCommandsNeedNoIdentity: `issue list` and `issue show` read the branch,
1249// so they must keep working where the instance cannot be reached at all.
1250func TestReadCommandsNeedNoIdentity(t *testing.T) {
1251 t.Parallel()
1252 f := newFixture(t)
1253 f.publish(f.repo)
1254 work := workRepo(t, map[string]string{"readme.md": "hi\n"})
1255 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
1256 t.Fatalf("push main: %s", errs)
1257 }
1258 clone := filepath.Join(t.TempDir(), "clone")
1259 if _, errs, err := f.git("", "clone", "-q", f.sshURL(f.repo), clone); err != nil {
1260 t.Fatalf("clone: %s", errs)
1261 }
1262 if out, errs, err := f.cli(clone, "issue", "new", "--title", "offline read", "-m", "body"); err != nil {
1263 t.Fatalf("issue new: %v (%s)", err, errs)
1264 } else if fields := strings.Fields(out); len(fields) > 0 {
1265 // The instance goes away; the branch is still here.
1266 f.srv.SSH.Close()
1267 out, errs, err := f.cli(clone, "issue", "list")
1268 if err != nil {
1269 t.Fatalf("issue list needs the instance but should not: %v\n%s", err, errs)
1270 }
1271 if !strings.Contains(out, "offline read") {
1272 t.Fatalf("issue list lost the issue:\n%s", out)
1273 }
1274 }
1275}
1276
1277// TestRequestAuthorComesFromTheKey: a request records the account that opened it,
1278// and there is no flag for saying otherwise. `--author` was a name you could typo
1279// into a round trip; the key already answers the question.
1280func TestRequestAuthorComesFromTheKey(t *testing.T) {
1281 t.Parallel()
1282 f := newFixture(t)
1283 f.publish(f.repo)
1284 work := workRepo(t, map[string]string{"readme.md": "hi\n"})
1285 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
1286 t.Fatalf("push main: %s", errs)
1287 }
1288 clone := filepath.Join(t.TempDir(), "clone")
1289 if _, errs, err := f.git("", "clone", "-q", f.sshURL(f.repo), clone); err != nil {
1290 t.Fatalf("clone: %s", errs)
1291 }
1292 if _, errs, err := runGit(t, clone, "config", "user.name", "Keenan Nemetz"); err != nil {
1293 t.Fatalf("config: %s", errs)
1294 }
1295 // A request reviews a change, so it needs a head that is not already the base.
1296 runGit(t, clone, "checkout", "-q", "-b", "topic")
1297 if err := os.WriteFile(filepath.Join(clone, "readme.md"), []byte("hi\ntopic\n"), 0o644); err != nil {
1298 t.Fatal(err)
1299 }
1300 runGit(t, clone, "commit", "-qam", "topic")
1301 if _, errs, err := f.git(clone, "push", f.sshURL(f.repo), "topic:refs/heads/topic"); err != nil {
1302 t.Fatalf("push topic: %s", errs)
1303 }
1304
1305 out, errs, err := f.cli(clone, "change", "new", "--title", "author from the key", "--head", "topic", "-m", "body")
1306 if err != nil {
1307 t.Fatalf("request new: %v\n%s", err, errs)
1308 }
1309 fields := strings.Fields(out)
1310 if len(fields) == 0 {
1311 t.Fatalf("no request id printed: %q", out)
1312 }
1313 raw, _, err := runGit(t, filepath.Join(f.cfg.ReposDir(), f.repo+".git"),
1314 "show", "refs/heads/vole:changes/"+fields[0]+"/change.md")
1315 if err != nil {
1316 t.Fatalf("read request from the server: %v\n%s", err, raw)
1317 }
1318 if !strings.Contains(raw, "author: keenan\n") {
1319 t.Fatalf("request author is not the account:\n%s", raw)
1320 }
1321 if strings.Contains(raw, "author_id:") {
1322 t.Fatalf("request still carries a second identifier:\n%s", raw)
1323 }
1324 if strings.Contains(raw, "keenan-nemetz") {
1325 t.Fatalf("the git-config guess reached the request:\n%s", raw)
1326 }
1327 if _, errs, err := f.cli(clone, "change", "new", "--title", "x", "--author", "wilbur"); err == nil {
1328 t.Fatalf("--author is still accepted: %s", errs)
1329 }
1330}
1331
1332// TestAURLRemoteIsFetchedBeforeWriting: `git init` plus `--remote ssh://…` is how
1333// somebody reports a bug without cloning. It used to work once per repository and
1334// then fail — the fetch was skipped for a URL, so the second writer pushed from a
1335// tip it had never updated, and the retry reported a "lost race" that never
1336// happened and re-applied onto the same stale base.
1337func TestAURLRemoteIsFetchedBeforeWriting(t *testing.T) {
1338 t.Parallel()
1339 f := newFixture(t)
1340 f.publish(f.repo)
1341 fresh := func() string {
1342 dir := t.TempDir()
1343 if _, errs, err := runGit(t, dir, "init", "-q", "-b", "main"); err != nil {
1344 t.Fatalf("init: %v: %s", err, errs)
1345 }
1346 return dir
1347 }
1348 url := f.sshURL(f.repo)
1349 for i, dir := range []string{fresh(), fresh()} {
1350 if _, errs, err := f.cli(dir, "issue", "new", "--title", fmt.Sprintf("from repo %d", i), "--remote", url); err != nil {
1351 t.Fatalf("repo %d could not write through a URL: %v\n%s", i, err, errs)
1352 }
1353 }
1354 // The second write only lands if it fetched first, so both being on the branch
1355 // is the assertion.
1356 _, body := f.get("/" + f.repo + "/issues")
1357 for _, want := range []string{"from repo 0", "from repo 1"} {
1358 if !strings.Contains(body, want) {
1359 t.Fatalf("%q is missing from the branch:\n%s", want, body)
1360 }
1361 }
1362}
1363
1364// TestEmptyStatesPrintAWorkingCommand: opening an issue or a change is a write
1365// the web cannot take from an empty list, so those pages hand over the command.
1366// A discussion has a composer on the page and does not need one.
1367func TestEmptyStatesPrintAWorkingCommand(t *testing.T) {
1368 t.Parallel()
1369 f := newFixture(t)
1370 f.publish(f.repo)
1371 for _, c := range []struct{ path, want string }{
1372 {"/" + f.repo + "/issues", "vole issue new --title"},
1373 {"/" + f.repo + "/changes", "vole change new --title"},
1374 } {
1375 code, body := f.get(c.path)
1376 if code != 200 {
1377 t.Fatalf("%s: %d", c.path, code)
1378 }
1379 if !strings.Contains(body, c.want) {
1380 t.Fatalf("%s does not print %q:\n%s", c.path, c.want, body)
1381 }
1382 }
1383}
1384
1385// TestRequestPageSaysWhenItWouldConflict: a diff says what changed, never whether
1386// the branch still merges, and this page is where an approval is decided. The
1387// answer comes from `git merge-tree` in the repository — nothing is written — and
1388// it is only claimed when the question could actually be asked.
1389func TestRequestPageSaysWhenItWouldConflict(t *testing.T) {
1390 t.Parallel()
1391 f := newFixture(t)
1392 f.publish(f.repo)
1393
1394 base := workRepo(t, map[string]string{"code.txt": "a\nb\nc\n"})
1395 if _, errs, err := f.git(base, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
1396 t.Fatalf("main push: %s", errs)
1397 }
1398 // The feature branch changes the middle line...
1399 feature, head := f.cloneWithChange(t, map[string]string{"code.txt": "a\nFEATURE\nc\n"}, "feature")
1400 if _, errs, err := f.git(feature, "push", f.sshURL(f.repo), "HEAD:refs/heads/feature"); err != nil {
1401 t.Fatalf("feature push: %s", errs)
1402 }
1403 // ...and main moves the same line before anybody reviews it.
1404 if err := os.WriteFile(filepath.Join(base, "code.txt"), []byte("a\nMAIN\nc\n"), 0o644); err != nil {
1405 t.Fatal(err)
1406 }
1407 if _, errs, err := runGit(t, base, "add", "-A"); err != nil {
1408 t.Fatalf("add: %s", errs)
1409 }
1410 if _, errs, err := runGit(t, base, "commit", "-qm", "main moves too"); err != nil {
1411 t.Fatalf("commit: %s", errs)
1412 }
1413 if _, errs, err := f.git(base, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
1414 t.Fatalf("second main push: %s", errs)
1415 }
1416
1417 meta := f.newMetaRepo(t)
1418 if err := meta.apply(map[string]string{
1419 "changes/" + reqID + "/change.md": requestFile("main", head, "keenan", "why this change\n"),
1420 }, "request"); err != nil {
1421 t.Fatalf("request push: %v", err)
1422 }
1423
1424 code, body := f.get("/" + f.repo + "/changes/" + reqID)
1425 if code != 200 {
1426 t.Fatalf("request page: %d", code)
1427 }
1428 if !strings.Contains(body, "no longer merges into main cleanly") || !strings.Contains(body, "code.txt") {
1429 t.Fatalf("the page did not report the conflict:\n%s", body)
1430 }
1431 // The requirement is on the page, not left to memory.
1432 if !strings.Contains(body, "0 of 1 required approval(s)") {
1433 t.Fatalf("the page does not state how many approvals are needed:\n%s", body)
1434 }
1435
1436 // A branch that does merge says so, so silence means "could not check".
1437 clean, cleanHead := f.cloneWithChange(t, map[string]string{"other.txt": "new file\n"}, "clean")
1438 if _, errs, err := f.git(clean, "push", f.sshURL(f.repo), "HEAD:refs/heads/clean"); err != nil {
1439 t.Fatalf("clean push: %s", errs)
1440 }
1441 const cleanID = "01M5JR2JK29KKMAA1T9MDNTR02"
1442 if err := meta.apply(map[string]string{
1443 "changes/" + cleanID + "/change.md": requestFile("main", cleanHead, "keenan", "clean change\n"),
1444 }, "clean request"); err != nil {
1445 t.Fatalf("clean request push: %v", err)
1446 }
1447 if _, body := f.get("/" + f.repo + "/changes/" + cleanID); !strings.Contains(body, "Merges into main cleanly.") {
1448 t.Fatalf("a mergeable branch was not reported as clean:\n%s", body)
1449 }
1450
1451 // The CLI asks the same question from the clone, and answers it while the head
1452 // is reachable.
1453 clone := filepath.Join(t.TempDir(), "clone")
1454 if _, errs, err := f.git("", "clone", "-q", f.sshURL(f.repo), clone); err != nil {
1455 t.Fatalf("clone: %s", errs)
1456 }
1457 out, _, err := f.cli(clone, "change", "show", reqID)
1458 if err != nil {
1459 t.Fatalf("request show: %v", err)
1460 }
1461 if !strings.Contains(out, "CONFLICT with main in code.txt") {
1462 t.Fatalf("the CLI did not report the conflict:\n%s", out)
1463 }
1464
1465 // With the branch gone, the head is unreachable, and neither side claims
1466 // anything: a page that says "clean" for a commit it could not load is worse
1467 // than one that stays quiet.
1468 if _, errs, err := f.git(clone, "push", f.sshURL(f.repo), ":refs/heads/feature"); err != nil {
1469 t.Fatalf("delete branch: %s", errs)
1470 }
1471 fresh := filepath.Join(t.TempDir(), "fresh")
1472 if _, errs, err := f.git("", "clone", "-q", f.sshURL(f.repo), fresh); err != nil {
1473 t.Fatalf("fresh clone: %s", errs)
1474 }
1475 out, _, err = f.cli(fresh, "change", "show", reqID)
1476 if err != nil {
1477 t.Fatalf("request show: %v", err)
1478 }
1479 if !strings.Contains(out, "cannot check") || !strings.Contains(out, "git fetch") {
1480 t.Fatalf("a clone without the head claimed something about the merge:\n%s", out)
1481 }
1482 // A request whose head was never pushed is the state the page must stay quiet
1483 // about: the commit is not in the repository, so there is no merge to report.
1484 const ghostID = "01M5JR2JK29KKMAA1T9MDNTR03"
1485 if err := meta.apply(map[string]string{
1486 "changes/" + ghostID + "/change.md": requestFile("main",
1487 "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef", "keenan", "ghost head\n"),
1488 }, "ghost request"); err != nil {
1489 t.Fatalf("ghost request push: %v", err)
1490 }
1491 if code, body := f.get("/" + f.repo + "/changes/" + ghostID); code != 200 ||
1492 strings.Contains(body, "no longer merges") || strings.Contains(body, "Merges into main cleanly.") {
1493 t.Fatalf("the page claimed a merge result it could not compute (%d):\n%s", code, body)
1494 }
1495}
1496
1497// TestRequestDiscussionIsPushedAndOwned: a change request can be discussed, and
1498// the discussion is the same kind of file as an issue comment — pushed, validated
1499// at the boundary, and owned by whoever wrote it. Without this, the only ways to
1500// say something about a request are editing its body or approving it.
1501func TestRequestDiscussionIsPushedAndOwned(t *testing.T) {
1502 t.Parallel()
1503 f := newFixture(t)
1504 writerKey := f.addWriter(t) // wilbur: maintain access, not admin
1505 f.publish(f.repo)
1506
1507 base := workRepo(t, map[string]string{"readme.md": "v1\n"})
1508 if _, errs, err := f.git(base, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
1509 t.Fatalf("main push: %s", errs)
1510 }
1511 feature, head := f.cloneWithChange(t, map[string]string{"readme.md": "v1\nfeature\n"}, "feature")
1512 if _, errs, err := f.git(feature, "push", f.sshURL(f.repo), "HEAD:refs/heads/feature"); err != nil {
1513 t.Fatalf("feature push: %s", errs)
1514 }
1515 meta := f.newMetaRepo(t)
1516 if err := meta.apply(map[string]string{
1517 "changes/" + reqID + "/change.md": requestFile("main", head, "keenan", "rationale\n"),
1518 }, "request"); err != nil {
1519 t.Fatalf("request push: %v", err)
1520 }
1521
1522 // An empty discussion says so, and leaves the reader at the composer rather
1523 // than sending them to a clone for a command the page can take.
1524 code, body := f.get("/" + f.repo + "/changes/" + reqID)
1525 if code != 200 {
1526 t.Fatalf("request page: %d", code)
1527 }
1528 if !strings.Contains(body, "No discussion yet.") {
1529 t.Fatalf("the empty discussion state does not say it is empty:\n%s", body)
1530 }
1531 if strings.Contains(body, "vole change comment") {
1532 t.Fatalf("the empty discussion state prints a CLI command:\n%s", body)
1533 }
1534
1535 // The author comments through the CLI, and the id is printed so the editing
1536 // commands have something to take.
1537 clone := filepath.Join(t.TempDir(), "clone")
1538 if _, errs, err := f.git("", "clone", "-q", f.sshURL(f.repo), clone); err != nil {
1539 t.Fatalf("clone: %s", errs)
1540 }
1541 out, errs, err := f.cli(clone, "change", "comment", reqID, "-m", "Looks good to me.")
1542 if err != nil {
1543 t.Fatalf("request comment: %v\n%s", err, errs)
1544 }
1545 cid := strings.TrimSpace(out)
1546 if len(cid) != 26 {
1547 t.Fatalf("no comment id printed: %q", out)
1548 }
1549 _, body = f.get("/" + f.repo + "/changes/" + reqID)
1550 if !strings.Contains(body, "Looks good to me.") || !strings.Contains(body, `id="c-`+cid+`"`) {
1551 t.Fatalf("the comment is not rendered with an anchor:\n%s", body)
1552 }
1553 // `request show` prints the thread with ids, which is how they are found.
1554 if out, _, err := f.cli(clone, "change", "show", reqID); err != nil ||
1555 !strings.Contains(out, "--- "+cid+" keenan") {
1556 t.Fatalf("request show does not print the thread: %v\n%s", err, out)
1557 }
1558 replyOut, errs, err := f.cli(clone, "change", "comment", reqID, "--reply-to", cid, "-m", "A threaded reply.")
1559 if err != nil {
1560 t.Fatalf("threaded request comment: %v\n%s", err, errs)
1561 }
1562 replyID := strings.TrimSpace(replyOut)
1563 if len(replyID) != 26 {
1564 t.Fatalf("no reply id printed: %q", replyOut)
1565 }
1566 _, body = f.get("/" + f.repo + "/changes/" + reqID)
1567 if !strings.Contains(body, `class="comment-replies"`) || !strings.Contains(body, `id="c-`+replyID+`"`) ||
1568 !strings.Contains(body, "A threaded reply.") {
1569 t.Fatalf("the CLI request reply is not nested:\n%s", body)
1570 }
1571
1572 commentPath := func(id string) string { return "changes/" + reqID + "/comments/" + id + ".md" }
1573 const created = "2026-10-22T23:00:00Z"
1574
1575 // Someone else may not rewrite it, adopt it, or delete it.
1576 other := f.newMetaRepo(t)
1577 other.key = writerKey
1578 if err := other.apply(map[string]string{commentPath(cid): commentFile("keenan", created, "something else\n")}, "edit foreign"); err == nil {
1579 t.Fatal("a writer rewrote somebody else's review comment")
1580 } else if !strings.Contains(err.Error(), "only keenan can change this comment") {
1581 t.Fatalf("refusal did not name the author: %v", err)
1582 }
1583 if err := other.apply(map[string]string{commentPath(cid): commentFile("wilbur", created, "mine now\n")}, "forge"); err == nil {
1584 t.Fatal("a writer adopted a review comment by rewriting its author field")
1585 }
1586 if err := other.remove(commentPath(cid)); err == nil {
1587 t.Fatal("a writer deleted somebody else's review comment")
1588 } else if !strings.Contains(err.Error(), "only keenan can delete this comment") {
1589 t.Fatalf("refusal did not name the author: %v", err)
1590 }
1591
1592 // Joining the discussion is free, and his own words are his to edit.
1593 wilburCm := "01M5JR2JK29KKMAA1T9MDNTRW1"
1594 if err := other.apply(map[string]string{commentPath(wilburCm): commentFile("wilbur", created, "one question\n")}, "join"); err != nil {
1595 t.Fatalf("adding your own comment was refused: %v", err)
1596 }
1597 if err := other.apply(map[string]string{commentPath(wilburCm): commentFile("wilbur", created, "one question, rephrased\n")}, "edit own"); err != nil {
1598 t.Fatalf("editing your own comment was refused: %v", err)
1599 }
1600 _, body = f.get("/" + f.repo + "/changes/" + reqID)
1601 if !strings.Contains(body, "one question, rephrased") || strings.Contains(body, "one question</") {
1602 t.Fatalf("the edited comment was not replaced:\n%s", body)
1603 }
1604 if !strings.Contains(body, "Looks good to me.") {
1605 t.Fatalf("the thread lost the first comment:\n%s", body)
1606 }
1607 // An empty comment is refused by the CLI before anything is pushed.
1608 if _, _, err := f.cli(clone, "change", "comment", reqID, "-m", " "); err == nil {
1609 t.Fatal("an empty comment was accepted")
1610 }
1611
1612 // Deleting the parent leaves a tombstone so the reply keeps its thread.
1613 if _, errs, err := f.cli(clone, "change", "rm-comment", reqID, cid); err != nil {
1614 t.Fatalf("delete parent comment: %v\n%s", err, errs)
1615 }
1616 _, body = f.get("/" + f.repo + "/changes/" + reqID)
1617 if !strings.Contains(body, "Comment deleted") || !strings.Contains(body, "A threaded reply.") {
1618 t.Fatalf("the deleted request parent left no tombstone:\n%s", body)
1619 }
1620}
1621
1622// TestReviewCommentAnchorsALine: a review comment can name the line it is about,
1623// on either side of the diff, and it is glued to the *content* it saw — the blob
1624// id — so it stays current while that file is untouched and is marked stale the
1625// moment the file changes, instead of sliding to a line that now says something
1626// else.
1627func TestReviewCommentAnchorsALine(t *testing.T) {
1628 t.Parallel()
1629 f := newFixture(t)
1630 f.publish(f.repo)
1631
1632 base := workRepo(t, map[string]string{"code.txt": "a\nb\nc\n"})
1633 if _, errs, err := f.git(base, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
1634 t.Fatalf("main push: %s", errs)
1635 }
1636 feature, head := f.cloneWithChange(t, map[string]string{"code.txt": "a\nFEATURE\nc\n"}, "feature")
1637 if _, errs, err := f.git(feature, "push", f.sshURL(f.repo), "HEAD:refs/heads/feature"); err != nil {
1638 t.Fatalf("feature push: %s", errs)
1639 }
1640 meta := f.newMetaRepo(t)
1641 if err := meta.apply(map[string]string{
1642 "changes/" + reqID + "/change.md": requestFile("main", head, "keenan", "rationale\n"),
1643 }, "request"); err != nil {
1644 t.Fatalf("request push: %v", err)
1645 }
1646 clone := filepath.Join(t.TempDir(), "clone")
1647 if _, errs, err := f.git("", "clone", "-q", f.sshURL(f.repo), clone); err != nil {
1648 t.Fatalf("clone: %s", errs)
1649 }
1650
1651 // New side: the line in the head's copy of the file.
1652 out, errs, err := f.cli(clone, "change", "comment", reqID, "-m", "why FEATURE?",
1653 "--path", "code.txt", "--line", "2")
1654 if err != nil {
1655 t.Fatalf("anchored comment: %v\n%s", err, errs)
1656 }
1657 newCm := strings.TrimSpace(out)
1658 serverFile := func(path string) string {
1659 text, _, _ := runGit(t, filepath.Join(f.cfg.ReposDir(), f.repo+".git"), "show", "refs/heads/vole:"+path)
1660 return text
1661 }
1662 wantBlob, _, _ := runGit(t, filepath.Join(f.cfg.ReposDir(), f.repo+".git"), "rev-parse", head+":code.txt")
1663 wantBlob = strings.TrimSpace(wantBlob)
1664 got := serverFile("changes/" + reqID + "/comments/" + newCm + ".md")
1665 for _, want := range []string{"path: code.txt", "side: new", "line: 2", "blob: " + wantBlob} {
1666 if !strings.Contains(got, want) {
1667 t.Fatalf("the anchor is missing %q:\n%s", want, got)
1668 }
1669 }
1670 // The Files tab puts the comment beneath that line; the conversation still
1671 // labels the card with where it points.
1672 _, body := f.get("/" + f.repo + "/changes/" + reqID + "/files/code.txt")
1673 if !strings.Contains(body, `id="inline-c-`+newCm+`"`) || !strings.Contains(body, "#c-"+newCm) ||
1674 !strings.Contains(body, "why FEATURE?") {
1675 t.Fatalf("the diff has no inline note for the comment:\n%s", body)
1676 }
1677 _, body = f.get("/" + f.repo + "/changes/" + reqID)
1678 if !strings.Contains(body, "code.txt:2") || !strings.Contains(body, "(new side)") ||
1679 !strings.Contains(body, `class="comment-snippet"`) || !strings.Contains(body, `class="snippet-line anchor"`) ||
1680 !strings.Contains(body, "View in Files") {
1681 t.Fatalf("the comment card does not show its code:\n%s", body)
1682 }
1683
1684 // Old side: a line of the file as it stood at the merge base.
1685 out, errs, err = f.cli(clone, "change", "comment", reqID, "-m", "the old middle line",
1686 "--path", "code.txt", "--line", "2", "--side", "old")
1687 if err != nil {
1688 t.Fatalf("anchored comment (old side): %v\n%s", err, errs)
1689 }
1690 oldCm := strings.TrimSpace(out)
1691 mergeBase, _, _ := runGit(t, filepath.Join(f.cfg.ReposDir(), f.repo+".git"), "merge-base", "refs/heads/main", head)
1692 oldBlob, _, _ := runGit(t, filepath.Join(f.cfg.ReposDir(), f.repo+".git"),
1693 "rev-parse", strings.TrimSpace(mergeBase)+":code.txt")
1694 if got := serverFile("changes/" + reqID + "/comments/" + oldCm + ".md"); !strings.Contains(got, "blob: "+strings.TrimSpace(oldBlob)) {
1695 t.Fatalf("the old side anchor does not name the merge-base blob:\n%s", got)
1696 }
1697
1698 // A line that is not in the file is refused before anything is pushed.
1699 before := f.serverIssues(t)
1700 if _, errs, err := f.cli(clone, "change", "comment", reqID, "-m", "past the end",
1701 "--path", "code.txt", "--line", "99"); err == nil {
1702 t.Fatal("a line past the end of the file was accepted")
1703 } else if !strings.Contains(errs, "past the end") {
1704 t.Fatalf("unhelpful refusal: %s", errs)
1705 }
1706 if f.serverIssues(t) != before {
1707 t.Fatal("a refused anchor moved the branch")
1708 }
1709
1710 // A new head changes that file, so the new-side comment is stale while the
1711 // old-side one still points at content that has not moved.
1712 next, nextHead := func() (string, string) {
1713 if err := os.WriteFile(filepath.Join(feature, "code.txt"), []byte("a\nFEATURE v2\nc\n"), 0o644); err != nil {
1714 t.Fatal(err)
1715 }
1716 if _, errs, err := runGit(t, feature, "commit", "-qam", "v2"); err != nil {
1717 t.Fatalf("commit: %s", errs)
1718 }
1719 if _, errs, err := f.git(feature, "push", f.sshURL(f.repo), "HEAD:refs/heads/feature"); err != nil {
1720 t.Fatalf("push: %s", errs)
1721 }
1722 out, _, _ := runGit(t, feature, "rev-parse", "HEAD")
1723 return feature, strings.TrimSpace(out)
1724 }()
1725 // The new commit reached the clone by pushing it from elsewhere, so fetch before
1726 // naming it.
1727 if _, errs, err := f.git(clone, "fetch", "-q", "origin", "feature"); err != nil {
1728 t.Fatalf("fetch: %s", errs)
1729 }
1730 if _, errs, err := f.cli(clone, "change", "update", reqID, "--head", nextHead); err != nil {
1731 t.Fatalf("request update: %v\n%s", err, errs)
1732 }
1733 _, body = f.get("/" + f.repo + "/changes/" + reqID)
1734 if !strings.Contains(body, "the file has changed since this comment") ||
1735 !strings.Contains(body, `class="snippet-line anchor"`) || !strings.Contains(body, "original line") {
1736 t.Fatalf("a moved file did not keep the original snippet for the stale comment:\n%s", body)
1737 }
1738 _, body = f.get("/" + f.repo + "/changes/" + reqID + "/files/code.txt")
1739 if strings.Contains(body, `id="inline-c-`+newCm+`"`) {
1740 t.Fatalf("the stale comment kept its inline note:\n%s", body)
1741 }
1742 _ = next
1743}
1744
1745func (f *fixture) serverIssues(t *testing.T) string {
1746 t.Helper()
1747 out, _, _ := runGit(t, filepath.Join(f.cfg.ReposDir(), f.repo+".git"),
1748 "rev-parse", "--verify", "--quiet", "refs/heads/vole")
1749 return strings.TrimSpace(out)
1750}
1751
1752// TestIssueFiltersAndSearch covers the SQL paths behind the list page.
1753func TestIssueFiltersAndSearch(t *testing.T) {
1754 t.Parallel()
1755 f := newFixture(t)
1756 f.publish(f.repo)
1757 files := map[string]string{}
1758 add := func(id, title, state, body string, labels []string) {
1759 files["issues/"+id+"/issue.md"] = issueFile(title, "keenan", state, "2026-10-22T21:00:00Z", labels, body)
1760 }
1761 add("01M5JR2JK29KKMAA1T9MDNTPB1", "Segfault on startup", "open", "crash in parser", []string{"bug"})
1762 add("01M5JR2JK29KKMAA1T9MDNTPB2", "Docs typo", "closed", "spelling", []string{"docs"})
1763 add("01M5JR2JK29KKMAA1T9MDNTPB3", "Slow parser", "open", "throughput", []string{"bug", "perf"})
1764
1765 work := f.issuesRepo(t, files)
1766 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "HEAD:refs/heads/vole"); err != nil {
1767 t.Fatalf("push: %s", errs)
1768 }
1769
1770 for _, c := range []struct{ query, want string }{
1771 {"?state=all", "Docs typo"},
1772 {"?label=bug", "Segfault on startup"},
1773 {"?state=closed", "Docs typo"},
1774 {"?q=parser", "Slow parser"},
1775 } {
1776 code, body := f.get("/" + f.repo + "/issues" + c.query)
1777 if code != 200 {
1778 t.Fatalf("%q: status %d", c.query, code)
1779 }
1780 if !strings.Contains(body, c.want) {
1781 t.Fatalf("%q: missing %q", c.query, c.want)
1782 }
1783 }
1784 // A label filter must exclude issues without that label.
1785 if _, body := f.get("/" + f.repo + "/issues?label=docs"); strings.Contains(body, "Segfault") {
1786 t.Fatal("label filter did not exclude other issues")
1787 }
1788 // Negation case: searching a word only in the body.
1789 if _, body := f.get("/" + f.repo + "/issues?q=throughput"); !strings.Contains(body, "Slow parser") {
1790 t.Fatal("body search did not match")
1791 }
1792}
1793
1794// TestIssueAssigneesRenderedFilteredAndValidated: an assignee is a name you can
1795// click, a filter that narrows the list, and — because it names a person — a
1796// value the push boundary refuses when it could never be an account.
1797func TestIssueAssigneesRenderedFilteredAndValidated(t *testing.T) {
1798 t.Parallel()
1799 f := newFixture(t)
1800 f.publish(f.repo)
1801 const (
1802 assigned = "01M5JR2JK29KKMAA1T9MDNTPC1"
1803 unassigned = "01M5JR2JK29KKMAA1T9MDNTPC2"
1804 )
1805 files := map[string]string{
1806 "issues/" + assigned + "/issue.md": issueFile("assigned bug", "keenan", "open",
1807 "2026-10-22T21:00:00Z", []string{"bug"}, "body\n", "wilbur"),
1808 "issues/" + unassigned + "/issue.md": issueFile("unassigned bug", "keenan", "open",
1809 "2026-10-22T21:00:00Z", []string{"bug"}, "body\n"),
1810 }
1811 work := f.issuesRepo(t, files)
1812 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "HEAD:refs/heads/vole"); err != nil {
1813 t.Fatalf("push: %s", errs)
1814 }
1815
1816 // The detail page shows the assignee as a link that filters.
1817 code, body := f.get("/" + f.repo + "/issues/" + assigned)
1818 if code != 200 {
1819 t.Fatalf("issue page: %d", code)
1820 }
1821 if !strings.Contains(body, "@wilbur") || !strings.Contains(body, "issues?assignee=wilbur") {
1822 t.Fatalf("assignee is not rendered as a filter link:\n%s", body)
1823 }
1824 // The list page offers the assignee as a filter and shows it on the row.
1825 if _, body := f.get("/" + f.repo + "/issues"); !strings.Contains(body, "Assignee") {
1826 t.Fatalf("list has no assignee filters:\n%s", body)
1827 }
1828 // And the filter excludes issues with a different assignee.
1829 _, body = f.get("/" + f.repo + "/issues?assignee=wilbur")
1830 if !strings.Contains(body, "assigned bug") {
1831 t.Fatalf("assignee filter dropped the assigned issue:\n%s", body)
1832 }
1833 if strings.Contains(body, "unassigned bug") {
1834 t.Fatal("assignee filter did not exclude other issues")
1835 }
1836
1837 // A value that could never be an account name is refused at the boundary,
1838 // and the refusal names the path and the value.
1839 before := f.serverIssues(t)
1840 badPath := filepath.Join(work, "issues", "01M5JR2JK29KKMAA1T9MDNTPC3")
1841 if err := os.MkdirAll(badPath, 0o755); err != nil {
1842 t.Fatal(err)
1843 }
1844 bad := issueFile("typo", "keenan", "open", "2026-10-22T21:00:00Z", nil, "body\n", "Wilbur")
1845 if err := os.WriteFile(filepath.Join(badPath, "issue.md"), []byte(bad), 0o644); err != nil {
1846 t.Fatal(err)
1847 }
1848 if _, errs, err := runGit(t, work, "add", "-A"); err != nil {
1849 t.Fatalf("add: %s", errs)
1850 }
1851 if _, errs, err := runGit(t, work, "commit", "-qm", "typo"); err != nil {
1852 t.Fatalf("commit: %s", errs)
1853 }
1854 _, errs, err := f.git(work, "push", f.sshURL(f.repo), "HEAD:refs/heads/vole")
1855 if err == nil {
1856 t.Fatalf("push with an impossible assignee was accepted")
1857 }
1858 if !strings.Contains(errs, "invalid assignee") || !strings.Contains(errs, "01M5JR2JK29KKMAA1T9MDNTPC3") {
1859 t.Fatalf("refusal did not name the path and the value:\n%s", errs)
1860 }
1861 if after := f.serverIssues(t); after != before {
1862 t.Fatalf("issues branch moved on a rejected push: %s -> %s", before, after)
1863 }
1864}
1865
1866// commentFile renders issues/<id>/comments/<cid>.md for fixtures.
1867func commentFile(author, created, body string) string {
1868 d := metadata.NewDoc()
1869 d.Word("author", author)
1870 d.Word("created", created)
1871 out, err := d.Bytes(body)
1872 if err != nil {
1873 panic(err)
1874 }
1875 return string(out)
1876}
1877
1878// TestCommentEditsBelongToTheirAuthor is the boundary behind editable comments.
1879// A comment is somebody's own words, so changing or deleting it is checked
1880// against the author recorded on the *old* blob — the one that was validated when
1881// it landed — and an admin may override only audibly.
1882//
1883// What is deliberately NOT covered: adding a comment, and any edit to the issue
1884// file. Closing and relabelling somebody else's issue is triage, not authorship,
1885// and refusing it would make the shared fields useless.
1886func TestCommentEditsBelongToTheirAuthor(t *testing.T) {
1887 t.Parallel()
1888 f := newFixture(t)
1889 writerKey := f.addWriter(t) // wilbur: maintain access, not admin
1890 f.publish(f.repo)
1891 const (
1892 issueID = "01M5JR2JK29KKMAA1T9MDNTPD1"
1893 keenanCm = "01M5JR2JK29KKMAA1T9MDNTPD2"
1894 wilburCm = "01M5JR2JK29KKMAA1T9MDNTPD3"
1895 )
1896 const created = "2026-10-22T21:01:00Z"
1897 files := map[string]string{
1898 "issues/" + issueID + "/issue.md": issueFile("shared bug", "keenan", "open",
1899 "2026-10-22T21:00:00Z", nil, "body\n"),
1900 "issues/" + issueID + "/comments/" + keenanCm + ".md": commentFile("keenan", created, "original words\n"),
1901 }
1902 work := f.issuesRepo(t, files)
1903 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "HEAD:refs/heads/vole"); err != nil {
1904 t.Fatalf("push: %s", errs)
1905 }
1906 commentPath := func(id string) string { return "issues/" + issueID + "/comments/" + id + ".md" }
1907
1908 meta := f.newMetaRepo(t)
1909 meta.key = writerKey
1910
1911 // A writer may not rewrite someone else's words...
1912 if err := meta.apply(map[string]string{commentPath(keenanCm): commentFile("keenan", created, "rewritten by wilbur\n")}, "edit foreign"); err == nil {
1913 t.Fatal("a writer changed someone else's comment")
1914 } else if !strings.Contains(err.Error(), "only keenan can change this comment") {
1915 t.Fatalf("refusal did not name the author: %v", err)
1916 }
1917 // ...nor adopt them by editing the author line in the same push: the old
1918 // blob decides, not the new one.
1919 if err := meta.apply(map[string]string{commentPath(keenanCm): commentFile("wilbur", created, "mine now\n")}, "forge"); err == nil {
1920 t.Fatal("a writer took ownership of a comment by rewriting its author field")
1921 }
1922 // ...nor delete them.
1923 if err := meta.remove(commentPath(keenanCm)); err == nil {
1924 t.Fatal("a writer deleted someone else's comment")
1925 } else if !strings.Contains(err.Error(), "only keenan can delete this comment") {
1926 t.Fatalf("refusal did not name the author: %v", err)
1927 }
1928 // None of those refusals touched the thread.
1929 if _, body := f.get("/" + f.repo + "/issues/" + issueID); !strings.Contains(body, "original words") {
1930 t.Fatalf("a refused edit changed the rendered thread:\n%s", body)
1931 }
1932
1933 // His own comment he may add, edit and delete freely.
1934 if err := meta.apply(map[string]string{commentPath(wilburCm): commentFile("wilbur", created, "first draft\n")}, "add own"); err != nil {
1935 t.Fatalf("add own comment: %v", err)
1936 }
1937 if err := meta.apply(map[string]string{commentPath(wilburCm): commentFile("wilbur", created, "second draft\n")}, "edit own"); err != nil {
1938 t.Fatalf("edit own comment: %v", err)
1939 }
1940 _, body := f.get("/" + f.repo + "/issues/" + issueID)
1941 if !strings.Contains(body, "second draft") || strings.Contains(body, "first draft") {
1942 t.Fatalf("own edit did not replace the body:\n%s", body)
1943 }
1944
1945 // Triage is not authorship: closing another person's issue is a normal write.
1946 if err := meta.apply(map[string]string{"issues/" + issueID + "/issue.md": issueFile("shared bug", "keenan", "closed",
1947 "2026-10-22T21:00:00Z", nil, "body\n")}, "close"); err != nil {
1948 t.Fatalf("closing another person's issue was refused: %v", err)
1949 }
1950
1951 // An admin may remove the comment of someone who has left, and it is never
1952 // silent.
1953 admin := f.newMetaRepo(t)
1954 if err := admin.remove(commentPath(wilburCm)); err != nil {
1955 t.Fatalf("admin delete: %v", err)
1956 }
1957 if _, body := f.get("/" + f.repo + "/issues/" + issueID); strings.Contains(body, "second draft") {
1958 t.Fatal("a deleted comment is still rendered")
1959 }
1960 audit, err := os.ReadFile(f.cfg.AuditLog())
1961 if err != nil {
1962 t.Fatal(err)
1963 }
1964 if !strings.Contains(string(audit), "\tcomment.delete\tkeenan\tdemo\t"+commentPath(wilburCm)) {
1965 t.Fatalf("the admin override was not audited:\n%s", audit)
1966 }
1967}
1968
1969// TestCrossReferencesLinksIssues: a #<ulid> in a body becomes a link, and a
1970// commit mentioning an issue shows as a backlink.
1971func TestCrossReferencesLinksIssues(t *testing.T) {
1972 t.Parallel()
1973 f := newFixture(t)
1974 f.publish(f.repo)
1975 const target = "01M5JR2JK29KKMAA1T9MDNTPC9"
1976 const other = "01M5JR2JK29KKMAA1T9MDNTPD1"
1977
1978 files := issueFiles(target, "Referenced issue", "plain\n")
1979 for k, v := range issueFiles(other, "Referring issue",
1980 "See #"+target+" for context.\n\n```\n#"+target+"\n```\n") {
1981 files[k] = v
1982 }
1983 work := f.issuesRepo(t, files)
1984 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "HEAD:refs/heads/vole"); err != nil {
1985 t.Fatalf("push issues: %s", errs)
1986 }
1987
1988 _, body := f.get("/" + f.repo + "/issues/" + other)
1989 if !strings.Contains(body, `href="/demo/issues/`+target+`"`) {
1990 t.Fatalf("cross-reference not linked:\n%s", body)
1991 }
1992 // The reference inside a fenced block must stay literal, not become a link.
1993 fence := body[strings.Index(body, "<pre>"):]
1994 if strings.Contains(fence[:min(len(fence), 400)], `href="/demo/issues/`+target) {
1995 t.Fatal("cross-reference inside a code fence was linked")
1996 }
1997
1998 // Backlink: a commit message mentioning the issue id. git --grep searches
1999 // messages, not file contents.
2000 readme := workRepo(t, map[string]string{"readme.md": "change\n"})
2001 if _, errs, err := f.git(readme, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
2002 t.Fatalf("push main: %s", errs)
2003 }
2004 if err := os.WriteFile(filepath.Join(readme, "b.txt"), []byte("x\n"), 0o644); err != nil {
2005 t.Fatal(err)
2006 }
2007 if _, errs, err := runGit(t, readme, "add", "-A"); err != nil {
2008 t.Fatalf("add: %v: %s", err, errs)
2009 }
2010 if _, errs, err := runGit(t, readme, "commit", "-qm", "fixes #"+target); err != nil {
2011 t.Fatalf("commit: %v: %s", err, errs)
2012 }
2013 if _, errs, err := f.git(readme, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
2014 t.Fatalf("push main: %s", errs)
2015 }
2016 f.publish(f.repo)
2017 if _, body := f.get("/" + f.repo + "/issues/" + target); !strings.Contains(body, "Referenced in commits") {
2018 t.Fatalf("backlink section missing:\n%s", body)
2019 }
2020}
2021
2022func min(a, b int) int {
2023 if a < b {
2024 return a
2025 }
2026 return b
2027}
2028
2029// TestMetadataRejectsMergeCommits: metadata history is linear by policy, so a
2030// merge node is refused rather than quietly accepted.
2031func TestMetadataRejectsMergeCommits(t *testing.T) {
2032 t.Parallel()
2033 f := newFixture(t)
2034 f.publish(f.repo)
2035
2036 work := f.issuesRepo(t, issueFiles(testIssueID, "first", "body\n"))
2037 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "HEAD:refs/heads/vole"); err != nil {
2038 t.Fatalf("initial push: %s", errs)
2039 }
2040
2041 // Two diverging commits, then a merge.
2042 git := func(args ...string) {
2043 t.Helper()
2044 if _, errs, err := runGit(t, work, args...); err != nil {
2045 t.Fatalf("%v: %v: %s", args, err, errs)
2046 }
2047 }
2048 git("checkout", "-q", "-b", "side")
2049 writeIssueFile(t, work, "01M5JR2JK29KKMAA1T9MDNTPE1", "side")
2050 git("add", "-A")
2051 git("commit", "-qm", "side issue")
2052 git("checkout", "-q", "scratch")
2053 writeIssueFile(t, work, "01M5JR2JK29KKMAA1T9MDNTPE2", "main")
2054 git("add", "-A")
2055 git("commit", "-qm", "main issue")
2056 git("merge", "-q", "--no-ff", "-m", "merge side", "side")
2057
2058 _, errs, err := f.git(work, "push", f.sshURL(f.repo), "HEAD:refs/heads/vole")
2059 if err == nil {
2060 t.Fatal("a merge commit was accepted on the metadata branch")
2061 }
2062 if !strings.Contains(errs, "linear history") {
2063 t.Fatalf("rejection did not explain the rule:\n%s", errs)
2064 }
2065}
2066
2067// TestMetadataForcePushIsAdminOnly: rewriting discussion content is a redaction
2068// tool, not a normal operation.
2069func TestMetadataForcePushIsAdminOnly(t *testing.T) {
2070 t.Parallel()
2071 f := newFixture(t)
2072 f.publish(f.repo)
2073 writerKey := f.addWriter(t)
2074
2075 work := f.issuesRepo(t, issueFiles(testIssueID, "first", "body\n"))
2076 if _, errs, err := f.gitWithKey(writerKey, work, "push", f.sshURL(f.repo), "HEAD:refs/heads/vole"); err != nil {
2077 t.Fatalf("writer initial push: %s", errs)
2078 }
2079
2080 // Amend in place: a new object for the same parent, which is a rewrite. Adding
2081 // and resetting would leave the tip unchanged and the push a no-op that never
2082 // reaches the hook.
2083 if _, errs, err := runGit(t, work, "commit", "-q", "--amend", "-m", "rewritten"); err != nil {
2084 t.Fatalf("amend: %v: %s", err, errs)
2085 }
2086
2087 _, errs, err := f.gitWithKey(writerKey, work, "push", "--force", f.sshURL(f.repo), "HEAD:refs/heads/vole")
2088 if err == nil {
2089 t.Fatal("a non-admin force-pushed the metadata branch")
2090 }
2091 if !strings.Contains(errs, "requires admin") {
2092 t.Fatalf("rejection did not name the rule:\n%s", errs)
2093 }
2094
2095 // The admin may, and it is recorded.
2096 if _, errs, err := f.git(work, "push", "--force", f.sshURL(f.repo), "HEAD:refs/heads/vole"); err != nil {
2097 t.Fatalf("admin force-push refused: %s", errs)
2098 }
2099 audit, err := os.ReadFile(f.cfg.AuditLog())
2100 if err != nil {
2101 t.Fatal(err)
2102 }
2103 if !strings.Contains(string(audit), "\tpush.force\tkeenan\tdemo\trefs/heads/vole") {
2104 t.Fatalf("force-push not audited:\n%s", audit)
2105 }
2106}
2107
2108// TestMetadataRejectsUnknownTopLevel: the layout is closed, so a typo is caught
2109// rather than silently ignored by the web.
2110func TestMetadataRejectsUnknownTopLevel(t *testing.T) {
2111 t.Parallel()
2112 f := newFixture(t)
2113 f.publish(f.repo)
2114 work := t.TempDir()
2115 if _, errs, err := runGit(t, work, "init", "-q", "-b", "vole"); err != nil {
2116 t.Fatalf("init: %v: %s", err, errs)
2117 }
2118 if err := os.MkdirAll(filepath.Join(work, "notes"), 0o755); err != nil {
2119 t.Fatal(err)
2120 }
2121 if err := os.WriteFile(filepath.Join(work, "notes", "x.md"), []byte("hi\n"), 0o644); err != nil {
2122 t.Fatal(err)
2123 }
2124 if _, errs, err := runGit(t, work, "add", "-A"); err != nil {
2125 t.Fatalf("add: %v: %s", err, errs)
2126 }
2127 if _, errs, err := runGit(t, work, "commit", "-qm", "notes"); err != nil {
2128 t.Fatalf("commit: %v: %s", err, errs)
2129 }
2130 _, errs, err := f.git(work, "push", f.sshURL(f.repo), "HEAD:refs/heads/vole")
2131 if err == nil {
2132 t.Fatal("an unknown top-level directory was accepted")
2133 }
2134 if !strings.Contains(errs, "may only contain") {
2135 t.Fatalf("rejection did not explain the layout:\n%s", errs)
2136 }
2137}
2138
2139// TestConcurrentCLIWritesAllLand: two people filing issues at once must both
2140// succeed. The CLI re-applies on a lost race instead of failing.
2141func TestConcurrentCLIWritesAllLand(t *testing.T) {
2142 t.Parallel()
2143 f := newFixture(t)
2144 f.publish(f.repo)
2145 if _, errs, err := f.git(workRepo(t, map[string]string{"readme.md": "hi\n"}),
2146 "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
2147 t.Fatalf("push main: %s", errs)
2148 }
2149
2150 const n = 6
2151 clones := make([]string, n)
2152 for i := range clones {
2153 dir := filepath.Join(t.TempDir(), fmt.Sprintf("clone%d", i))
2154 if _, errs, err := f.git("", "clone", "-q", f.sshURL(f.repo), dir); err != nil {
2155 t.Fatalf("clone: %s", errs)
2156 }
2157 clones[i] = dir
2158 }
2159
2160 var wg sync.WaitGroup
2161 errs := make([]string, n)
2162 for i := 0; i < n; i++ {
2163 wg.Add(1)
2164 go func(i int) {
2165 defer wg.Done()
2166 cmd := exec.Command(binPath, "issue", "new", "--title", fmt.Sprintf("concurrent %d", i))
2167 cmd.Dir = clones[i]
2168 cmd.Env = append(os.Environ(),
2169 "GIT_SSH_COMMAND=ssh -i "+f.keyPath+" -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR",
2170 "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null",
2171 "USER=keenan", "GIT_AUTHOR_NAME=keenan", "GIT_AUTHOR_EMAIL=k@e")
2172 var out bytes.Buffer
2173 cmd.Stdout, cmd.Stderr = &out, &out
2174 if err := cmd.Run(); err != nil {
2175 errs[i] = out.String()
2176 }
2177 }(i)
2178 }
2179 wg.Wait()
2180 for i, e := range errs {
2181 if e != "" {
2182 t.Fatalf("writer %d failed:\n%s", i, e)
2183 }
2184 }
2185
2186 if err := f.srv.Index.EnsureIssues(mustRepo(t, f)); err != nil {
2187 t.Fatal(err)
2188 }
2189 rows, err := f.srv.Index.Issues(repoID(t, f), index.Filter{})
2190 if err != nil {
2191 t.Fatal(err)
2192 }
2193 if len(rows) != n {
2194 t.Fatalf("%d issues landed, want %d (a lost race dropped a write)", len(rows), n)
2195 }
2196}
2197
2198func writeIssueFile(t *testing.T, dir, id, title string) {
2199 t.Helper()
2200 p := filepath.Join(dir, "issues", id)
2201 if err := os.MkdirAll(p, 0o755); err != nil {
2202 t.Fatal(err)
2203 }
2204 raw := issueFile(title, "keenan", "open", "2026-10-22T21:00:00Z", nil, "body\n")
2205 if err := os.WriteFile(filepath.Join(p, "issue.md"), []byte(raw), 0o644); err != nil {
2206 t.Fatal(err)
2207 }
2208}
2209
2210// metaRepo is a worktree for editing the metadata branch, always committing on
2211// top of what is on the server.
2212type metaRepo struct {
2213 t *testing.T
2214 f *fixture
2215 dir string
2216 key string
2217}
2218
2219func (f *fixture) newMetaRepo(t *testing.T) *metaRepo {
2220 t.Helper()
2221 dir := filepath.Join(t.TempDir(), "meta")
2222 if err := os.MkdirAll(dir, 0o755); err != nil {
2223 t.Fatal(err)
2224 }
2225 // Work on a scratch branch, never on vole itself: git refuses to fetch into
2226 // the checked-out branch, which is exactly how a real user works too.
2227 if _, errs, err := runGit(t, dir, "init", "-q", "-b", "scratch"); err != nil {
2228 t.Fatalf("init: %v: %s", err, errs)
2229 }
2230 if _, errs, err := runGit(t, dir, "remote", "add", "origin", f.sshURL(f.repo)); err != nil {
2231 t.Fatalf("remote: %v: %s", err, errs)
2232 }
2233 return &metaRepo{t: t, f: f, dir: dir, key: f.keyPath}
2234}
2235
2236// sync brings the worktree to the server's metadata tip.
2237func (m *metaRepo) sync() {
2238 m.t.Helper()
2239 if _, _, err := fgit(m.t, m.key, m.dir, "fetch", "-q", "origin", "+refs/heads/vole:refs/heads/meta-base"); err == nil {
2240 if _, errs, rerr := runGit(m.t, m.dir, "reset", "-q", "--hard", "refs/heads/meta-base"); rerr != nil {
2241 m.t.Fatalf("reset: %v: %s", rerr, errs)
2242 }
2243 }
2244}
2245
2246func (m *metaRepo) apply(files map[string]string, msg string) error {
2247 m.t.Helper()
2248 m.sync()
2249 for name, content := range files {
2250 p := filepath.Join(m.dir, name)
2251 if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
2252 m.t.Fatal(err)
2253 }
2254 if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
2255 m.t.Fatal(err)
2256 }
2257 }
2258 if _, errs, err := runGit(m.t, m.dir, "add", "-A"); err != nil {
2259 m.t.Fatalf("add: %v: %s", err, errs)
2260 }
2261 if _, errs, err := runGit(m.t, m.dir, "commit", "-qm", msg); err != nil {
2262 m.t.Fatalf("commit: %v: %s", err, errs)
2263 }
2264 _, errs, err := fgit(m.t, m.key, m.dir, "push", "origin", "HEAD:refs/heads/vole")
2265 if err != nil {
2266 return fmt.Errorf("%v: %s", err, errs)
2267 }
2268 return nil
2269}
2270
2271// remove deletes paths from the metadata branch and pushes. apply only writes,
2272// and a deletion is the interesting case for authorship.
2273func (m *metaRepo) remove(paths ...string) error {
2274 m.t.Helper()
2275 m.sync()
2276 for _, p := range paths {
2277 if err := os.Remove(filepath.Join(m.dir, p)); err != nil {
2278 m.t.Fatalf("remove %s: %v", p, err)
2279 }
2280 }
2281 if _, errs, err := runGit(m.t, m.dir, "add", "-A"); err != nil {
2282 m.t.Fatalf("add: %v: %s", err, errs)
2283 }
2284 if _, errs, err := runGit(m.t, m.dir, "commit", "-qm", "remove"); err != nil {
2285 m.t.Fatalf("commit: %v: %s", err, errs)
2286 }
2287 _, errs, err := fgit(m.t, m.key, m.dir, "push", "origin", "HEAD:refs/heads/vole")
2288 if err != nil {
2289 return fmt.Errorf("%v: %s", err, errs)
2290 }
2291 return nil
2292}
2293
2294// fgit runs git as a specific key holding user, for tests that need identities.
2295func fgit(t *testing.T, key, dir string, args ...string) (string, string, error) {
2296 t.Helper()
2297 cmd := exec.Command("git", args...)
2298 cmd.Dir = dir
2299 cmd.Env = append(os.Environ(),
2300 "GIT_TERMINAL_PROMPT=0", "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null",
2301 "GIT_SSH_COMMAND=ssh -i "+key+" -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR",
2302 "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.com",
2303 "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.com",
2304 )
2305 var out, errb bytes.Buffer
2306 cmd.Stdout, cmd.Stderr = &out, &errb
2307 err := cmd.Run()
2308 return out.String(), errb.String(), err
2309}
2310
2311// cloneWithChange clones the repo and commits one change, returning the worktree
2312// and its head. Building features as clones keeps them descendants of main, which
2313// keeps the push a fast-forward so the hook, not the client, decides.
2314func (f *fixture) cloneWithChange(t *testing.T, files map[string]string, msg string) (string, string) {
2315 t.Helper()
2316 dir := filepath.Join(t.TempDir(), "work")
2317 if _, errs, err := f.git("", "clone", "-q", f.sshURL(f.repo), dir); err != nil {
2318 t.Fatalf("clone: %s", errs)
2319 }
2320 for name, content := range files {
2321 p := filepath.Join(dir, name)
2322 if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
2323 t.Fatal(err)
2324 }
2325 if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
2326 t.Fatal(err)
2327 }
2328 }
2329 if _, errs, err := runGit(t, dir, "add", "-A"); err != nil {
2330 t.Fatalf("add: %v: %s", err, errs)
2331 }
2332 if _, errs, err := runGit(t, dir, "commit", "-qm", msg); err != nil {
2333 t.Fatalf("commit: %v: %s", err, errs)
2334 }
2335 out, _, _ := runGit(t, dir, "rev-parse", "HEAD")
2336 return dir, strings.TrimSpace(out)
2337}
2338
2339const reqID = "01M5JR2JK29KKMAA1T9MDNTR01"
2340
2341// requestFile renders changes/<id>/change.md. The body is the request's
2342// description, so a fixture cannot drift from the format the reader expects.
2343func requestFile(base, head, author, body string) string {
2344 return requestFileTitled("Land the feature", base, head, author, body)
2345}
2346
2347// requestFileTitled is requestFile with a title, for a list that has to tell its
2348// rows apart.
2349func requestFileTitled(title, base, head, author, body string) string {
2350 return requestFileState(title, base, head, author, "open", body)
2351}
2352
2353// requestFileState is requestFileTitled with an explicit state, for a fixture
2354// that has to tell an open record from a closed one without landing anything.
2355func requestFileState(title, base, head, author, state, body string) string {
2356 d := metadata.NewDoc()
2357 d.Text("title", title)
2358 d.Word("state", state)
2359 d.Word("author", author)
2360 d.Word("base", base)
2361 d.Word("head", head)
2362 d.Word("created", "2026-10-22T22:00:00Z")
2363 out, err := d.Bytes(body)
2364 if err != nil {
2365 panic(err)
2366 }
2367 return string(out)
2368}
2369
2370// requestMeta is requestFile with no description: the common fixture shape.
2371func requestMeta(base, head, author string) string {
2372 return requestFile(base, head, author, "")
2373}
2374
2375// requestFileFollowing renders an open request that follows a branch, for a
2376// fixture that has to tell a followed head from a pinned one.
2377func requestFileFollowing(title, base, head, headRef, author, body string) string {
2378 d := metadata.NewDoc()
2379 d.Text("title", title)
2380 d.Word("state", "open")
2381 d.Word("author", author)
2382 d.Word("base", base)
2383 d.Word("head", head)
2384 d.Word("head_ref", headRef)
2385 d.Word("created", "2026-10-22T22:00:00Z")
2386 out, err := d.Bytes(body)
2387 if err != nil {
2388 panic(err)
2389 }
2390 return string(out)
2391}
2392
2393// requestFileMerged renders a request that already landed, for a fixture that
2394// needs a merged change without running the landing flow. The merged head is the
2395// same commit as the reviewed head, which is the fast-forward shape.
2396func requestFileMerged(title, base, head, author, mergedBy string, closes []string) string {
2397 d := metadata.NewDoc()
2398 d.Text("title", title)
2399 d.Word("state", "merged")
2400 d.Word("author", author)
2401 d.Word("base", base)
2402 d.Word("head", head)
2403 d.Word("created", "2026-10-22T22:00:00Z")
2404 d.List("closes", closes)
2405 d.Word("merged_base", head)
2406 d.Word("merged_head", head)
2407 d.Word("merged_by", mergedBy)
2408 d.Word("merged_at", "2026-10-23T22:00:00Z")
2409 out, err := d.Bytes("")
2410 if err != nil {
2411 panic(err)
2412 }
2413 return string(out)
2414}
2415
2416func approvalFile(base, head string) string {
2417 d := metadata.NewDoc()
2418 d.Word("base", base)
2419 d.Word("head", head)
2420 d.Word("created", "2026-10-22T22:10:00Z")
2421 out, err := d.Bytes("")
2422 if err != nil {
2423 panic(err)
2424 }
2425 return string(out)
2426}
2427
2428// issueFile renders issues/<id>/issue.md for fixtures.
2429func issueFile(title, author, state, created string, labels []string, body string, assignees ...string) string {
2430 d := metadata.NewDoc()
2431 d.Text("title", title)
2432 d.Word("state", state)
2433 d.Word("author", author)
2434 d.Word("created", created)
2435 d.List("labels", labels)
2436 d.List("assignees", assignees)
2437 out, err := d.Bytes(body)
2438 if err != nil {
2439 panic(err)
2440 }
2441 return string(out)
2442}
2443
2444// TestProtectedBranchRequiresApprovedRequest is the protected-branch contract: a
2445// writer cannot land on a protected branch until a change request for that exact
2446// commit has the required approvals, and an admin still bypasses.
2447func TestProtectedBranchRequiresApprovedRequest(t *testing.T) {
2448 t.Parallel()
2449 f := newFixture(t)
2450 writerKey := f.addWriter(t)
2451 f.publish(f.repo)
2452
2453 // Initial main, pushed by the admin.
2454 base := workRepo(t, map[string]string{"readme.md": "v1\n"})
2455 if _, errs, err := f.git(base, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
2456 t.Fatalf("initial main: %s", errs)
2457 }
2458
2459 // Protect main.
2460 m := mustRepo(t, f)
2461 a, _ := m.ACL()
2462 a.Protected = []string{"refs/heads/main"}
2463 if err := m.SetACL(a, "test"); err != nil {
2464 t.Fatal(err)
2465 }
2466
2467 // The writer pushes a feature branch and gets its head.
2468 feature, head := f.cloneWithChange(t, map[string]string{"readme.md": "v2\n"}, "feature")
2469 if _, errs, err := f.gitWithKey(writerKey, feature, "push", f.sshURL(f.repo), "HEAD:refs/heads/feature"); err != nil {
2470 t.Fatalf("feature push: %s", errs)
2471 }
2472
2473 // 1. No request at all: refused, and the message names the commit.
2474 _, errs, err := f.gitWithKey(writerKey, feature, "push", f.sshURL(f.repo), "HEAD:refs/heads/main")
2475 if err == nil {
2476 t.Fatal("a writer landed on a protected branch with no change request")
2477 }
2478 if !strings.Contains(errs, "needs 1 approval") || !strings.Contains(errs, head[:8]) {
2479 t.Fatalf("rejection is not actionable:\n%s", errs)
2480 }
2481
2482 // 2. Request open, no approvals: still refused.
2483 meta := f.newMetaRepo(t)
2484 if err := meta.apply(map[string]string{
2485 "changes/" + reqID + "/change.md": requestMeta("main", head, "wilbur"),
2486 }, "request"); err != nil {
2487 t.Fatalf("request push: %v", err)
2488 }
2489 if _, errs, err := f.gitWithKey(writerKey, feature, "push", f.sshURL(f.repo), "HEAD:refs/heads/main"); err == nil {
2490 t.Fatal("a request with no approvals was enough to land")
2491 } else if !strings.Contains(errs, "needs 1 approval") {
2492 t.Fatalf("unexpected rejection: %s", errs)
2493 }
2494
2495 // 3. Approval by another principal: allowed.
2496 approver := f.newMetaRepo(t)
2497 if err := approver.apply(map[string]string{
2498 "changes/" + reqID + "/approvals/" + f.accountName("keenan") + ".md": approvalFile("main", head),
2499 }, "approve"); err != nil {
2500 t.Fatalf("approval push: %v", err)
2501 }
2502 if _, errs, err := f.gitWithKey(writerKey, feature, "push", f.sshURL(f.repo), "HEAD:refs/heads/main"); err != nil {
2503 t.Fatalf("approved push was refused: %s", errs)
2504 }
2505
2506 audit, err := os.ReadFile(f.cfg.AuditLog())
2507 if err != nil {
2508 t.Fatal(err)
2509 }
2510 if !strings.Contains(string(audit), "\tmerge\twilbur\tdemo\trefs/heads/main via request "+reqID) {
2511 t.Fatalf("merge not audited:\n%s", audit)
2512 }
2513}
2514
2515// TestApprovalIsBoundToTheCommit is the property that makes approvals mean
2516// something: approve A, amend to B, and the approval no longer applies.
2517func TestApprovalIsBoundToTheCommit(t *testing.T) {
2518 t.Parallel()
2519 f := newFixture(t)
2520 writerKey := f.addWriter(t)
2521 f.publish(f.repo)
2522 base := workRepo(t, map[string]string{"readme.md": "v1\n"})
2523 if _, errs, err := f.git(base, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
2524 t.Fatalf("initial main: %s", errs)
2525 }
2526 m := mustRepo(t, f)
2527 a, _ := m.ACL()
2528 a.Protected = []string{"refs/heads/main"}
2529 if err := m.SetACL(a, "test"); err != nil {
2530 t.Fatal(err)
2531 }
2532
2533 feature, approved := f.cloneWithChange(t, map[string]string{"readme.md": "v2\n"}, "feature")
2534
2535 meta := f.newMetaRepo(t)
2536 if err := meta.apply(map[string]string{
2537 "changes/" + reqID + "/change.md": requestMeta("main", approved, "wilbur"),
2538 }, "request"); err != nil {
2539 t.Fatalf("request: %v", err)
2540 }
2541 approver := f.newMetaRepo(t)
2542 if err := approver.apply(map[string]string{
2543 "changes/" + reqID + "/approvals/" + f.accountName("keenan") + ".md": approvalFile("main", approved),
2544 }, "approve"); err != nil {
2545 t.Fatalf("approve: %v", err)
2546 }
2547
2548 // Amend the feature so the head is a different commit.
2549 if _, errs, err := runGit(t, feature, "commit", "-q", "--amend", "-m", "sneaky change"); err != nil {
2550 t.Fatalf("amend: %v: %s", err, errs)
2551 }
2552 if _, _, err := f.gitWithKey(writerKey, feature, "push", f.sshURL(f.repo), "HEAD:refs/heads/main"); err == nil {
2553 t.Fatal("a commit that was never approved landed on main")
2554 }
2555}
2556
2557// TestSelfApprovalRejected: the metadata push itself fails, so the approval never
2558// exists to be counted.
2559func TestSelfApprovalRejected(t *testing.T) {
2560 t.Parallel()
2561 f := newFixture(t)
2562 writerKey := f.addWriter(t)
2563 f.publish(f.repo)
2564 if _, errs, err := f.git(workRepo(t, map[string]string{"a.md": "a\n"}),
2565 "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
2566 t.Fatalf("initial main: %s", errs)
2567 }
2568 _, head := f.cloneWithChange(t, map[string]string{"a.md": "b\n"}, "change")
2569
2570 meta := f.newMetaRepo(t)
2571 err := meta.apply(map[string]string{
2572 "changes/" + reqID + "/change.md": requestMeta("main", head, "wilbur"),
2573 "changes/" + reqID + "/approvals/" + f.accountName("wilbur") + ".md": approvalFile("main", head),
2574 }, "self approve")
2575 if err == nil {
2576 t.Fatal("a self-approval was accepted")
2577 }
2578 if !strings.Contains(err.Error(), "cannot approve their own request") {
2579 t.Fatalf("unexpected error: %v", err)
2580 }
2581 _ = writerKey
2582}
2583
2584// TestMergeCommitShapeAllowed: landing through a merge commit whose parents are
2585// the branch tip and the approved head is allowed; smuggling an extra commit into
2586// that merge is not.
2587func TestMergeCommitShapeAllowed(t *testing.T) {
2588 t.Parallel()
2589 f := newFixture(t)
2590 writerKey := f.addWriter(t)
2591 f.publish(f.repo)
2592 base := workRepo(t, map[string]string{"readme.md": "v1\n"})
2593 if _, errs, err := f.git(base, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
2594 t.Fatalf("initial main: %s", errs)
2595 }
2596 m := mustRepo(t, f)
2597 a, _ := m.ACL()
2598 a.Protected = []string{"refs/heads/main"}
2599 if err := m.SetACL(a, "test"); err != nil {
2600 t.Fatal(err)
2601 }
2602
2603 feature, head := f.cloneWithChange(t, map[string]string{"readme.md": "v2\n"}, "feature")
2604 if _, errs, err := f.gitWithKey(writerKey, feature, "push", f.sshURL(f.repo), "HEAD:refs/heads/feature"); err != nil {
2605 t.Fatalf("feature: %s", errs)
2606 }
2607 meta := f.newMetaRepo(t)
2608 if err := meta.apply(map[string]string{
2609 "changes/" + reqID + "/change.md": requestMeta("main", head, "wilbur"),
2610 }, "request"); err != nil {
2611 t.Fatalf("request: %v", err)
2612 }
2613 approver := f.newMetaRepo(t)
2614 if err := approver.apply(map[string]string{
2615 "changes/" + reqID + "/approvals/" + f.accountName("keenan") + ".md": approvalFile("main", head),
2616 }, "approve"); err != nil {
2617 t.Fatalf("approve: %v", err)
2618 }
2619
2620 // A merge of main + the approved head is allowed.
2621 mergeWork := filepath.Join(t.TempDir(), "merge")
2622 if err := os.MkdirAll(mergeWork, 0o755); err != nil {
2623 t.Fatal(err)
2624 }
2625 if _, errs, err := f.git("", "clone", "-q", f.sshURL(f.repo), mergeWork); err != nil {
2626 t.Fatalf("clone: %s", errs)
2627 }
2628 if _, errs, err := f.git(mergeWork, "fetch", "-q", "origin", "feature:feature"); err != nil {
2629 t.Fatalf("fetch feature: %v: %s", err, errs)
2630 }
2631 if _, errs, err := runGit(t, mergeWork, "merge", "-q", "--no-ff", "-m", "merge feature", "feature"); err != nil {
2632 t.Fatalf("merge: %v: %s", err, errs)
2633 }
2634 if _, errs, err := f.gitWithKey(writerKey, mergeWork, "push", f.sshURL(f.repo), "HEAD:refs/heads/main"); err != nil {
2635 t.Fatalf("approved merge was refused: %s", errs)
2636 }
2637}
2638
2639// TestImpersonatedApprovalRejected: an approval file is only written by the person
2640// it names, otherwise "approval" means nothing.
2641func TestImpersonatedApprovalRejected(t *testing.T) {
2642 t.Parallel()
2643 f := newFixture(t)
2644 writerKey := f.addWriter(t)
2645 f.publish(f.repo)
2646 if _, errs, err := f.git(workRepo(t, map[string]string{"a.md": "a\n"}),
2647 "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
2648 t.Fatalf("initial main: %s", errs)
2649 }
2650 _, head := f.cloneWithChange(t, map[string]string{"a.md": "b\n"}, "change")
2651
2652 meta := f.newMetaRepo(t)
2653 if err := meta.apply(map[string]string{
2654 "changes/" + reqID + "/change.md": requestMeta("main", head, "wilbur"),
2655 }, "request"); err != nil {
2656 t.Fatalf("request: %v", err)
2657 }
2658
2659 impostor := f.newMetaRepo(t)
2660 impostor.key = writerKey
2661 err := impostor.apply(map[string]string{
2662 "changes/" + reqID + "/approvals/" + f.accountName("keenan") + ".md": approvalFile("main", head),
2663 }, "approve as someone else")
2664 if err == nil {
2665 t.Fatal("a writer approved in another principal's name")
2666 }
2667 if !strings.Contains(err.Error(), "keenan") {
2668 t.Fatalf("rejection does not name the impersonated principal: %v", err)
2669 }
2670}
2671
2672// TestApprovalIsBoundToTheBase: repointing a request at another branch must not
2673// carry its approvals along.
2674func TestApprovalIsBoundToTheBase(t *testing.T) {
2675 t.Parallel()
2676 f := newFixture(t)
2677 writerKey := f.addWriter(t)
2678 f.publish(f.repo)
2679 if _, errs, err := f.git(workRepo(t, map[string]string{"a.md": "a\n"}),
2680 "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
2681 t.Fatalf("initial main: %s", errs)
2682 }
2683 if _, errs, err := f.git(workRepo(t, map[string]string{"b.md": "b\n"}),
2684 "push", f.sshURL(f.repo), "main:refs/heads/release"); err != nil {
2685 t.Fatalf("release: %s", errs)
2686 }
2687 m := mustRepo(t, f)
2688 a, _ := m.ACL()
2689 a.Protected = []string{"refs/heads/main", "refs/heads/release"}
2690 if err := m.SetACL(a, "test"); err != nil {
2691 t.Fatal(err)
2692 }
2693
2694 feature, head := f.cloneWithChange(t, map[string]string{"a.md": "v2\n"}, "feature")
2695 meta := f.newMetaRepo(t)
2696 if err := meta.apply(map[string]string{
2697 "changes/" + reqID + "/change.md": requestMeta("main", head, "wilbur"),
2698 }, "request"); err != nil {
2699 t.Fatalf("request: %v", err)
2700 }
2701 approver := f.newMetaRepo(t)
2702 if err := approver.apply(map[string]string{
2703 "changes/" + reqID + "/approvals/" + f.accountName("keenan") + ".md": approvalFile("main", head),
2704 }, "approve"); err != nil {
2705 t.Fatalf("approve: %v", err)
2706 }
2707
2708 // Repoint the request at release. The approval named main.
2709 repoint := f.newMetaRepo(t)
2710 if err := repoint.apply(map[string]string{
2711 "changes/" + reqID + "/change.md": requestMeta("release", head, "wilbur"),
2712 }, "repoint"); err != nil {
2713 t.Fatalf("repoint: %v", err)
2714 }
2715 if _, _, err := f.gitWithKey(writerKey, feature, "push", f.sshURL(f.repo), "HEAD:refs/heads/release"); err == nil {
2716 t.Fatal("an approval for main landed the commit on release")
2717 }
2718}
2719
2720// TestFirstPushCannotSeedForeignApprovals: the check that an approval is written by
2721// its named principal must also cover the push that creates the branch, when there
2722// is no previous commit to diff against.
2723func TestFirstPushCannotSeedForeignApprovals(t *testing.T) {
2724 t.Parallel()
2725 f := newFixture(t)
2726 writerKey := f.addWriter(t)
2727 f.publish(f.repo)
2728 if _, errs, err := f.git(workRepo(t, map[string]string{"a.md": "a\n"}),
2729 "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
2730 t.Fatalf("initial main: %s", errs)
2731 }
2732 _, head := f.cloneWithChange(t, map[string]string{"a.md": "b\n"}, "change")
2733
2734 // The writer creates the metadata branch, seeding an approval in keenan's name.
2735 meta := f.newMetaRepo(t)
2736 meta.key = writerKey
2737 err := meta.apply(map[string]string{
2738 "changes/" + reqID + "/change.md": requestMeta("main", head, "wilbur"),
2739 "changes/" + reqID + "/approvals/" + f.accountName("keenan") + ".md": approvalFile("main", head),
2740 }, "seed")
2741 if err == nil {
2742 t.Fatal("the first push seeded an approval in another principal's name")
2743 }
2744 if !strings.Contains(err.Error(), "keenan") {
2745 t.Fatalf("rejection does not name the impersonated principal: %v", err)
2746 }
2747}
2748
2749// TestModifiedApprovalCannotBeForged: an approval file that already exists can be
2750// *edited* rather than added, so a check that only inspects added paths misses it.
2751// This is the exploit: approve commit A, then rewrite the approval to name B.
2752func TestModifiedApprovalCannotBeForged(t *testing.T) {
2753 t.Parallel()
2754 f := newFixture(t)
2755 writerKey := f.addWriter(t)
2756 f.publish(f.repo)
2757 base := workRepo(t, map[string]string{"readme.md": "v1\n"})
2758 if _, errs, err := f.git(base, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
2759 t.Fatalf("initial main: %s", errs)
2760 }
2761 m := mustRepo(t, f)
2762 a, _ := m.ACL()
2763 a.Protected = []string{"refs/heads/main"}
2764 if err := m.SetACL(a, "test"); err != nil {
2765 t.Fatal(err)
2766 }
2767
2768 feature, approvedHead := f.cloneWithChange(t, map[string]string{"readme.md": "v2\n"}, "feature")
2769 if _, errs, err := f.gitWithKey(writerKey, feature, "push", f.sshURL(f.repo), "HEAD:refs/heads/feature"); err != nil {
2770 t.Fatalf("feature: %s", errs)
2771 }
2772 meta := f.newMetaRepo(t)
2773 if err := meta.apply(map[string]string{
2774 "changes/" + reqID + "/change.md": requestMeta("main", approvedHead, "wilbur"),
2775 }, "request"); err != nil {
2776 t.Fatalf("request: %v", err)
2777 }
2778 approver := f.newMetaRepo(t)
2779 if err := approver.apply(map[string]string{
2780 "changes/" + reqID + "/approvals/" + f.accountName("keenan") + ".md": approvalFile("main", approvedHead),
2781 }, "approve"); err != nil {
2782 t.Fatalf("approve: %v", err)
2783 }
2784
2785 // wilbur amends the feature to a commit keenan never saw.
2786 if _, errs, err := runGit(t, feature, "commit", "-q", "--amend", "-m", "unreviewed"); err != nil {
2787 t.Fatalf("amend: %v: %s", err, errs)
2788 }
2789 out, _, _ := runGit(t, feature, "rev-parse", "HEAD")
2790 sneaky := strings.TrimSpace(out)
2791
2792 // He repoints both his request and keenan's approval at it.
2793 forger := f.newMetaRepo(t)
2794 forger.key = writerKey
2795 err := forger.apply(map[string]string{
2796 "changes/" + reqID + "/change.md": requestMeta("main", sneaky, "wilbur"),
2797 "changes/" + reqID + "/approvals/" + f.accountName("keenan") + ".md": approvalFile("main", sneaky),
2798 }, "repoint approval")
2799 if err == nil {
2800 t.Fatal("a writer rewrote another principal's approval and the push was accepted")
2801 }
2802 if !strings.Contains(err.Error(), "keenan") {
2803 t.Fatalf("rejection does not name the impersonated principal: %v", err)
2804 }
2805}
2806
2807// TestApprovalDeletionIsOwnerOrAdmin: deleting a file forges nothing, but it
2808// erases someone else's statement, so a writer may not do it to another person.
2809func TestApprovalDeletionIsOwnerOrAdmin(t *testing.T) {
2810 t.Parallel()
2811 f := newFixture(t)
2812 writerKey := f.addWriter(t)
2813 f.publish(f.repo)
2814 if _, errs, err := f.git(workRepo(t, map[string]string{"a.md": "a\n"}),
2815 "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
2816 t.Fatalf("initial main: %s", errs)
2817 }
2818 _, head := f.cloneWithChange(t, map[string]string{"a.md": "b\n"}, "change")
2819
2820 // wilbur authors the request; keenan approves it.
2821 meta := f.newMetaRepo(t)
2822 meta.key = writerKey
2823 if err := meta.apply(map[string]string{
2824 "changes/" + reqID + "/change.md": requestMeta("main", head, "wilbur"),
2825 }, "request"); err != nil {
2826 t.Fatalf("setup: %v", err)
2827 }
2828 approver := f.newMetaRepo(t)
2829 if err := approver.apply(map[string]string{
2830 "changes/" + reqID + "/approvals/" + f.accountName("keenan") + ".md": approvalFile("main", head),
2831 }, "approve"); err != nil {
2832 t.Fatalf("approve: %v", err)
2833 }
2834
2835 // wilbur erases keenan's approval.
2836 deleter := f.newMetaRepo(t)
2837 deleter.key = writerKey
2838 deleter.sync()
2839 if err := os.Remove(filepath.Join(deleter.dir, "changes", reqID, "approvals", f.accountName("keenan")+".md")); err != nil {
2840 t.Fatal(err)
2841 }
2842 if _, errs, err := runGit(t, deleter.dir, "add", "-A"); err != nil {
2843 t.Fatalf("add: %v: %s", err, errs)
2844 }
2845 if _, errs, err := runGit(t, deleter.dir, "commit", "-qm", "drop approval"); err != nil {
2846 t.Fatalf("commit: %v: %s", err, errs)
2847 }
2848 _, errs, err := fgit(t, writerKey, deleter.dir, "push", "origin", "HEAD:refs/heads/vole")
2849 if err == nil {
2850 t.Fatal("a writer deleted another principal's approval")
2851 }
2852 if !strings.Contains(errs, "may not delete keenan's approval") {
2853 t.Fatalf("unexpected rejection: %s", errs)
2854 }
2855
2856 // keenan withdraws his own.
2857 own := f.newMetaRepo(t)
2858 own.sync()
2859 if err := os.Remove(filepath.Join(own.dir, "changes", reqID, "approvals", f.accountName("keenan")+".md")); err != nil {
2860 t.Fatal(err)
2861 }
2862 if _, errs, cerr := runGit(t, own.dir, "add", "-A"); cerr != nil {
2863 t.Fatalf("add: %v: %s", cerr, errs)
2864 }
2865 if _, errs, cerr := runGit(t, own.dir, "commit", "-qm", "withdraw my approval"); cerr != nil {
2866 t.Fatalf("commit: %v: %s", cerr, errs)
2867 }
2868 if _, errs, cerr := fgit(t, f.keyPath, own.dir, "push", "origin", "HEAD:refs/heads/vole"); cerr != nil {
2869 t.Fatalf("owner could not withdraw their own approval: %s", errs)
2870 }
2871}
2872
2873// TestAccessAndApprovalOwnershipFollowTheName: ACL entries and approval files
2874// are keyed by the same registered name metadata authors carry.
2875func TestAccessAndApprovalOwnershipFollowTheName(t *testing.T) {
2876 t.Parallel()
2877 f := newFixture(t)
2878 writerKey := f.addWriter(t)
2879 f.publish(f.repo)
2880 if _, errs, err := f.git(workRepo(t, map[string]string{"a.md": "a\n"}),
2881 "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
2882 t.Fatalf("initial main: %s", errs)
2883 }
2884 _, head := f.cloneWithChange(t, map[string]string{"a.md": "b\n"}, "change")
2885
2886 // keenan authors a request; wilbur (the writer) approves it.
2887 meta := f.newMetaRepo(t)
2888 if err := meta.apply(map[string]string{
2889 "changes/" + reqID + "/change.md": requestMeta("main", head, "keenan"),
2890 }, "request"); err != nil {
2891 t.Fatalf("request: %v", err)
2892 }
2893 approver := f.newMetaRepo(t)
2894 approver.key = writerKey
2895 if err := approver.apply(map[string]string{
2896 "changes/" + reqID + "/approvals/wilbur.md": approvalFile("main", head),
2897 }, "approve"); err != nil {
2898 t.Fatalf("approve: %v", err)
2899 }
2900
2901 // The same key still has write, under the same name.
2902 issue := f.newMetaRepo(t)
2903 issue.key = writerKey
2904 if err := issue.apply(map[string]string{
2905 "issues/01M5JR2JK29KKMAA1T9MDNTZ01/issue.md": issueFile("after approval", "wilbur", "open", "2026-10-22T22:00:00Z", nil, ""),
2906 }, "issue after approval"); err != nil {
2907 t.Fatalf("the account lost its access: %v", err)
2908 }
2909
2910 // wilbur may withdraw the approval they wrote.
2911 withdraw := f.newMetaRepo(t)
2912 withdraw.key = writerKey
2913 withdraw.sync()
2914 if err := os.Remove(filepath.Join(withdraw.dir, "changes", reqID, "approvals", "wilbur.md")); err != nil {
2915 t.Fatal(err)
2916 }
2917 if _, errs, err := runGit(t, withdraw.dir, "add", "-A"); err != nil {
2918 t.Fatalf("add: %v: %s", err, errs)
2919 }
2920 if _, errs, err := runGit(t, withdraw.dir, "commit", "-qm", "withdraw approval"); err != nil {
2921 t.Fatalf("commit: %v: %s", err, errs)
2922 }
2923 if _, errs, err := fgit(t, writerKey, withdraw.dir, "push", "origin", "HEAD:refs/heads/vole"); err != nil {
2924 t.Fatalf("the account could not withdraw its own approval: %s", errs)
2925 }
2926}
2927
2928// TestRetiredNameCannotBeReissued: a departure must not hand someone a retired
2929// person's access and approvals.
2930func TestRetiredNameCannotBeReissued(t *testing.T) {
2931 t.Parallel()
2932 f := newFixture(t)
2933 writerKey := f.addWriter(t)
2934 f.publish(f.repo)
2935 if _, errs, err := f.git(workRepo(t, map[string]string{"a.md": "a\n"}),
2936 "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
2937 t.Fatalf("initial main: %s", errs)
2938 }
2939
2940 if _, err := f.srv.Identity.Retire("wilbur", "test"); err != nil {
2941 t.Fatal(err)
2942 }
2943 reissue, _ := keypair(t, filepath.Join(f.cfg.DataDir, "id_reissued"))
2944 if _, err := f.srv.Identity.Create("wilbur", reissue, "test"); err == nil {
2945 t.Fatal("the retired name was reissued")
2946 }
2947 // And the retired key no longer authenticates.
2948 if _, _, err := f.gitWithKey(writerKey, t.TempDir(), "ls-remote", f.sshURL(f.repo)); err == nil {
2949 t.Fatal("a retired account's key still authenticates")
2950 }
2951}
2952
2953// TestInvalidWorkflowRejectedAtPush: a broken workflow fails the push that
2954// introduced it, naming the file and the reason, instead of sitting in the tree
2955// and never running.
2956func TestInvalidWorkflowRejectedAtPush(t *testing.T) {
2957 t.Parallel()
2958 f := newFixture(t)
2959 f.publish(f.repo)
2960
2961 broken := workRepo(t, map[string]string{
2962 ".vole/workflows/ci.yaml": "version: 1\njobs:\n test:\n image: alpine:3.22\n steps:\n - name: forgot the command\n",
2963 })
2964 _, errs, err := f.git(broken, "push", f.sshURL(f.repo), "main:refs/heads/main")
2965 if err == nil {
2966 t.Fatal("a workflow with a step that runs nothing was accepted")
2967 }
2968 for _, want := range []string{".vole/workflows/ci.yaml", "run is required"} {
2969 if !strings.Contains(errs, want) {
2970 t.Fatalf("rejection does not mention %q:\n%s", want, errs)
2971 }
2972 }
2973
2974 // The same push with a valid file is accepted.
2975 good := workRepo(t, map[string]string{
2976 ".vole/workflows/ci.yaml": "version: 1\non:\n push:\n branches: [main]\njobs:\n test:\n image: alpine:3.22\n steps:\n - run: go test ./...\n",
2977 })
2978 if _, errs, err := f.git(good, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
2979 t.Fatalf("valid workflow rejected: %s", errs)
2980 }
2981}
2982
2983// ---------- CI ----------
2984
2985// ciEnv is the environment a runner needs: the same key git would push with.
2986func (f *fixture) ciEnv() []string { return f.ciEnvFor(f.keyPath) }
2987
2988func (f *fixture) ciEnvFor(keyPath string) []string {
2989 sshCmd := "ssh -i " + keyPath + " -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR"
2990 env := append(os.Environ(), "VOLE_SSH_COMMAND="+sshCmd, "GIT_SSH_COMMAND="+sshCmd,
2991 "GIT_TERMINAL_PROMPT=0", "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null",
2992 "FAKE_RUNTIME_STATE="+fakeRuntimeState)
2993 // The runner requires an OCI runtime. Put the test script first so this package
2994 // does not depend on Docker being installed or usable in a sandboxed test host.
2995 for i, kv := range env {
2996 if strings.HasPrefix(kv, "PATH=") {
2997 env[i] = "PATH=" + fakeRuntimeDir + string(os.PathListSeparator) + strings.TrimPrefix(kv, "PATH=")
2998 break
2999 }
3000 }
3001 return env
3002}
3003
3004func (f *fixture) sshBase() string { return "ssh://git@" + f.sshAddr }
3005
3006// waitForRun polls the queue until a run matches, because ingestion is a ticker.
3007func (f *fixture) waitForRun(t *testing.T, within time.Duration, match func(*runs.Run) bool) *runs.Run {
3008 t.Helper()
3009 deadline := time.Now().Add(within)
3010 var last []*runs.Run
3011 for time.Now().Before(deadline) {
3012 list, err := f.srv.Runs.Recent(f.repo, 20)
3013 if err == nil {
3014 last = list
3015 for _, r := range list {
3016 if match(r) {
3017 return r
3018 }
3019 }
3020 }
3021 time.Sleep(100 * time.Millisecond)
3022 }
3023 t.Fatalf("no matching run after %s; saw %d run(s): %+v", within, len(last), last)
3024 return nil
3025}
3026
3027func workflowFile(body string) map[string]string {
3028 return map[string]string{".vole/workflows/ci.yaml": body}
3029}
3030
3031// TestPushRunsWorkflow is the end-to-end criterion: a push queues a job, a runner
3032// leases it, executes the pinned steps and reports the log.
3033func TestPushRunsWorkflow(t *testing.T) {
3034 t.Parallel()
3035 f := newFixtureWith(t, func(c *config.Config) { c.LeaseSeconds = 2 })
3036 f.publish(f.repo)
3037
3038 work := workRepo(t, workflowFile(`version: 1
3039jobs:
3040 test:
3041 image: alpine:3.22
3042 steps:
3043 - name: say hello
3044 run: echo "hello from the runner"
3045`))
3046 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
3047 t.Fatalf("push: %s", errs)
3048 }
3049
3050 queued := f.waitForRun(t, 5*time.Second, func(r *runs.Run) bool { return r.Status == runs.StatusQueued })
3051 if queued.Job != "test" || len(queued.Steps) != 1 {
3052 t.Fatalf("queued run = %+v", queued)
3053 }
3054 if queued.WorkflowSHA == "" {
3055 t.Fatal("the workflow blob was not pinned; a later edit could change what runs")
3056 }
3057
3058 cmd := exec.Command(binPath, "runner", "--remote", f.sshBase(), "--once", "--dir", t.TempDir())
3059 cmd.Env = f.ciEnv()
3060 if out, err := cmd.CombinedOutput(); err != nil {
3061 t.Fatalf("runner: %v\n%s", err, out)
3062 }
3063
3064 done := f.waitForRun(t, 10*time.Second, func(r *runs.Run) bool { return r.Status != runs.StatusQueued && r.Status != runs.StatusRunning })
3065 if done.Status != runs.StatusSuccess {
3066 t.Fatalf("run status = %s (exit %d)", done.Status, done.ExitCode)
3067 }
3068 // The log is a file under the instance's log directory, streamed while the job
3069 // ran rather than uploaded as one object at the end. The page serves those bytes.
3070 logPath := filepath.Join(f.cfg.LogsDir(), f.repo, done.ID, fmt.Sprintf("%d.log", done.Attempts))
3071 stored, err := os.ReadFile(logPath)
3072 if err != nil {
3073 t.Fatalf("finished run has no log file: %v", err)
3074 }
3075 code, body := f.get("/" + f.repo + "/runs/" + done.ID + "/log")
3076 if code != 200 || !strings.Contains(body, "hello from the runner") {
3077 t.Fatalf("run log page = %d, body:\n%s", code, body)
3078 }
3079 if body != string(stored) {
3080 t.Fatalf("the served log differs from %s", logPath)
3081 }
3082 dlReq, dlErr := http.NewRequest(http.MethodGet, f.http.URL+"/"+f.repo+"/runs/"+done.ID+"/log?download=1", nil)
3083 if dlErr != nil {
3084 t.Fatal(dlErr)
3085 }
3086 dlResp, dlErr := http.DefaultClient.Do(dlReq)
3087 if dlErr != nil {
3088 t.Fatal(dlErr)
3089 }
3090 downloaded, dlErr := io.ReadAll(dlResp.Body)
3091 dlResp.Body.Close()
3092 if dlErr != nil {
3093 t.Fatal(dlErr)
3094 }
3095 if cd := dlResp.Header.Get("Content-Disposition"); !strings.Contains(cd, "attachment") || !strings.Contains(cd, done.ID) {
3096 t.Fatalf("downloaded log Content-Disposition = %q", cd)
3097 }
3098 if string(downloaded) != string(stored) {
3099 t.Fatalf("the downloaded log differs from %s", logPath)
3100 }
3101
3102 // The result is stored in the database, not on the branch: the record names this
3103 // job, its log and its outcome, and the queue records who reported it. Attribution
3104 // is the SSH channel's, and it is logged rather than carried as a signature.
3105 att, err := f.srv.Runs.Attestation(done.ID)
3106 if err != nil || att == nil {
3107 t.Fatalf("no attestation on the run: %v", err)
3108 }
3109 if att.Signer == "" {
3110 t.Fatalf("attestation is not attributed: %+v", att)
3111 }
3112 rec, err := f.srv.Runs.Attempt(done.ID, done.Attempts)
3113 if err != nil || rec == nil {
3114 t.Fatalf("no stored attempt: %v", err)
3115 }
3116 if rec.LogSize != int64(len(stored)) || rec.Exit != 0 || rec.Job != done.Job {
3117 t.Fatalf("the record does not describe the run: %+v", rec)
3118 }
3119 // The runner also records what each step did and where its output lives in the
3120 // archived log. A step range is a slice of the same bytes, not a second upload.
3121 if len(rec.Steps) != 1 || rec.Steps[0].Name != "say hello" {
3122 t.Fatalf("the record does not describe the step: %+v", rec.Steps)
3123 }
3124 if rec.Steps[0].LogSize == 0 || rec.Steps[0].Duration() <= 0 {
3125 t.Fatalf("the step has no timing or log range: %+v", rec.Steps[0])
3126 }
3127 code, stepLog := f.get("/" + f.repo + "/runs/" + done.ID + "/log?step=1")
3128 if code != 200 || !strings.Contains(stepLog, "hello from the runner") {
3129 t.Fatalf("step log = %d, body:\n%s", code, stepLog)
3130 }
3131 if len(stepLog) >= len(body) {
3132 t.Fatalf("step log is %d bytes, no smaller than the whole %d-byte log", len(stepLog), len(body))
3133 }
3134 // The archived object is served directly, so a range request works without the
3135 // server reading the whole log.
3136 req, err := http.NewRequest(http.MethodGet, f.http.URL+"/"+f.repo+"/runs/"+done.ID+"/log", nil)
3137 if err != nil {
3138 t.Fatal(err)
3139 }
3140 req.Header.Set("Range", "bytes=0-3")
3141 resp, err := http.DefaultClient.Do(req)
3142 if err != nil {
3143 t.Fatal(err)
3144 }
3145 defer resp.Body.Close()
3146 if resp.StatusCode != http.StatusPartialContent || resp.Header.Get("Content-Range") == "" {
3147 t.Fatalf("range request = %d, Content-Range %q", resp.StatusCode, resp.Header.Get("Content-Range"))
3148 }
3149 // Attribution is the boundary's, and the page says who it accepted the result from.
3150 // A finished run shows each step once; the whole log is a download, not a second
3151 // copy of every step's output in an accordion above them.
3152 if _, page := f.get("/" + f.repo + "/runs/" + done.ID); !strings.Contains(page, "reported by") ||
3153 !strings.Contains(page, "say hello") || !strings.Contains(page, "step=1") ||
3154 !strings.Contains(page, "Download full log") || !strings.Contains(page, "download=1") {
3155 t.Fatalf("the run page does not show the result, the step log, or the full-log download:\n%s", page)
3156 } else if strings.Contains(page, "data-log-stream") {
3157 t.Fatalf("the finished run page still shows the full live log viewer:\n%s", page)
3158 }
3159 if done.SHA != mustSHA(t, work) {
3160 t.Fatalf("run recorded sha %s, want the pushed commit", done.SHA)
3161 }
3162}
3163
3164// TestRunLogStreamsWhileTheJobRuns is the point of streaming: the log endpoint
3165// serves the first step's bytes before the job finishes, and the finished log still
3166// contains everything.
3167func TestRunLogStreamsWhileTheJobRuns(t *testing.T) {
3168 t.Parallel()
3169 f := newFixtureWith(t, func(c *config.Config) { c.LeaseSeconds = 5 })
3170 f.publish(f.repo)
3171
3172 work := workRepo(t, workflowFile(`version: 1
3173jobs:
3174 test:
3175 image: alpine:3.22
3176 steps:
3177 - name: slow
3178 run: echo "first line"; sleep 4; echo "second line"
3179`))
3180 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
3181 t.Fatalf("push: %s", errs)
3182 }
3183 f.waitForRun(t, 5*time.Second, func(r *runs.Run) bool { return r.Status == runs.StatusQueued })
3184
3185 cmd := exec.Command(binPath, "runner", "--remote", f.sshBase(), "--once", "--dir", t.TempDir())
3186 cmd.Env = f.ciEnv()
3187 if err := cmd.Start(); err != nil {
3188 t.Fatal(err)
3189 }
3190 defer func() {
3191 _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
3192 _, _ = cmd.Process.Wait()
3193 }()
3194
3195 run := f.waitForRun(t, 10*time.Second, func(r *runs.Run) bool { return r.Status == runs.StatusRunning })
3196 deadline := time.Now().Add(8 * time.Second)
3197 for {
3198 code, body := f.get("/" + f.repo + "/runs/" + run.ID + "/log?after=0")
3199 if code == 200 && strings.Contains(body, "first line") {
3200 break
3201 }
3202 if time.Now().After(deadline) {
3203 t.Fatalf("the running log never showed the first line: %d %q", code, body)
3204 }
3205 time.Sleep(100 * time.Millisecond)
3206 }
3207 if _, page := f.get("/" + f.repo + "/runs/" + run.ID); !strings.Contains(page, "data-log-stream") ||
3208 !strings.Contains(page, "Job output") || !strings.Contains(page, "slow") {
3209 t.Fatalf("the running run page does not show the live log viewer or step accordion:\n%s", page)
3210 }
3211 // The live viewer polls; a running attempt must not claim the log is final.
3212 resp, err := http.Get(f.http.URL + "/" + f.repo + "/runs/" + run.ID + "/log?after=0")
3213 if err != nil {
3214 t.Fatal(err)
3215 }
3216 if final := resp.Header.Get("X-Log-Final"); final == "1" {
3217 t.Fatalf("a running attempt's log is marked final")
3218 }
3219 resp.Body.Close()
3220
3221 done := f.waitForRun(t, 20*time.Second, func(r *runs.Run) bool {
3222 return r.Status != runs.StatusQueued && r.Status != runs.StatusRunning
3223 })
3224 if done.Status != runs.StatusSuccess {
3225 t.Fatalf("run status = %s (exit %d)", done.Status, done.ExitCode)
3226 }
3227 code, body := f.get("/" + f.repo + "/runs/" + done.ID + "/log")
3228 if code != 200 || !strings.Contains(body, "first line") || !strings.Contains(body, "second line") {
3229 t.Fatalf("finished log = %d, body:\n%s", code, body)
3230 }
3231 resp, err = http.Get(f.http.URL + "/" + f.repo + "/runs/" + done.ID + "/log?after=0")
3232 if err != nil {
3233 t.Fatal(err)
3234 }
3235 if final := resp.Header.Get("X-Log-Final"); final != "1" {
3236 t.Fatalf("a finished attempt's log is not marked final")
3237 }
3238 resp.Body.Close()
3239}
3240
3241// A workflow can name a definition under .vole/images. The queue pins the
3242// definition, the runner builds or finds the digest tag, and the record says which
3243// definition ran.
3244func TestWorkflowUsesAFileImage(t *testing.T) {
3245 t.Parallel()
3246 f := newFixtureWith(t, func(c *config.Config) { c.LeaseSeconds = 2 })
3247 f.publish(f.repo)
3248
3249 definition := "version: 1\nfrom: alpine:3.22\nrun:\n - echo image-built\n"
3250 work := workRepo(t, map[string]string{
3251 ".vole/workflows/ci.yaml": `version: 1
3252jobs:
3253 test:
3254 image-file: go
3255 steps:
3256 - name: say hello
3257 run: echo "hello from a file image"
3258`,
3259 ".vole/images/go.yaml": definition,
3260 })
3261 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
3262 t.Fatalf("push: %s", errs)
3263 }
3264
3265 queued := f.waitForRun(t, 5*time.Second, func(r *runs.Run) bool { return r.Status == runs.StatusQueued })
3266 wantSHA := ci.ImageDigest(definition)
3267 if queued.Image != "file:go" || queued.ImageFile != "go" || queued.ImageSHA != wantSHA ||
3268 queued.ImageDefinition != definition {
3269 t.Fatalf("queued image = %+v, want file:go with digest %s", queued, wantSHA)
3270 }
3271
3272 cmd := exec.Command(binPath, "runner", "--remote", f.sshBase(), "--once", "--dir", t.TempDir())
3273 cmd.Env = f.ciEnv()
3274 if out, err := cmd.CombinedOutput(); err != nil {
3275 t.Fatalf("runner: %v\n%s", err, out)
3276 }
3277
3278 done := f.waitForRun(t, 10*time.Second, func(r *runs.Run) bool {
3279 return r.Status != runs.StatusQueued && r.Status != runs.StatusRunning
3280 })
3281 if done.Status != runs.StatusSuccess || done.Image != "file:go" || done.ImageFile != "go" ||
3282 done.ImageSHA != wantSHA {
3283 t.Fatalf("finished run = %+v", done)
3284 }
3285 rec, err := f.srv.Runs.Attempt(done.ID, done.Attempts)
3286 if err != nil {
3287 t.Fatal(err)
3288 }
3289 if rec == nil || rec.Image != "file:go" || rec.ImageFile != "go" || rec.ImageSHA != wantSHA {
3290 t.Fatalf("record = %+v", rec)
3291 }
3292}
3293
3294// A runner started with --repos for something its account may not run refuses at
3295// startup. That is the whole point of checking it before the loop: a filter that
3296// matches nothing looks exactly like a quiet instance, so a runner that started
3297// happily and idled would be indistinguishable from a healthy one.
3298func TestARepoFilterOutsideTheGrantIsRefusedAtStartup(t *testing.T) {
3299 t.Parallel()
3300 f := newFixture(t)
3301 f.publish(f.repo)
3302
3303 cmd := exec.Command(binPath, "runner", "--remote", f.sshBase(), "--once",
3304 "--dir", t.TempDir(), "--repos", "some-other-repo")
3305 cmd.Env = f.ciEnv()
3306 out, err := cmd.CombinedOutput()
3307 if err == nil {
3308 t.Fatalf("a runner whose --repos matches nothing started anyway:\n%s", out)
3309 }
3310 for _, want := range []string{"some-other-repo", "matches nothing", f.repo} {
3311 if !strings.Contains(string(out), want) {
3312 t.Fatalf("the refusal does not mention %q:\n%s", want, out)
3313 }
3314 }
3315}
3316
3317// TestRunRecordLivesInTheDatabase is the promise after the move: a finished attempt
3318// is stored in runs.db, and the metadata branch carries no run record. CI history is
3319// instance-local; a clone does not carry it.
3320func TestRunRecordLivesInTheDatabase(t *testing.T) {
3321 t.Parallel()
3322 f := newFixtureWith(t, func(c *config.Config) { c.LeaseSeconds = 2 })
3323 f.publish(f.repo)
3324
3325 work := workRepo(t, workflowFile(`version: 1
3326jobs:
3327 test:
3328 image: alpine:3.22
3329 steps:
3330 - name: say hello
3331 run: echo "recorded in the database"
3332`))
3333 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
3334 t.Fatalf("push: %s", errs)
3335 }
3336 f.waitForRun(t, 5*time.Second, func(r *runs.Run) bool { return r.Status == runs.StatusQueued })
3337 f.runOnce(t)
3338 done := f.terminalRun(t)
3339 if done.Status != runs.StatusSuccess {
3340 t.Fatalf("run status = %s", done.Status)
3341 }
3342
3343 rec, err := f.srv.Runs.Attempt(done.ID, done.Attempts)
3344 if err != nil {
3345 t.Fatalf("reading the attempt: %v", err)
3346 }
3347 if rec == nil {
3348 t.Fatal("a finished run left no attempt in runs.db")
3349 }
3350 if rec.Signer == "" || rec.SHA != done.SHA || rec.Attempt != done.Attempts {
3351 t.Fatalf("attempt = %+v", rec)
3352 }
3353 if len(rec.Steps) != 1 || rec.Steps[0].Name != "say hello" {
3354 t.Fatalf("the attempt does not describe the step: %+v", rec.Steps)
3355 }
3356
3357 // The branch is not a copy. A clone gets the code and the issue metadata, not
3358 // the CI history.
3359 m, err := f.srv.Repos.Open(f.repo)
3360 if err != nil {
3361 t.Fatal(err)
3362 }
3363 if tip, err := m.Git().RevParseRaw(metadata.Ref); err != nil {
3364 t.Fatal(err)
3365 } else if tip != "" {
3366 entries, err := m.Git().LsTreeR(tip)
3367 if err != nil {
3368 t.Fatal(err)
3369 }
3370 for _, e := range entries {
3371 if strings.HasPrefix(e.Name, "runs/") {
3372 t.Fatalf("the run record is still on %s: %s", metadata.Ref, e.Name)
3373 }
3374 }
3375 }
3376
3377 // The page reads the attempt, not a tree walk.
3378 if _, page := f.get("/" + f.repo + "/runs/" + done.ID); !strings.Contains(page, "say hello") {
3379 t.Fatalf("the run page does not show the stored step:\n%s", page)
3380 }
3381}
3382
3383// TestKilledRunnerIsRequeued is the other half of the contract: killing a runner
3384// mid-job returns the job to the queue and another runner finishes it.
3385func TestKilledRunnerIsRequeued(t *testing.T) {
3386 t.Parallel()
3387 if testing.Short() {
3388 t.Skip("waits for a lease to expire and a janitor tick; slow under -race")
3389 }
3390 f := newFixtureWith(t, func(c *config.Config) { c.LeaseSeconds = 2 })
3391 f.publish(f.repo)
3392
3393 work := workRepo(t, workflowFile(`version: 1
3394jobs:
3395 slow:
3396 image: alpine:3.22
3397 steps:
3398 - name: hold the lease
3399 # Long enough that the runner is certainly still working when it is killed
3400 # (the kill happens as soon as the lease is visible, so the margin is the whole
3401 # step), and no longer: this same step also runs on the retry, so every second
3402 # here is a second added to the test.
3403 run: sleep 3
3404`))
3405 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
3406 t.Fatalf("push: %s", errs)
3407 }
3408
3409 first := exec.Command(binPath, "runner", "--remote", f.sshBase(), "--dir", t.TempDir())
3410 first.Env = f.ciEnv()
3411 first.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
3412 first.Stderr = io.Discard
3413 if err := first.Start(); err != nil {
3414 t.Fatal(err)
3415 }
3416 // It must actually own the job before we kill it, or the test proves nothing.
3417 f.waitForRun(t, 20*time.Second, func(r *runs.Run) bool { return r.Status == runs.StatusRunning })
3418 _ = syscall.Kill(-first.Process.Pid, syscall.SIGKILL)
3419 _, _ = first.Process.Wait()
3420
3421 // The lease expires and the janitor puts the job back.
3422 f.waitForRun(t, 30*time.Second, func(r *runs.Run) bool { return r.Status == runs.StatusQueued })
3423
3424 second := exec.Command(binPath, "runner", "--remote", f.sshBase(), "--once", "--dir", t.TempDir())
3425 second.Env = f.ciEnv()
3426 second.Stderr = io.Discard
3427 if err := second.Start(); err != nil {
3428 t.Fatal(err)
3429 }
3430 defer func() { _ = second.Process.Kill(); _, _ = second.Process.Wait() }()
3431
3432 done := f.waitForRun(t, 60*time.Second, func(r *runs.Run) bool {
3433 return r.Status == runs.StatusSuccess || r.Status == runs.StatusFailed
3434 })
3435 if done.Status != runs.StatusSuccess {
3436 _, body := f.get("/" + f.repo + "/runs/" + done.ID + "/log")
3437 t.Fatalf("the retried run did not succeed: %s (exit %d)\n%s",
3438 done.Status, done.ExitCode, body)
3439 }
3440 if done.Attempts < 2 {
3441 t.Fatalf("attempts = %d; the job was not actually handed to a second runner", done.Attempts)
3442 }
3443}
3444
3445// TestQueuedRunUsesTheWorkflowItWasQueuedFrom: editing a workflow after a push
3446// must not change what the already-queued run executes. The step list is pinned
3447// when the run is created, from the blob that was pushed.
3448//
3449// The first run is held inside its step, so the second push lands while it is running.
3450// A run still waiting when a newer tip arrives is canceled rather than started — the
3451// pinning question only exists for a run that got as far as executing.
3452func TestQueuedRunUsesTheWorkflowItWasQueuedFrom(t *testing.T) {
3453 t.Parallel()
3454 f := newFixtureWith(t, func(c *config.Config) { c.LeaseSeconds = 5 })
3455 f.publish(f.repo)
3456
3457 sentinel := filepath.Join(t.TempDir(), "release")
3458 work := workRepo(t, workflowFile(`version: 1
3459jobs:
3460 test:
3461 image: alpine:3.22
3462 steps:
3463 - run: while [ ! -f `+sentinel+` ]; do sleep 0.1; done; echo first-version
3464`))
3465 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
3466 t.Fatalf("push v1: %s", errs)
3467 }
3468 firstSHA := mustSHA(t, work)
3469 first := f.waitForRun(t, 5*time.Second, func(r *runs.Run) bool { return r.SHA == firstSHA })
3470
3471 // Lease the v1 run and hold it in its step.
3472 held := exec.Command(binPath, "runner", "--remote", f.sshBase(), "--once", "--dir", t.TempDir())
3473 held.Env = f.ciEnv()
3474 held.Stderr = io.Discard
3475 if err := held.Start(); err != nil {
3476 t.Fatal(err)
3477 }
3478 t.Cleanup(func() { _ = held.Process.Kill(); _ = held.Wait() })
3479 f.waitForRun(t, 15*time.Second, func(r *runs.Run) bool {
3480 return r.ID == first.ID && r.Status == runs.StatusRunning
3481 })
3482
3483 // Second push rewrites the workflow, while the first run is executing.
3484 if err := os.WriteFile(filepath.Join(work, ".vole/workflows/ci.yaml"),
3485 []byte("version: 1\njobs:\n test:\n image: alpine:3.22\n steps:\n - run: echo second-version\n"), 0o644); err != nil {
3486 t.Fatal(err)
3487 }
3488 if _, errs, err := runGit(t, work, "add", "-A"); err != nil {
3489 t.Fatalf("add: %s", errs)
3490 }
3491 if _, errs, err := runGit(t, work, "commit", "-qm", "ci v2"); err != nil {
3492 t.Fatalf("commit: %s", errs)
3493 }
3494 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
3495 t.Fatalf("push v2: %s", errs)
3496 }
3497 secondSHA := mustSHA(t, work)
3498 f.waitForRun(t, 5*time.Second, func(r *runs.Run) bool { return r.SHA == secondSHA })
3499
3500 // Release the held build, then run the job queued from v2.
3501 if err := os.WriteFile(sentinel, []byte("go\n"), 0o644); err != nil {
3502 t.Fatal(err)
3503 }
3504 first = f.waitForRun(t, 30*time.Second, func(r *runs.Run) bool {
3505 return r.SHA == firstSHA && r.Status != runs.StatusQueued && r.Status != runs.StatusRunning
3506 })
3507 f.runOnce(t)
3508 second := f.waitForRun(t, 30*time.Second, func(r *runs.Run) bool {
3509 return r.SHA == secondSHA && r.Status != runs.StatusQueued && r.Status != runs.StatusRunning
3510 })
3511 // A log is an object the runner uploaded when its attempt ended, so read it the
3512 // way the web does rather than expecting text in the queue row.
3513 logOf := func(r *runs.Run) string {
3514 t.Helper()
3515 code, body := f.get("/" + f.repo + "/runs/" + r.ID + "/log")
3516 if code != 200 {
3517 t.Fatalf("log of run %s = %d", r.ID, code)
3518 }
3519 return body
3520 }
3521 firstLog, secondLog := logOf(first), logOf(second)
3522 if !strings.Contains(firstLog, "first-version") || strings.Contains(firstLog, "second-version") {
3523 t.Fatalf("the run queued from v1 executed something else:\n%s", firstLog)
3524 }
3525 if !strings.Contains(secondLog, "second-version") {
3526 t.Fatalf("the run queued from v2 did not use its own workflow:\n%s", secondLog)
3527 }
3528}
3529
3530// runOnce executes one queued job with a real agent.
3531func (f *fixture) runOnce(t *testing.T) { f.runOnceAs(t, f.keyPath) }
3532
3533// runOnceAs runs one CI job as the account the key belongs to, which is how a test
3534// gets a result reported by somebody other than the instance's admin.
3535func (f *fixture) runOnceAs(t *testing.T, keyPath string) {
3536 t.Helper()
3537 cmd := exec.Command(binPath, "runner", "--remote", f.sshBase(), "--once", "--dir", t.TempDir())
3538 cmd.Env = f.ciEnvFor(keyPath)
3539 if out, err := cmd.CombinedOutput(); err != nil {
3540 t.Fatalf("runner: %v\n%s", err, out)
3541 }
3542}
3543
3544// verifyCommit checks a record's commit the way a reader holding the roster would: the
3545// signature has to verify under a key of the account that reported the result, and the
3546// principal git names has to be that account. It is the same check `pre-receive` makes,
3547// run from the reading side.
3548func (f *fixture) cfgFile(t *testing.T) string {
3549 t.Helper()
3550 path := filepath.Join(t.TempDir(), "vole.toml")
3551 if err := os.WriteFile(path, []byte(fmt.Sprintf("data_dir = %q\n", f.cfg.DataDir)), 0o644); err != nil {
3552 t.Fatal(err)
3553 }
3554 return path
3555}
3556
3557// openAndApprove files a request for head and gets it approved.
3558func (f *fixture) openAndApprove(t *testing.T, head, author string) {
3559 t.Helper()
3560 meta := f.newMetaRepo(t)
3561 if err := meta.apply(map[string]string{
3562 "changes/" + reqID + "/change.md": requestMeta("main", head, author),
3563 }, "request"); err != nil {
3564 t.Fatalf("request: %v", err)
3565 }
3566 approver := f.newMetaRepo(t)
3567 if err := approver.apply(map[string]string{
3568 "changes/" + reqID + "/approvals/" + f.ownerID + ".md": approvalFile("main", head),
3569 }, "approve"); err != nil {
3570 t.Fatalf("approve: %v", err)
3571 }
3572}
3573
3574// TestProtectedBranchRequiresGreenChecks: approvals say a person read the change,
3575// checks say it works, and a protected branch wants both.
3576func TestProtectedBranchRequiresGreenChecks(t *testing.T) {
3577 t.Parallel()
3578 f := newFixtureWith(t, func(c *config.Config) { c.LeaseSeconds = 2 })
3579 writerKey := f.addWriter(t)
3580 f.publish(f.repo)
3581 if _, errs, err := f.git(workRepo(t, map[string]string{"a.md": "a\n"}),
3582 "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
3583 t.Fatalf("initial main: %s", errs)
3584 }
3585 m := mustRepo(t, f)
3586 a, _ := m.ACL()
3587 a.Protected = []string{"refs/heads/main"}
3588 if err := m.SetACL(a, "test"); err != nil {
3589 t.Fatal(err)
3590 }
3591
3592 feature, head := f.cloneWithChange(t, workflowFile("version: 1\njobs:\n test:\n image: alpine:3.22\n steps:\n - run: echo ok\n"), "ci")
3593 if _, errs, err := f.gitWithKey(writerKey, feature, "push", f.sshURL(f.repo), "HEAD:refs/heads/feature"); err != nil {
3594 t.Fatalf("feature: %s", errs)
3595 }
3596 f.waitForRun(t, 10*time.Second, func(r *runs.Run) bool { return r.SHA == head })
3597 f.runOnce(t)
3598 f.waitForRun(t, 20*time.Second, func(r *runs.Run) bool {
3599 return r.SHA == head && r.Status == runs.StatusSuccess
3600 })
3601
3602 f.openAndApprove(t, head, "wilbur")
3603 if _, errs, err := f.gitWithKey(writerKey, feature, "push", f.sshURL(f.repo), "HEAD:refs/heads/main"); err != nil {
3604 t.Fatalf("approved and green push was refused: %s", errs)
3605 }
3606}
3607
3608// TestOnlyAllowedRunnersGreenLightABranch: a green run counts only if the host let
3609// the account that reported it. A required check is a claim about a commit, and
3610// without this rule any writer could satisfy it by running CI on their own machine.
3611func TestOnlyAllowedRunnersGreenLightABranch(t *testing.T) {
3612 t.Parallel()
3613 f := newFixtureWith(t, func(c *config.Config) { c.LeaseSeconds = 2 })
3614 writerKey := f.addWriter(t)
3615 wilbur := f.accountName("wilbur")
3616 f.publish(f.repo)
3617 if _, errs, err := f.git(workRepo(t, map[string]string{"a.md": "a\n"}),
3618 "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
3619 t.Fatalf("initial main: %s", errs)
3620 }
3621 m := mustRepo(t, f)
3622 a, _ := m.ACL()
3623 a.Protected = []string{"refs/heads/main"}
3624 if err := m.SetACL(a, "test"); err != nil {
3625 t.Fatal(err)
3626 }
3627
3628 feature, head := f.cloneWithChange(t, workflowFile("version: 1\njobs:\n test:\n image: alpine:3.22\n steps:\n - run: echo ok\n"), "ci")
3629 if _, errs, err := f.gitWithKey(writerKey, feature, "push", f.sshURL(f.repo), "HEAD:refs/heads/feature"); err != nil {
3630 t.Fatalf("feature: %s", errs)
3631 }
3632 f.waitForRun(t, 10*time.Second, func(r *runs.Run) bool { return r.SHA == head })
3633
3634 // Not a runner yet: the job is not even leased, because running a job executes
3635 // the repository's code. The push stays queued rather than silently green.
3636 f.runOnceAs(t, writerKey)
3637 if got := f.waitForRun(t, 10*time.Second, func(r *runs.Run) bool { return r.SHA == head }); got.Status != runs.StatusQueued {
3638 t.Fatalf("a non-runner was given work: %+v", got)
3639 }
3640
3641 // Allow the account, through the command a host admin would use.
3642 out, errs, err := f.cli("", "acl", "runners", "add", f.repo, "wilbur", "--config", f.cfgFile(t))
3643 if err != nil {
3644 t.Fatalf("acl runners: %v\n%s", err, errs)
3645 }
3646 if !strings.Contains(out, "wilbur") {
3647 t.Fatalf("acl runners did not name the account:\n%s", out)
3648 }
3649 f.runOnceAs(t, writerKey)
3650 done := f.waitForRun(t, 20*time.Second, func(r *runs.Run) bool {
3651 return r.SHA == head && r.Status == runs.StatusSuccess
3652 })
3653 att, err := f.srv.Runs.Attestation(done.ID)
3654 if err != nil || att == nil || att.Signer != wilbur {
3655 t.Fatalf("the run is not attributed to wilbur: %+v, %v", att, err)
3656 }
3657
3658 // Revoking trust is what the rule is for: the same green result stops counting,
3659 // and the refusal says who reported it and what to do about it.
3660 a, _ = m.ACL()
3661 a.SetRunners(nil)
3662 if err := m.SetACL(a, "test"); err != nil {
3663 t.Fatal(err)
3664 }
3665 f.openAndApprove(t, head, "wilbur")
3666 _, errs, err = f.gitWithKey(writerKey, feature, "push", f.sshURL(f.repo), "HEAD:refs/heads/main")
3667 if err == nil {
3668 t.Fatal("a result from a revoked runner landed on a protected branch")
3669 }
3670 if !strings.Contains(errs, "is not a runner for this repository") || !strings.Contains(errs, "acl runners") {
3671 t.Fatalf("the refusal does not say why:\n%s", errs)
3672 }
3673 // The page explains the same thing where somebody would look for it.
3674 if _, page := f.get("/" + f.repo + "/runs/" + done.ID); !strings.Contains(page, "does not count as a check") {
3675 t.Fatalf("the run page does not say the result does not count:\n%s", page)
3676 }
3677
3678 // Allowing it again is enough: nothing else changed, and nothing was lost.
3679 if _, errs, err := f.cli("", "acl", "runners", "add", f.repo, "wilbur", "--config", f.cfgFile(t)); err != nil {
3680 t.Fatalf("acl runners: %v\n%s", err, errs)
3681 }
3682 if _, errs, err := f.gitWithKey(writerKey, feature, "push", f.sshURL(f.repo), "HEAD:refs/heads/main"); err != nil {
3683 t.Fatalf("an allowed runner's result was refused: %s", errs)
3684 }
3685}
3686
3687// TestACLRunnersEditsTheList: add, set, remove and clear are separate verbs,
3688// because replacing a list to add a sixth name is how a list gets shortened by mistake.
3689func TestACLRunnersEditsTheList(t *testing.T) {
3690 t.Parallel()
3691 f := newFixture(t)
3692 f.addWriter(t)
3693 f.publish(f.repo)
3694 cfg := f.cfgFile(t)
3695 runners := func() []string {
3696 t.Helper()
3697 a, err := mustRepo(t, f).ACL()
3698 if err != nil {
3699 t.Fatal(err)
3700 }
3701 return a.Runners
3702 }
3703
3704 if out, errs, err := f.cli("", "acl", "runners", "add", f.repo, "wilbur", "--config", cfg); err != nil {
3705 t.Fatalf("add: %v\n%s", err, errs)
3706 } else if !strings.Contains(out, "wilbur") {
3707 t.Fatalf("add did not report the account:\n%s", out)
3708 }
3709 if got := runners(); len(got) != 1 || got[0] != f.accountName("wilbur") {
3710 t.Fatalf("runners after add wilbur = %#v", got)
3711 }
3712
3713 // Adding again is a no-op rather than a duplicate in the file.
3714 if _, errs, err := f.cli("", "acl", "runners", "add", f.repo, "wilbur", "--config", cfg); err != nil {
3715 t.Fatalf("second add: %v\n%s", err, errs)
3716 }
3717 if got := runners(); len(got) != 1 {
3718 t.Fatalf("a repeated add duplicated the account: %#v", got)
3719 }
3720
3721 // The owner is an admin, so they already count; naming them is legal but adds
3722 // nothing, and an empty list is rendered as what it means rather than as "none".
3723 if out, _, err := f.cli("", "acl", "runners", "list", f.repo, "--config", cfg); err != nil {
3724 t.Fatalf("show: %v", err)
3725 } else if !strings.Contains(out, "wilbur") {
3726 t.Fatalf("show did not list the runner:\n%s", out)
3727 }
3728
3729 // set replaces the list: a repo with one runner stays a repo with one.
3730 if _, errs, err := f.cli("", "acl", "runners", "set", f.repo, "keenan", "--config", cfg); err != nil {
3731 t.Fatalf("replace: %v\n%s", err, errs)
3732 }
3733 if got := runners(); len(got) != 1 || got[0] != f.ownerID {
3734 t.Fatalf("runners after replacement = %#v", got)
3735 }
3736
3737 // Removing somebody who is not on the list is an error, the way `git config
3738 // --unset` refuses a key that is not set: a revocation that never happened
3739 // should not look like one that did.
3740 if _, errs, err := f.cli("", "acl", "runners", "remove", f.repo, "wilbur", "--config", cfg); err == nil {
3741 t.Fatalf("removing a non-runner was accepted:\n%s", errs)
3742 } else if !strings.Contains(errs, "is not a runner for") {
3743 t.Fatalf("the refusal does not say what happened:\n%s", errs)
3744 }
3745 if got := runners(); len(got) != 1 {
3746 t.Fatalf("a refused removal changed the list: %#v", got)
3747 }
3748
3749 // Removal is a subcommand, not a whole-list replacement.
3750 if _, errs, err := f.cli("", "acl", "runners", "remove", f.repo, "keenan", "--config", cfg); err != nil {
3751 t.Fatalf("remove: %v\n%s", err, errs)
3752 }
3753 if got := runners(); len(got) != 0 {
3754 t.Fatalf("runners after remove = %#v", got)
3755 }
3756
3757 // And clear returns to admins-only, which is where a new repository starts.
3758 if _, errs, err := f.cli("", "acl", "runners", "add", f.repo, "wilbur", "--config", cfg); err != nil {
3759 t.Fatalf("re-add: %v\n%s", err, errs)
3760 }
3761 if _, errs, err := f.cli("", "acl", "runners", "clear", f.repo, "--config", cfg); err != nil {
3762 t.Fatalf("clear: %v\n%s", err, errs)
3763 }
3764 if got := runners(); len(got) != 0 {
3765 t.Fatalf("runners after clear = %#v", got)
3766 }
3767 // Modes are subcommands now; the old flag form is gone rather than accepted
3768 // silently with different semantics.
3769 if _, errs, err := f.cli("", "acl", "runners", "--add", f.repo, "wilbur", "--config", cfg); err == nil {
3770 t.Fatalf("the old --add form was accepted:\n%s", errs)
3771 }
3772
3773 // An unknown account is refused rather than written as a typo that silently
3774 // permits nobody.
3775 _, errs, err := f.cli("", "acl", "runners", "add", f.repo, "nobody", "--config", cfg)
3776 if err == nil {
3777 t.Fatalf("an unknown account was accepted:\n%s", errs)
3778 }
3779}
3780
3781// TestACLCommandsNameTheFieldTheyChange: each mutation says what it changes,
3782// and list edits have explicit verbs instead of a mode flag or bare names.
3783func TestACLCommandsNameTheFieldTheyChange(t *testing.T) {
3784 t.Parallel()
3785 f := newFixture(t)
3786 f.addWriter(t)
3787 f.publish(f.repo)
3788 cfg := f.cfgFile(t)
3789 cli := func(args ...string) (string, string) {
3790 t.Helper()
3791 args = append(args, "--config", cfg)
3792 out, errs, err := f.cli("", args...)
3793 if err != nil {
3794 t.Fatalf("%v: %v\n%s", args, err, errs)
3795 }
3796 return out, errs
3797 }
3798 acl := func() *access.ACL {
3799 t.Helper()
3800 a, err := mustRepo(t, f).ACL()
3801 if err != nil {
3802 t.Fatal(err)
3803 }
3804 return a
3805 }
3806
3807 cli("acl", "set-role", f.repo, "wilbur", "read")
3808 if got := acl().Entries[f.accountName("wilbur")]; got != "read" {
3809 t.Fatalf("role after set-role = %q", got)
3810 }
3811
3812 cli("acl", "set-default-role", f.repo, "contribute")
3813 if got := acl().Default; got != "contribute" {
3814 t.Fatalf("default after set-default-role = %q", got)
3815 }
3816
3817 cli("acl", "set-approvals", f.repo, "2")
3818 if got := acl().RequiredApprovals(); got != 2 {
3819 t.Fatalf("approvals after set-approvals = %d", got)
3820 }
3821
3822 cli("acl", "checks", "disable", f.repo)
3823 if acl().RequiresChecks() {
3824 t.Fatal("checks disable left checks required")
3825 }
3826 cli("acl", "checks", "enable", f.repo)
3827 if !acl().RequiresChecks() {
3828 t.Fatal("checks enable left checks optional")
3829 }
3830
3831 cli("acl", "protect", "add", f.repo, "refs/heads/main", "refs/heads/release")
3832 if got := strings.Join(acl().Protected, ","); got != "refs/heads/main,refs/heads/release" {
3833 t.Fatalf("protected after add = %q", got)
3834 }
3835 cli("acl", "protect", "remove", f.repo, "refs/heads/main")
3836 if got := strings.Join(acl().Protected, ","); got != "refs/heads/release" {
3837 t.Fatalf("protected after remove = %q", got)
3838 }
3839 cli("acl", "protect", "set", f.repo, "refs/heads/only")
3840 if got := strings.Join(acl().Protected, ","); got != "refs/heads/only" {
3841 t.Fatalf("protected after set = %q", got)
3842 }
3843 if out, _ := cli("acl", "protect", "list", f.repo); !strings.Contains(out, "refs/heads/only") {
3844 t.Fatalf("protect list did not show the list:\n%s", out)
3845 }
3846 cli("acl", "protect", "clear", f.repo)
3847 if got := acl().Protected; len(got) != 0 {
3848 t.Fatalf("protected after clear = %#v", got)
3849 }
3850
3851 // The old positional command is gone rather than silently mapped.
3852 if _, errs, err := f.cli("", "acl", "set", f.repo, "wilbur", "read", "--config", cfg); err == nil {
3853 t.Fatalf("old acl set form was accepted:\n%s", errs)
3854 }
3855}
3856
3857// TestACLProtectRefusesAPatternThatCannotMatch: protection is configured once and
3858// assumed afterwards, so a typo in the category must fail at the command rather
3859// than leave the branch open behind a line that reads like a correct one.
3860func TestACLProtectRefusesAPatternThatCannotMatch(t *testing.T) {
3861 t.Parallel()
3862 f := newFixture(t)
3863 f.publish(f.repo)
3864 cfg := f.cfgFile(t)
3865 protected := func() string {
3866 t.Helper()
3867 a, err := mustRepo(t, f).ACL()
3868 if err != nil {
3869 t.Fatal(err)
3870 }
3871 return strings.Join(a.Protected, ",")
3872 }
3873
3874 _, errs, err := f.cli("", "acl", "protect", "add", f.repo, "refs/head/main", "--config", cfg)
3875 if err == nil {
3876 t.Fatalf("a pattern that cannot match was stored:\n%s", errs)
3877 }
3878 if !strings.Contains(errs, `refs/heads/main`) {
3879 t.Fatalf("the refusal does not name the fix:\n%s", errs)
3880 }
3881 if got := protected(); got != "" {
3882 t.Fatalf("the refusal stored %q", got)
3883 }
3884
3885 // One bad pattern in a list refuses the whole list rather than storing part
3886 // of it, which would read as a complete rule set.
3887 if _, errs, err := f.cli("", "acl", "protect", "add", f.repo, "refs/heads/main", "refs/heads/ma in", "--config", cfg); err == nil {
3888 t.Fatalf("a list with a bad pattern was accepted:\n%s", errs)
3889 }
3890 if got := protected(); got != "" {
3891 t.Fatalf("a refused list stored part of itself: %q", got)
3892 }
3893 if _, errs, err := f.cli("", "acl", "protect", "set", f.repo, "heads/main", "--config", cfg); err == nil {
3894 t.Fatalf("protect set stored a pattern that cannot match:\n%s", errs)
3895 }
3896
3897 for _, pattern := range []string{"refs/heads/main", "refs/heads/release/*"} {
3898 if _, errs, err := f.cli("", "acl", "protect", "add", f.repo, pattern, "--config", cfg); err != nil {
3899 t.Fatalf("%s: %v\n%s", pattern, err, errs)
3900 }
3901 }
3902 if got, want := protected(), "refs/heads/main,refs/heads/release/*"; got != want {
3903 t.Fatalf("protected = %q, want %q", got, want)
3904 }
3905 out, _, err := f.cli("", "acl", "show", f.repo, "--config", cfg)
3906 if err != nil || !strings.Contains(out, "refs/heads/release/*") {
3907 t.Fatalf("acl show does not round-trip the pattern:\n%s", out)
3908 }
3909}
3910
3911// TestFailingCheckBlocksTheMerge: a green approval does not outvote a red build.
3912func TestFailingCheckBlocksTheMerge(t *testing.T) {
3913 t.Parallel()
3914 f := newFixtureWith(t, func(c *config.Config) { c.LeaseSeconds = 2 })
3915 writerKey := f.addWriter(t)
3916 f.publish(f.repo)
3917 if _, errs, err := f.git(workRepo(t, map[string]string{"a.md": "a\n"}),
3918 "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
3919 t.Fatalf("initial main: %s", errs)
3920 }
3921 m := mustRepo(t, f)
3922 a, _ := m.ACL()
3923 a.Protected = []string{"refs/heads/main"}
3924 if err := m.SetACL(a, "test"); err != nil {
3925 t.Fatal(err)
3926 }
3927
3928 feature, head := f.cloneWithChange(t, workflowFile("version: 1\njobs:\n test:\n image: alpine:3.22\n steps:\n - run: exit 3\n"), "ci")
3929 if _, errs, err := f.gitWithKey(writerKey, feature, "push", f.sshURL(f.repo), "HEAD:refs/heads/feature"); err != nil {
3930 t.Fatalf("feature: %s", errs)
3931 }
3932 f.waitForRun(t, 10*time.Second, func(r *runs.Run) bool { return r.SHA == head })
3933 f.runOnce(t)
3934 f.waitForRun(t, 20*time.Second, func(r *runs.Run) bool {
3935 return r.SHA == head && r.Status == runs.StatusFailed
3936 })
3937
3938 f.openAndApprove(t, head, "wilbur")
3939 _, errs, err := f.gitWithKey(writerKey, feature, "push", f.sshURL(f.repo), "HEAD:refs/heads/main")
3940 if err == nil {
3941 t.Fatal("a commit with a failing check landed on a protected branch")
3942 }
3943 if !strings.Contains(errs, "failed for") {
3944 t.Fatalf("rejection does not say the check failed:\n%s", errs)
3945 }
3946}
3947
3948// TestMissingRunBlocksWithAHint: a workflow that only runs on main can never
3949// produce a result for a feature branch, and the message has to say so.
3950func TestMissingRunBlocksWithAHint(t *testing.T) {
3951 t.Parallel()
3952 f := newFixtureWith(t, func(c *config.Config) { c.LeaseSeconds = 2 })
3953 writerKey := f.addWriter(t)
3954 f.publish(f.repo)
3955 if _, errs, err := f.git(workRepo(t, map[string]string{"a.md": "a\n"}),
3956 "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
3957 t.Fatalf("initial main: %s", errs)
3958 }
3959 m := mustRepo(t, f)
3960 a, _ := m.ACL()
3961 a.Protected = []string{"refs/heads/main"}
3962 if err := m.SetACL(a, "test"); err != nil {
3963 t.Fatal(err)
3964 }
3965
3966 // This workflow filters to main, so pushing a feature branch queues nothing.
3967 feature, head := f.cloneWithChange(t, workflowFile("version: 1\non:\n push:\n branches: [main]\njobs:\n test:\n image: alpine:3.22\n steps:\n - run: echo ok\n"), "ci")
3968 if _, errs, err := f.gitWithKey(writerKey, feature, "push", f.sshURL(f.repo), "HEAD:refs/heads/feature"); err != nil {
3969 t.Fatalf("feature: %s", errs)
3970 }
3971 f.openAndApprove(t, head, "wilbur")
3972 _, errs, err := f.gitWithKey(writerKey, feature, "push", f.sshURL(f.repo), "HEAD:refs/heads/main")
3973 if err == nil {
3974 t.Fatal("a commit with no CI result landed on a protected branch")
3975 }
3976 if !strings.Contains(errs, "has no run for") || !strings.Contains(errs, "filters on [main]") {
3977 t.Fatalf("rejection does not explain the branch filter:\n%s", errs)
3978 }
3979}
3980
3981func mustSHA(t *testing.T, dir string) string {
3982 t.Helper()
3983 out, _, err := runGit(t, dir, "rev-parse", "HEAD")
3984 if err != nil {
3985 t.Fatal(err)
3986 }
3987 return strings.TrimSpace(out)
3988}
3989
3990func mustRepo(t *testing.T, f *fixture) *repo.Meta {
3991 t.Helper()
3992 m, err := f.srv.Repos.Open(f.repo)
3993 if err != nil {
3994 t.Fatal(err)
3995 }
3996 return m
3997}
3998
3999func repoID(t *testing.T, f *fixture) string {
4000 t.Helper()
4001 return mustRepo(t, f).ID
4002}
4003
4004func TestHealthzIsAPlainProbe(t *testing.T) {
4005 t.Parallel()
4006 f := newFixture(t)
4007 code, body := f.get("/-/healthz")
4008 if code != 200 {
4009 t.Fatalf("healthz: %d", code)
4010 }
4011 if body != "ok\n" {
4012 t.Fatalf("healthz body: %q", body)
4013 }
4014}
4015
4016// A job that names a machine with runs-on is only built by a runner that carries the
4017// label. The end-to-end shape matters here: the whole point is that nothing runs, which
4018// has to be distinguishable from "a runner started and failed".
4019func TestRunsOnWaitsForARunnerThatCarriesTheLabel(t *testing.T) {
4020 t.Parallel()
4021 f := newFixtureWith(t, func(c *config.Config) { c.LeaseSeconds = 5 })
4022 f.publish(f.repo)
4023
4024 work := workRepo(t, workflowFile(`version: 1
4025jobs:
4026 build:
4027 runs-on: [linux, fast]
4028 image: alpine:3.22
4029 steps:
4030 - run: echo "on the right machine"
4031`))
4032 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
4033 t.Fatalf("push: %s", errs)
4034 }
4035 queued := f.waitForRun(t, 5*time.Second, func(r *runs.Run) bool { return r.Status == runs.StatusQueued })
4036 if queued.Job != "build" {
4037 t.Fatalf("queued run = %+v", queued)
4038 }
4039
4040 // A one-shot runner carrying only one of the two labels must not take it.
4041 cmd := exec.Command(binPath, "runner", "--remote", f.sshBase(), "--once",
4042 "--dir", t.TempDir(), "--label", "linux")
4043 cmd.Env = f.ciEnv()
4044 if out, err := cmd.CombinedOutput(); err != nil {
4045 t.Fatalf("runner: %v\n%s", err, out)
4046 }
4047 still, err := f.srv.Runs.Get(queued.ID)
4048 if err != nil {
4049 t.Fatal(err)
4050 }
4051 if still.Status != runs.StatusQueued {
4052 t.Fatalf("a runner carrying only linux took a job that asks for linux and fast: run is %s", still.Status)
4053 }
4054
4055 // The matching runner builds it.
4056 cmd = exec.Command(binPath, "runner", "--remote", f.sshBase(), "--once",
4057 "--dir", t.TempDir(), "--label", "linux", "--label", "fast")
4058 cmd.Env = f.ciEnv()
4059 if out, err := cmd.CombinedOutput(); err != nil {
4060 t.Fatalf("runner: %v\n%s", err, out)
4061 }
4062 done := f.waitForRun(t, 10*time.Second, func(r *runs.Run) bool {
4063 return r.Status != runs.StatusQueued && r.Status != runs.StatusRunning
4064 })
4065 if done.Status != runs.StatusSuccess {
4066 t.Fatalf("run status = %s", done.Status)
4067 }
4068 if code, body := f.get("/" + f.repo + "/runs/" + done.ID + "/log"); code != 200 ||
4069 !strings.Contains(body, "on the right machine") {
4070 t.Fatalf("run log = %d:\n%s", code, body)
4071 }
4072}
4073
4074// A label the workflow validator would reject is a command-line mistake, so a runner
4075// refuses it before it dials. A machine advertising a label no workflow can ask for is
4076// a typo with no symptom.
4077func TestABadRunnerLabelIsRefusedBeforeConnecting(t *testing.T) {
4078 t.Parallel()
4079 f := newFixture(t)
4080 cmd := exec.Command(binPath, "runner", "--remote", f.sshBase(), "--once",
4081 "--dir", t.TempDir(), "--label", "Linux")
4082 cmd.Env = f.ciEnv()
4083 out, err := cmd.CombinedOutput()
4084 if err == nil {
4085 t.Fatalf("a runner accepted --label Linux:\n%s", out)
4086 }
4087 if !strings.Contains(string(out), "not a runner label") {
4088 t.Fatalf("the refusal does not explain the rule:\n%s", out)
4089 }
4090}
4091