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.

Blame · server/integration_test.go

Line attribution on main · 2184 line(s) · showing the first 1 MiB

a5f19b3c
Keenan Nemetz
18d ago
1 // End-to-end tests for the read-only-web / SSH-write architecture.
653e0cfc
Keenan Nemetz
18d ago
2 package server_test
3
4 import (
5 "bytes"
a5f19b3c
Keenan Nemetz
18d ago
6 "crypto/ed25519"
7 "crypto/rand"
8 "encoding/pem"
f9568748
Keenan Nemetz
18d ago
9 "errors"
653e0cfc
Keenan Nemetz
18d ago
10 "fmt"
11 "io"
12 "log/slog"
a5f19b3c
Keenan Nemetz
18d ago
13 "net"
653e0cfc
Keenan Nemetz
18d ago
14 "net/http"
15 "net/http/httptest"
16 "os"
17 "os/exec"
18 "path/filepath"
9c188795
Keenan Nemetz
6d ago
19 "runtime/debug"
653e0cfc
Keenan Nemetz
18d ago
20 "strings"
92b37587
Keenan Nemetz
18d ago
21 "sync"
b44cbfaf
Keenan Nemetz
17d ago
22 "syscall"
653e0cfc
Keenan Nemetz
18d ago
23 "testing"
b44cbfaf
Keenan Nemetz
17d ago
24 "time"
653e0cfc
Keenan Nemetz
18d ago
25
a5f19b3c
Keenan Nemetz
18d ago
26 "golang.org/x/crypto/ssh"
27
d37d301f
Keenan Nemetz
19h ago
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"
653e0cfc
Keenan Nemetz
18d ago
38 )
39
44530e74
Keenan Nemetz
16d ago
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.
653e0cfc
Keenan Nemetz
18d ago
42 var binPath string
43
70b70264
Keenan Nemetz
15d ago
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.
48 var fakeRuntimeDir string
49
4ac42680
Keenan Nemetz
7d ago
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.
54 var fakeRuntimeState string
55
70b70264
Keenan Nemetz
15d ago
56 const 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.
59 cmd="$1"
60 [ $# -gt 0 ] && shift
61 case "$cmd" in
62 ps)
63 exit 0
64 ;;
65 image)
66 exit 0
67 ;;
68 build)
69 exit 0
70 ;;
71 run)
4ac42680
Keenan Nemetz
7d ago
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
70b70264
Keenan Nemetz
15d ago
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
4ac42680
Keenan Nemetz
7d ago
111 container="$1"
70b70264
Keenan Nemetz
15d ago
112 [ $# -gt 0 ] && shift
113 [ $# -gt 0 ] && shift
114 [ $# -gt 0 ] && shift
115 script="$1"
4ac42680
Keenan Nemetz
7d ago
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
70b70264
Keenan Nemetz
15d ago
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 ;;
135 esac
136 `
137
9c188795
Keenan Nemetz
6d ago
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.
142 func 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
44530e74
Keenan Nemetz
16d ago
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.
653e0cfc
Keenan Nemetz
18d ago
169 func TestMain(m *testing.M) {
a5f19b3c
Keenan Nemetz
18d ago
170 dir, err := os.MkdirTemp("", "vole-int-bin-")
653e0cfc
Keenan Nemetz
18d ago
171 if err != nil {
172 fmt.Fprintln(os.Stderr, err)
173 os.Exit(1)
174 }
175 binPath = filepath.Join(dir, "vole")
9c188795
Keenan Nemetz
6d ago
176 args := append([]string{"build"}, binaryBuildArgs()...)
d37d301f
Keenan Nemetz
19h ago
177 args = append(args, "-o", binPath, "rodent.software/vole/cmd/vole")
9c188795
Keenan Nemetz
6d ago
178 build := exec.Command("go", args...)
653e0cfc
Keenan Nemetz
18d ago
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 }
70b70264
Keenan Nemetz
15d ago
184 fakeRuntimeDir = filepath.Join(dir, "runtime")
4ac42680
Keenan Nemetz
7d ago
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 }
70b70264
Keenan Nemetz
15d ago
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 }
f5456470
Keenan Nemetz
13d ago
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")
653e0cfc
Keenan Nemetz
18d ago
203 code := m.Run()
204 os.RemoveAll(dir)
205 os.Exit(code)
206 }
207
208 type fixture struct {
31df8bae
Keenan Nemetz
17d ago
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
653e0cfc
Keenan Nemetz
18d ago
219 }
220
b44cbfaf
Keenan Nemetz
17d ago
221 func 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.
225 func newFixtureWith(t *testing.T, tweak func(*config.Config)) *fixture {
653e0cfc
Keenan Nemetz
18d ago
226 t.Helper()
227 cfg, err := config.Load("")
228 if err != nil {
229 t.Fatal(err)
230 }
231 cfg.DataDir = t.TempDir()
b44cbfaf
Keenan Nemetz
17d ago
232 if tweak != nil {
233 tweak(cfg)
234 }
a5f19b3c
Keenan Nemetz
18d ago
235 if err := os.MkdirAll(cfg.ReposDir(), 0o755); err != nil {
236 t.Fatal(err)
653e0cfc
Keenan Nemetz
18d ago
237 }
427c4761
Keenan Nemetz
17d ago
238 if err := identity.Init(cfg.UsersDB()); err != nil {
a5f19b3c
Keenan Nemetz
18d ago
239 t.Fatal(err)
240 }
241 pub, priv := keypair(t, filepath.Join(cfg.DataDir, "id_test"))
427c4761
Keenan Nemetz
17d ago
242 ident, err := identity.Open(cfg.UsersDB())
653e0cfc
Keenan Nemetz
18d ago
243 if err != nil {
244 t.Fatal(err)
245 }
b1499c72
Keenan Nemetz
13d ago
246 ident.AuditPath = cfg.AuditLog()
62576fa8
Keenan Nemetz
14d ago
247 owner, err := ident.Create("keenan", pub, "test")
31df8bae
Keenan Nemetz
17d ago
248 if err != nil {
a5f19b3c
Keenan Nemetz
18d ago
249 t.Fatal(err)
653e0cfc
Keenan Nemetz
18d ago
250 }
a5f19b3c
Keenan Nemetz
18d ago
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 {
653e0cfc
Keenan Nemetz
18d ago
255 t.Fatal(err)
256 }
a5f19b3c
Keenan Nemetz
18d ago
257 ln, err := net.Listen("tcp", "127.0.0.1:0")
653e0cfc
Keenan Nemetz
18d ago
258 if err != nil {
259 t.Fatal(err)
260 }
a5f19b3c
Keenan Nemetz
18d ago
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(),
62576fa8
Keenan Nemetz
14d ago
266 keyPath: priv, keyPub: pub, repo: "demo", ownerID: owner.Name,
a5f19b3c
Keenan Nemetz
18d ago
267 }
268 t.Cleanup(func() {
269 ts.Close()
270 srv.SSH.Close()
271 srv.Close()
272 })
273
62576fa8
Keenan Nemetz
14d ago
274 m, err := srv.Repos.Create(f.repo, owner.Name, "demo repo")
a5f19b3c
Keenan Nemetz
18d ago
275 if err != nil {
653e0cfc
Keenan Nemetz
18d ago
276 t.Fatal(err)
277 }
278 if err := srv.Index.Upsert(m); err != nil {
279 t.Fatal(err)
280 }
281 return f
282 }
283
a5f19b3c
Keenan Nemetz
18d ago
284 func keypair(t *testing.T, path string) (pubLine, privPath string) {
285 t.Helper()
286 pub, priv, err := ed25519.GenerateKey(rand.Reader)
653e0cfc
Keenan Nemetz
18d ago
287 if err != nil {
a5f19b3c
Keenan Nemetz
18d ago
288 t.Fatal(err)
653e0cfc
Keenan Nemetz
18d ago
289 }
a5f19b3c
Keenan Nemetz
18d ago
290 blk, err := ssh.MarshalPrivateKey(priv, "test")
653e0cfc
Keenan Nemetz
18d ago
291 if err != nil {
a5f19b3c
Keenan Nemetz
18d ago
292 t.Fatal(err)
653e0cfc
Keenan Nemetz
18d ago
293 }
a5f19b3c
Keenan Nemetz
18d ago
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
653e0cfc
Keenan Nemetz
18d ago
302 }
303
6ce635a8
Keenan Nemetz
1d ago
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.
307 func (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
6c84d387
Keenan Nemetz
16d ago
351 // addWriter creates a second user with maintain access and returns their key path.
92b37587
Keenan Nemetz
18d ago
352 func (f *fixture) addWriter(t *testing.T) string {
353 t.Helper()
354 pub, priv := keypair(t, filepath.Join(f.cfg.DataDir, "id_writer"))
62576fa8
Keenan Nemetz
14d ago
355 writer, err := f.srv.Identity.Create("wilbur", pub, "test")
31df8bae
Keenan Nemetz
17d ago
356 if err != nil {
92b37587
Keenan Nemetz
18d ago
357 t.Fatal(err)
358 }
359 m := mustRepo(t, f)
360 a, err := m.ACL()
361 if err != nil {
362 t.Fatal(err)
363 }
31df8bae
Keenan Nemetz
17d ago
364 // ACL entries are keyed by account id, not by name.
62576fa8
Keenan Nemetz
14d ago
365 if err := a.SetEntry(writer.Name, "maintain"); err != nil {
92b37587
Keenan Nemetz
18d ago
366 t.Fatal(err)
367 }
368 if err := m.SetACL(a, "test"); err != nil {
369 t.Fatal(err)
370 }
62576fa8
Keenan Nemetz
14d ago
371 f.writerID = writer.Name
92b37587
Keenan Nemetz
18d ago
372 return priv
373 }
374
62576fa8
Keenan Nemetz
14d ago
375 // accountName checks that a fixture account is active and returns its name, the
376 // identifier ACLs and approvals store.
377 func (f *fixture) accountName(name string) string {
31df8bae
Keenan Nemetz
17d ago
378 f.t.Helper()
62576fa8
Keenan Nemetz
14d ago
379 a, err := f.srv.Identity.Find(name)
380 if err != nil || !a.Active() {
381 f.t.Fatalf("no active account %q", name)
31df8bae
Keenan Nemetz
17d ago
382 }
62576fa8
Keenan Nemetz
14d ago
383 return a.Name
31df8bae
Keenan Nemetz
17d ago
384 }
385
92b37587
Keenan Nemetz
18d ago
386 // gitWithKey is f.git with a specific identity.
387 func (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
a5f19b3c
Keenan Nemetz
18d ago
405 func (f *fixture) sshURL(name string) string {
406 return "ssh://git@" + f.sshAddr + "/" + name + ".git"
653e0cfc
Keenan Nemetz
18d ago
407 }
408
a5f19b3c
Keenan Nemetz
18d ago
409 func (f *fixture) httpURL(name string) string {
ed03b3d1
Keenan Nemetz
6h ago
410 return f.http.URL + "/" + name + ".git"
653e0cfc
Keenan Nemetz
18d ago
411 }
412
a5f19b3c
Keenan Nemetz
18d ago
413 func (f *fixture) git(dir string, args ...string) (string, string, error) {
414 f.t.Helper()
653e0cfc
Keenan Nemetz
18d ago
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",
a5f19b3c
Keenan Nemetz
18d ago
421 "GIT_SSH_COMMAND=ssh -i "+f.keyPath+" -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR",
653e0cfc
Keenan Nemetz
18d ago
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
431 func workRepo(t *testing.T, files map[string]string) string {
432 t.Helper()
433 dir := t.TempDir()
a5f19b3c
Keenan Nemetz
18d ago
434 if _, errs, err := runGit(t, dir, "init", "-q", "-b", "main"); err != nil {
435 t.Fatalf("init: %v: %s", err, errs)
653e0cfc
Keenan Nemetz
18d ago
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 }
a5f19b3c
Keenan Nemetz
18d ago
446 if _, errs, err := runGit(t, dir, "add", "-A"); err != nil {
447 t.Fatalf("add: %v: %s", err, errs)
653e0cfc
Keenan Nemetz
18d ago
448 }
a5f19b3c
Keenan Nemetz
18d ago
449 if _, errs, err := runGit(t, dir, "commit", "-qm", "initial"); err != nil {
653e0cfc
Keenan Nemetz
18d ago
450 t.Fatalf("commit: %v: %s", err, errs)
451 }
452 return dir
453 }
454
a5f19b3c
Keenan Nemetz
18d ago
455 func runGit(t *testing.T, dir string, args ...string) (string, string, error) {
653e0cfc
Keenan Nemetz
18d ago
456 t.Helper()
a5f19b3c
Keenan Nemetz
18d ago
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
653e0cfc
Keenan Nemetz
18d ago
468 }
469
a5f19b3c
Keenan Nemetz
18d ago
470 func (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()
653e0cfc
Keenan Nemetz
18d ago
477 if err != nil {
a5f19b3c
Keenan Nemetz
18d ago
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)
653e0cfc
Keenan Nemetz
18d ago
486 }
487 }
488
a5f19b3c
Keenan Nemetz
18d ago
489 func (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 }
653e0cfc
Keenan Nemetz
18d ago
499
a63475ce
Keenan Nemetz
17d ago
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.
502 func (f *fixture) cli(dir string, args ...string) (string, string, error) {
eec0e603
Keenan Nemetz
6d ago
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.
509 func (f *fixture) cliWithKey(key, dir string, args ...string) (string, string, error) {
a63475ce
Keenan Nemetz
17d ago
510 f.t.Helper()
511 cmd := exec.Command(binPath, args...)
512 cmd.Dir = dir
513 cmd.Env = append(os.Environ(),
eec0e603
Keenan Nemetz
6d ago
514 "GIT_SSH_COMMAND=ssh -i "+key+" -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR",
a63475ce
Keenan Nemetz
17d ago
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
a5f19b3c
Keenan Nemetz
18d ago
523 // TestPushOverSSHThenReadOverBothTransports is the core loop: writes need a key,
524 // reads do not.
525 func TestPushOverSSHThenReadOverBothTransports(t *testing.T) {
44530e74
Keenan Nemetz
16d ago
526 t.Parallel()
a5f19b3c
Keenan Nemetz
18d ago
527 f := newFixture(t)
528 f.publish(f.repo)
653e0cfc
Keenan Nemetz
18d ago
529
a5f19b3c
Keenan Nemetz
18d ago
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)
653e0cfc
Keenan Nemetz
18d ago
533 }
534
a5f19b3c
Keenan Nemetz
18d ago
535 // Anonymous HTTP clone must work once the repo is public.
653e0cfc
Keenan Nemetz
18d ago
536 dst := filepath.Join(t.TempDir(), "clone")
a5f19b3c
Keenan Nemetz
18d ago
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)
653e0cfc
Keenan Nemetz
18d ago
539 }
540 got, err := os.ReadFile(filepath.Join(dst, "readme.md"))
541 if err != nil {
542 t.Fatal(err)
543 }
a5f19b3c
Keenan Nemetz
18d ago
544 if string(got) != "# hello from vole\n" {
653e0cfc
Keenan Nemetz
18d ago
545 t.Fatalf("cloned content = %q", got)
546 }
547
a5f19b3c
Keenan Nemetz
18d ago
548 // The web shows it without any login.
ed03b3d1
Keenan Nemetz
6h ago
549 if code, body := f.get("/" + f.repo); code != 200 || !strings.Contains(body, "readme.md") {
f09ef39e
Keenan Nemetz
15d ago
550 t.Fatalf("overview page: %d", code)
653e0cfc
Keenan Nemetz
18d ago
551 }
ed03b3d1
Keenan Nemetz
6h ago
552 if code, body := f.get("/" + f.repo + "/blob/main/readme.md"); code != 200 || !strings.Contains(body, "hello from vole") {
a5f19b3c
Keenan Nemetz
18d ago
553 t.Fatalf("blob page: %d", code)
653e0cfc
Keenan Nemetz
18d ago
554 }
a5f19b3c
Keenan Nemetz
18d ago
555 if code, _ := f.get("/"); code != 200 {
556 t.Fatalf("index: %d", code)
653e0cfc
Keenan Nemetz
18d ago
557 }
558 }
559
a5f19b3c
Keenan Nemetz
18d ago
560 // TestPrivateRepoIsInvisible covers the visibility rule: private means absent,
561 // not forbidden.
562 func TestPrivateRepoIsInvisible(t *testing.T) {
44530e74
Keenan Nemetz
16d ago
563 t.Parallel()
a5f19b3c
Keenan Nemetz
18d ago
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)
653e0cfc
Keenan Nemetz
18d ago
568 }
569
ed03b3d1
Keenan Nemetz
6h ago
570 for _, path := range []string{"/" + f.repo, "/" + f.repo + "/blob/main/secret.md"} {
a5f19b3c
Keenan Nemetz
18d ago
571 if code, _ := f.get(path); code != http.StatusNotFound {
572 t.Fatalf("%s: status %d, want 404", path, code)
573 }
653e0cfc
Keenan Nemetz
18d ago
574 }
a5f19b3c
Keenan Nemetz
18d ago
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.
ed03b3d1
Keenan Nemetz
6h ago
582 resp, err := http.Get(f.http.URL + "/" + f.repo + ".git/info/refs?service=git-upload-pack")
a5f19b3c
Keenan Nemetz
18d ago
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 }
653e0cfc
Keenan Nemetz
18d ago
589 }
a5f19b3c
Keenan Nemetz
18d ago
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)
653e0cfc
Keenan Nemetz
18d ago
595 }
596 }
597
a5f19b3c
Keenan Nemetz
18d ago
598 func TestPushOverHTTPRefused(t *testing.T) {
44530e74
Keenan Nemetz
16d ago
599 t.Parallel()
a5f19b3c
Keenan Nemetz
18d ago
600 f := newFixture(t)
601 f.publish(f.repo)
653e0cfc
Keenan Nemetz
18d ago
602 work := workRepo(t, map[string]string{"a.txt": "a\n"})
a5f19b3c
Keenan Nemetz
18d ago
603 if _, _, err := runGit(t, work, "push", f.httpURL(f.repo), "main:refs/heads/main"); err == nil {
604 t.Fatal("push over HTTP succeeded")
653e0cfc
Keenan Nemetz
18d ago
605 }
606 }
607
a5f19b3c
Keenan Nemetz
18d ago
608 func TestRebuildIndexAfterDelete(t *testing.T) {
44530e74
Keenan Nemetz
16d ago
609 t.Parallel()
a5f19b3c
Keenan Nemetz
18d ago
610 f := newFixture(t)
611 f.publish(f.repo)
653e0cfc
Keenan Nemetz
18d ago
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
a5f19b3c
Keenan Nemetz
18d ago
620 f.srv.SSH.Close()
621 f.http.Close()
622 f.srv.Close()
653e0cfc
Keenan Nemetz
18d ago
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 }
f9568748
Keenan Nemetz
18d ago
633 cmd := exec.Command(binPath, "index", "rebuild", "--config", cfgPath)
a5f19b3c
Keenan Nemetz
18d ago
634 out, err := cmd.CombinedOutput()
653e0cfc
Keenan Nemetz
18d ago
635 if err != nil {
a5f19b3c
Keenan Nemetz
18d ago
636 t.Fatalf("rebuild: %v\n%s", err, out)
653e0cfc
Keenan Nemetz
18d ago
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 }
a5f19b3c
Keenan Nemetz
18d ago
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)
653e0cfc
Keenan Nemetz
18d ago
649 }
650 }
651
427c4761
Keenan Nemetz
17d ago
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.
655 func TestRosterIsOneDatabaseFile(t *testing.T) {
44530e74
Keenan Nemetz
16d ago
656 t.Parallel()
a5f19b3c
Keenan Nemetz
18d ago
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 {
653e0cfc
Keenan Nemetz
18d ago
661 t.Fatalf("push: %s", errs)
662 }
663
427c4761
Keenan Nemetz
17d ago
664 copied := filepath.Join(t.TempDir(), "users.db")
665 if err := f.srv.Identity.Backup(copied); err != nil {
666 t.Fatal(err)
653e0cfc
Keenan Nemetz
18d ago
667 }
197ecaa2
Keenan Nemetz
18d ago
668 ident, err := identity.Open(copied)
653e0cfc
Keenan Nemetz
18d ago
669 if err != nil {
670 t.Fatal(err)
671 }
427c4761
Keenan Nemetz
17d ago
672 defer ident.Close()
b1499c72
Keenan Nemetz
13d ago
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()
31df8bae
Keenan Nemetz
17d ago
676 if _, ok := ident.Snapshot().ByName["keenan"]; !ok {
197ecaa2
Keenan Nemetz
18d ago
677 t.Fatal("copied roster has no users")
653e0cfc
Keenan Nemetz
18d ago
678 }
31df8bae
Keenan Nemetz
17d ago
679 if len(ident.Snapshot().ByKey) != 1 {
680 t.Fatalf("copied roster has %d keys", len(ident.Snapshot().ByKey))
653e0cfc
Keenan Nemetz
18d ago
681 }
197ecaa2
Keenan Nemetz
18d ago
682
427c4761
Keenan Nemetz
17d ago
683 for _, absent := range []string{"auth.db", "system.git", "trusted_keys"} {
197ecaa2
Keenan Nemetz
18d ago
684 if _, err := os.Stat(filepath.Join(f.cfg.DataDir, absent)); err == nil {
427c4761
Keenan Nemetz
17d ago
685 t.Fatalf("%s exists; the roster is supposed to be one database file", absent)
197ecaa2
Keenan Nemetz
18d ago
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.
693 func TestRosterIsReloadable(t *testing.T) {
44530e74
Keenan Nemetz
16d ago
694 t.Parallel()
197ecaa2
Keenan Nemetz
18d ago
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 }
31df8bae
Keenan Nemetz
17d ago
704 if got := len(f.srv.Identity.Snapshot().ByKey); got != 2 {
197ecaa2
Keenan Nemetz
18d ago
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)
653e0cfc
Keenan Nemetz
18d ago
713 }
714 }
715
37c01e5a
Keenan Nemetz
18d ago
716 const testIssueID = "01M5JR2JK29KKMAA1T9MDNTPBP"
717
718 func (f *fixture) issuesRepo(t *testing.T, files map[string]string) string {
719 t.Helper()
720 dir := t.TempDir()
1eeca4e6
Keenan Nemetz
18d ago
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 {
37c01e5a
Keenan Nemetz
18d ago
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
744 func issueFiles(id, title, desc string) map[string]string {
745 return map[string]string{
307cbc2a
Keenan Nemetz
17d ago
746 "issues/" + id + "/issue.md": issueFile(title, "keenan", "open", "2026-10-22T21:00:00Z", []string{"bug"}, desc),
37c01e5a
Keenan Nemetz
18d ago
747 }
748 }
749
750 // TestIssuePushedThenRendered is the whole option-A loop: a user with an editor
6c84d387
Keenan Nemetz
16d ago
751 // and a key files a bug, and the web shows it.
37c01e5a
Keenan Nemetz
18d ago
752 func TestIssuePushedThenRendered(t *testing.T) {
44530e74
Keenan Nemetz
16d ago
753 t.Parallel()
37c01e5a
Keenan Nemetz
18d ago
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))
92b37587
Keenan Nemetz
18d ago
759 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "HEAD:refs/heads/vole"); err != nil {
37c01e5a
Keenan Nemetz
18d ago
760 t.Fatalf("push issues: %v\n%s", err, errs)
761 }
762
ed03b3d1
Keenan Nemetz
6h ago
763 code, body := f.get("/" + f.repo + "/issues")
37c01e5a
Keenan Nemetz
18d ago
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
ed03b3d1
Keenan Nemetz
6h ago
774 code, body = f.get("/" + f.repo + "/issues/" + testIssueID)
37c01e5a
Keenan Nemetz
18d ago
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 }
c7b9094f
Keenan Nemetz
1d ago
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)
a63475ce
Keenan Nemetz
17d ago
791 }
37c01e5a
Keenan Nemetz
18d ago
792 // The repo page advertises the count.
ed03b3d1
Keenan Nemetz
6h ago
793 if _, body := f.get("/" + f.repo); !strings.Contains(body, "Issues") {
37c01e5a
Keenan Nemetz
18d ago
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.
800 func TestIssueMarkdownIsSanitised(t *testing.T) {
44530e74
Keenan Nemetz
16d ago
801 t.Parallel()
37c01e5a
Keenan Nemetz
18d ago
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))
92b37587
Keenan Nemetz
18d ago
806 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "HEAD:refs/heads/vole"); err != nil {
37c01e5a
Keenan Nemetz
18d ago
807 t.Fatalf("push: %s", errs)
808 }
ed03b3d1
Keenan Nemetz
6h ago
809 code, body := f.get("/" + f.repo + "/issues/" + testIssueID)
37c01e5a
Keenan Nemetz
18d ago
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.
823 func TestInvalidIssuePushRejected(t *testing.T) {
44530e74
Keenan Nemetz
16d ago
824 t.Parallel()
37c01e5a
Keenan Nemetz
18d ago
825 f := newFixture(t)
826 f.publish(f.repo)
827
828 good := f.issuesRepo(t, issueFiles(testIssueID, "fine", "body\n"))
92b37587
Keenan Nemetz
18d ago
829 if _, errs, err := f.git(good, "push", f.sshURL(f.repo), "HEAD:refs/heads/vole"); err != nil {
37c01e5a
Keenan Nemetz
18d ago
830 t.Fatalf("good push: %s", errs)
831 }
832 before, _, _ := runGit(t, filepath.Join(f.cfg.ReposDir(), f.repo+".git"),
92b37587
Keenan Nemetz
18d ago
833 "rev-parse", "--verify", "--quiet", "refs/heads/vole")
37c01e5a
Keenan Nemetz
18d ago
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 }
92b37587
Keenan Nemetz
18d ago
852 _, errs, err := f.git(good, "push", f.sshURL(f.repo), "HEAD:refs/heads/vole")
37c01e5a
Keenan Nemetz
18d ago
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"),
92b37587
Keenan Nemetz
18d ago
860 "rev-parse", "--verify", "--quiet", "refs/heads/vole")
37c01e5a
Keenan Nemetz
18d ago
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.
868 func TestIssuesSurviveIndexRebuild(t *testing.T) {
44530e74
Keenan Nemetz
16d ago
869 t.Parallel()
37c01e5a
Keenan Nemetz
18d ago
870 f := newFixture(t)
871 f.publish(f.repo)
872 work := f.issuesRepo(t, issueFiles(testIssueID, "persisted", "body\n"))
92b37587
Keenan Nemetz
18d ago
873 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "HEAD:refs/heads/vole"); err != nil {
37c01e5a
Keenan Nemetz
18d ago
874 t.Fatalf("push: %s", errs)
875 }
876 if err := f.srv.Index.EnsureIssues(mustRepo(t, f)); err != nil {
877 t.Fatal(err)
878 }
c1e3e3ca
Keenan Nemetz
18d ago
879 before, err := f.srv.Index.Issues(repoID(t, f), index.Filter{})
37c01e5a
Keenan Nemetz
18d ago
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 }
c1e3e3ca
Keenan Nemetz
18d ago
890 after, err := f.srv.Index.Issues(repoID(t, f), index.Filter{})
37c01e5a
Keenan Nemetz
18d ago
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
c1e3e3ca
Keenan Nemetz
18d ago
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.
901 func TestIssueCLIEndToEnd(t *testing.T) {
44530e74
Keenan Nemetz
16d ago
902 t.Parallel()
c1e3e3ca
Keenan Nemetz
18d ago
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
67dd8854
Keenan Nemetz
17d ago
929 out, errs, err := run("issue", "new", "--title", "Filed from the CLI", "-m", "Body from the CLI.", "-l", "bug", "-a", "keenan")
c1e3e3ca
Keenan Nemetz
18d ago
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
67dd8854
Keenan Nemetz
17d ago
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 }
ed03b3d1
Keenan Nemetz
6h ago
970 if _, body := f.get("/" + f.repo + "/issues?assignee=wilbur"); !strings.Contains(body, "Filed from the CLI") {
67dd8854
Keenan Nemetz
17d ago
971 t.Fatal("the assignment is not filterable on the web")
972 }
973
c1e3e3ca
Keenan Nemetz
18d ago
974 if !strings.HasPrefix(f.serverIssues(t), "") {
975 t.Fatal("issues branch missing on the server")
976 }
ed03b3d1
Keenan Nemetz
6h ago
977 if _, body := f.get("/" + f.repo + "/issues"); !strings.Contains(body, "Filed from the CLI") {
c1e3e3ca
Keenan Nemetz
18d ago
978 t.Fatalf("issue not rendered after CLI push:\n%s", body)
979 }
ed03b3d1
Keenan Nemetz
6h ago
980 if code, _ := f.get("/" + f.repo + "/issues/" + id); code != 200 {
c1e3e3ca
Keenan Nemetz
18d ago
981 t.Fatalf("issue page: %d", code)
982 }
e05f1c48
Keenan Nemetz
13d ago
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.
ed03b3d1
Keenan Nemetz
6h ago
985 if _, body := f.get("/" + f.repo + "/issues/" + id); !strings.Contains(body, "#1") {
e05f1c48
Keenan Nemetz
13d ago
986 t.Fatal("the issue page does not show its number")
987 }
ed03b3d1
Keenan Nemetz
6h ago
988 if _, body := f.get("/" + f.repo + "/n/1"); !strings.Contains(body, "Filed from the CLI") {
e05f1c48
Keenan Nemetz
13d ago
989 t.Fatal("#1 does not resolve to the issue")
990 }
c1e3e3ca
Keenan Nemetz
18d ago
991
e05f1c48
Keenan Nemetz
13d ago
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.
c1e3e3ca
Keenan Nemetz
18d ago
994 short := id
995 if len(short) > 8 {
996 short = short[:8]
997 }
e05f1c48
Keenan Nemetz
13d ago
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)
c1e3e3ca
Keenan Nemetz
18d ago
1003 }
f9568748
Keenan Nemetz
18d ago
1004 if _, errs, err := run("issue", "comment", "-m", "A reply from the CLI.", short); err != nil {
c1e3e3ca
Keenan Nemetz
18d ago
1005 t.Fatalf("comment: %v\n%s", err, errs)
1006 }
ed03b3d1
Keenan Nemetz
6h ago
1007 if _, body := f.get("/" + f.repo + "/issues/" + id); !strings.Contains(body, "A reply from the CLI.") {
c1e3e3ca
Keenan Nemetz
18d ago
1008 t.Fatal("comment not rendered")
1009 }
1010
79c88d72
Keenan Nemetz
17d ago
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 }
f6f4b1db
Keenan Nemetz
15d ago
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 }
ed03b3d1
Keenan Nemetz
6h ago
1030 _, issuePage := f.get("/" + f.repo + "/issues/" + id)
f6f4b1db
Keenan Nemetz
15d ago
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 }
79c88d72
Keenan Nemetz
17d ago
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 }
ed03b3d1
Keenan Nemetz
6h ago
1041 _, body := f.get("/" + f.repo + "/issues/" + id)
79c88d72
Keenan Nemetz
17d ago
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 }
ed03b3d1
Keenan Nemetz
6h ago
1050 if _, body := f.get("/" + f.repo + "/issues/" + id); !strings.Contains(body, "Retitled from the CLI") ||
79c88d72
Keenan Nemetz
17d ago
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 }
ed03b3d1
Keenan Nemetz
6h ago
1057 if _, body := f.get("/" + f.repo + "/issues/" + id); strings.Contains(body, "A corrected reply.") {
79c88d72
Keenan Nemetz
17d ago
1058 t.Fatal("the deleted comment is still rendered")
1059 }
1060
f6f4b1db
Keenan Nemetz
15d ago
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 }
ed03b3d1
Keenan Nemetz
6h ago
1076 _, body = f.get("/" + f.repo + "/issues/" + id)
f6f4b1db
Keenan Nemetz
15d ago
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
c1e3e3ca
Keenan Nemetz
18d ago
1085 if _, errs, err := run("issue", "close", short); err != nil {
1086 t.Fatalf("close: %v\n%s", err, errs)
1087 }
ed03b3d1
Keenan Nemetz
6h ago
1088 if _, body := f.get("/" + f.repo + "/issues/" + id); !strings.Contains(body, "closed") {
c1e3e3ca
Keenan Nemetz
18d ago
1089 t.Fatal("issue did not close")
1090 }
1091
1092 // And a local validation failure never reaches the server.
f9568748
Keenan Nemetz
18d ago
1093 if _, errs, err := run("issue", "new", "--title", "", "-m", "x"); err == nil {
c1e3e3ca
Keenan Nemetz
18d ago
1094 t.Fatalf("empty title accepted by the CLI: %s", errs)
1095 }
f9568748
Keenan Nemetz
18d ago
1096
1eeca4e6
Keenan Nemetz
18d ago
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")
f9568748
Keenan Nemetz
18d ago
1104 cmd.Dir = clone
1105 var errb bytes.Buffer
1106 cmd.Stderr = &errb
1107 err = cmd.Run()
1108 if err == nil {
1eeca4e6
Keenan Nemetz
18d ago
1109 t.Fatal("unknown option was accepted")
f9568748
Keenan Nemetz
18d ago
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 }
1eeca4e6
Keenan Nemetz
18d ago
1115 if !strings.Contains(errb.String(), "unknown option --nonsense") {
f9568748
Keenan Nemetz
18d ago
1116 t.Fatalf("unhelpful usage error: %s", errb.String())
1117 }
c1e3e3ca
Keenan Nemetz
18d ago
1118 }
1119
a63475ce
Keenan Nemetz
17d ago
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.
1125 func TestWhoAmIAnswersFromTheInstance(t *testing.T) {
44530e74
Keenan Nemetz
16d ago
1126 t.Parallel()
a63475ce
Keenan Nemetz
17d ago
1127 f := newFixtureWith(t, func(c *config.Config) { c.PublicURL = "http://forge.test" })
427c4761
Keenan Nemetz
17d ago
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 }
a63475ce
Keenan Nemetz
17d ago
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 }
427c4761
Keenan Nemetz
17d ago
1141
a63475ce
Keenan Nemetz
17d ago
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 }
ed03b3d1
Keenan Nemetz
6h ago
1167 want := "http://forge.test/" + f.repo + "/issues/" + id
a63475ce
Keenan Nemetz
17d ago
1168 if !strings.Contains(errs, want) {
1169 t.Fatalf("the CLI did not print where to read it (%q):\n%s", want, errs)
427c4761
Keenan Nemetz
17d ago
1170 }
1171
a63475ce
Keenan Nemetz
17d ago
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 }
ed03b3d1
Keenan Nemetz
6h ago
1181 _, page := f.get("/" + f.repo + "/issues/" + id)
a63475ce
Keenan Nemetz
17d ago
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
eec0e603
Keenan Nemetz
6d ago
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.
1196 func 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
a63475ce
Keenan Nemetz
17d ago
1248 // TestReadCommandsNeedNoIdentity: `issue list` and `issue show` read the branch,
1249 // so they must keep working where the instance cannot be reached at all.
1250 func TestReadCommandsNeedNoIdentity(t *testing.T) {
44530e74
Keenan Nemetz
16d ago
1251 t.Parallel()
a63475ce
Keenan Nemetz
17d ago
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.
1280 func TestRequestAuthorComesFromTheKey(t *testing.T) {
44530e74
Keenan Nemetz
16d ago
1281 t.Parallel()
a63475ce
Keenan Nemetz
17d ago
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 }
6755b751
Keenan Nemetz
10d ago
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 }
a63475ce
Keenan Nemetz
17d ago
1304
6755b751
Keenan Nemetz
10d ago
1305 out, errs, err := f.cli(clone, "change", "new", "--title", "author from the key", "--head", "topic", "-m", "body")
427c4761
Keenan Nemetz
17d ago
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"),
b492b557
Keenan Nemetz
3d ago
1314 "show", "refs/heads/vole:changes/"+fields[0]+"/change.md")
427c4761
Keenan Nemetz
17d ago
1315 if err != nil {
1316 t.Fatalf("read request from the server: %v\n%s", err, raw)
1317 }
a63475ce
Keenan Nemetz
17d ago
1318 if !strings.Contains(raw, "author: keenan\n") {
1319 t.Fatalf("request author is not the account:\n%s", raw)
427c4761
Keenan Nemetz
17d ago
1320 }
62576fa8
Keenan Nemetz
14d ago
1321 if strings.Contains(raw, "author_id:") {
1322 t.Fatalf("request still carries a second identifier:\n%s", raw)
427c4761
Keenan Nemetz
17d ago
1323 }
a63475ce
Keenan Nemetz
17d ago
1324 if strings.Contains(raw, "keenan-nemetz") {
1325 t.Fatalf("the git-config guess reached the request:\n%s", raw)
1326 }
04e00cfc
Keenan Nemetz
16d ago
1327 if _, errs, err := f.cli(clone, "change", "new", "--title", "x", "--author", "wilbur"); err == nil {
a63475ce
Keenan Nemetz
17d ago
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.
1337 func TestAURLRemoteIsFetchedBeforeWriting(t *testing.T) {
44530e74
Keenan Nemetz
16d ago
1338 t.Parallel()
a63475ce
Keenan Nemetz
17d ago
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.
ed03b3d1
Keenan Nemetz
6h ago
1356 _, body := f.get("/" + f.repo + "/issues")
a63475ce
Keenan Nemetz
17d ago
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
c7b9094f
Keenan Nemetz
1d ago
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.
a63475ce
Keenan Nemetz
17d ago
1367 func TestEmptyStatesPrintAWorkingCommand(t *testing.T) {
44530e74
Keenan Nemetz
16d ago
1368 t.Parallel()
a63475ce
Keenan Nemetz
17d ago
1369 f := newFixture(t)
1370 f.publish(f.repo)
1371 for _, c := range []struct{ path, want string }{
ed03b3d1
Keenan Nemetz
6h ago
1372 {"/" + f.repo + "/issues", "vole issue new --title"},
1373 {"/" + f.repo + "/changes", "vole change new --title"},
a63475ce
Keenan Nemetz
17d ago
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 }
427c4761
Keenan Nemetz
17d ago
1383 }
1384
3c5d11fa
Keenan Nemetz
17d ago
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.
1389 func TestRequestPageSaysWhenItWouldConflict(t *testing.T) {
44530e74
Keenan Nemetz
16d ago
1390 t.Parallel()
3c5d11fa
Keenan Nemetz
17d ago
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{
b492b557
Keenan Nemetz
3d ago
1419 "changes/" + reqID + "/change.md": requestFile("main", head, "keenan", "why this change\n"),
3c5d11fa
Keenan Nemetz
17d ago
1420 }, "request"); err != nil {
1421 t.Fatalf("request push: %v", err)
1422 }
1423
ed03b3d1
Keenan Nemetz
6h ago
1424 code, body := f.get("/" + f.repo + "/changes/" + reqID)
3c5d11fa
Keenan Nemetz
17d ago
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{
b492b557
Keenan Nemetz
3d ago
1443 "changes/" + cleanID + "/change.md": requestFile("main", cleanHead, "keenan", "clean change\n"),
3c5d11fa
Keenan Nemetz
17d ago
1444 }, "clean request"); err != nil {
1445 t.Fatalf("clean request push: %v", err)
1446 }
ed03b3d1
Keenan Nemetz
6h ago
1447 if _, body := f.get("/" + f.repo + "/changes/" + cleanID); !strings.Contains(body, "Merges into main cleanly.") {
3c5d11fa
Keenan Nemetz
17d ago
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 }
04e00cfc
Keenan Nemetz
16d ago
1457 out, _, err := f.cli(clone, "change", "show", reqID)
3c5d11fa
Keenan Nemetz
17d ago
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 }
04e00cfc
Keenan Nemetz
16d ago
1475 out, _, err = f.cli(fresh, "change", "show", reqID)
3c5d11fa
Keenan Nemetz
17d ago
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{
b492b557
Keenan Nemetz
3d ago
1486 "changes/" + ghostID + "/change.md": requestFile("main",
62576fa8
Keenan Nemetz
14d ago
1487 "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef", "keenan", "ghost head\n"),
3c5d11fa
Keenan Nemetz
17d ago
1488 }, "ghost request"); err != nil {
1489 t.Fatalf("ghost request push: %v", err)
1490 }
ed03b3d1
Keenan Nemetz
6h ago
1491 if code, body := f.get("/" + f.repo + "/changes/" + ghostID); code != 200 ||
3c5d11fa
Keenan Nemetz
17d ago
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
7c6e52e7
Keenan Nemetz
17d ago
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.
1501 func TestRequestDiscussionIsPushedAndOwned(t *testing.T) {
44530e74
Keenan Nemetz
16d ago
1502 t.Parallel()
7c6e52e7
Keenan Nemetz
17d ago
1503 f := newFixture(t)
6c84d387
Keenan Nemetz
16d ago
1504 writerKey := f.addWriter(t) // wilbur: maintain access, not admin
7c6e52e7
Keenan Nemetz
17d ago
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{
b492b557
Keenan Nemetz
3d ago
1517 "changes/" + reqID + "/change.md": requestFile("main", head, "keenan", "rationale\n"),
7c6e52e7
Keenan Nemetz
17d ago
1518 }, "request"); err != nil {
1519 t.Fatalf("request push: %v", err)
1520 }
1521
c7b9094f
Keenan Nemetz
1d ago
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.
ed03b3d1
Keenan Nemetz
6h ago
1524 code, body := f.get("/" + f.repo + "/changes/" + reqID)
7c6e52e7
Keenan Nemetz
17d ago
1525 if code != 200 {
1526 t.Fatalf("request page: %d", code)
1527 }
c7b9094f
Keenan Nemetz
1d ago
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)
7c6e52e7
Keenan Nemetz
17d ago
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 }
04e00cfc
Keenan Nemetz
16d ago
1541 out, errs, err := f.cli(clone, "change", "comment", reqID, "-m", "Looks good to me.")
7c6e52e7
Keenan Nemetz
17d ago
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 }
ed03b3d1
Keenan Nemetz
6h ago
1549 _, body = f.get("/" + f.repo + "/changes/" + reqID)
7c6e52e7
Keenan Nemetz
17d ago
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.
04e00cfc
Keenan Nemetz
16d ago
1554 if out, _, err := f.cli(clone, "change", "show", reqID); err != nil ||
7c6e52e7
Keenan Nemetz
17d ago
1555 !strings.Contains(out, "--- "+cid+" keenan") {
1556 t.Fatalf("request show does not print the thread: %v\n%s", err, out)
1557 }
f6f4b1db
Keenan Nemetz
15d ago
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 }
ed03b3d1
Keenan Nemetz
6h ago
1566 _, body = f.get("/" + f.repo + "/changes/" + reqID)
f6f4b1db
Keenan Nemetz
15d ago
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 }
7c6e52e7
Keenan Nemetz
17d ago
1571
b492b557
Keenan Nemetz
3d ago
1572 commentPath := func(id string) string { return "changes/" + reqID + "/comments/" + id + ".md" }
7c6e52e7
Keenan Nemetz
17d ago
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 }
ed03b3d1
Keenan Nemetz
6h ago
1600 _, body = f.get("/" + f.repo + "/changes/" + reqID)
7c6e52e7
Keenan Nemetz
17d ago
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.
04e00cfc
Keenan Nemetz
16d ago
1608 if _, _, err := f.cli(clone, "change", "comment", reqID, "-m", " "); err == nil {
7c6e52e7
Keenan Nemetz
17d ago
1609 t.Fatal("an empty comment was accepted")
1610 }
f6f4b1db
Keenan Nemetz
15d ago
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 }
ed03b3d1
Keenan Nemetz
6h ago
1616 _, body = f.get("/" + f.repo + "/changes/" + reqID)
f6f4b1db
Keenan Nemetz
15d ago
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 }
7c6e52e7
Keenan Nemetz
17d ago
1620 }
1621
9cc2f087
Keenan Nemetz
17d ago
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.
1627 func TestReviewCommentAnchorsALine(t *testing.T) {
44530e74
Keenan Nemetz
16d ago
1628 t.Parallel()
9cc2f087
Keenan Nemetz
17d ago
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{
b492b557
Keenan Nemetz
3d ago
1642 "changes/" + reqID + "/change.md": requestFile("main", head, "keenan", "rationale\n"),
9cc2f087
Keenan Nemetz
17d ago
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.
04e00cfc
Keenan Nemetz
16d ago
1652 out, errs, err := f.cli(clone, "change", "comment", reqID, "-m", "why FEATURE?",
9cc2f087
Keenan Nemetz
17d ago
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)
b492b557
Keenan Nemetz
3d ago
1664 got := serverFile("changes/" + reqID + "/comments/" + newCm + ".md")
9cc2f087
Keenan Nemetz
17d ago
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 }
f6f4b1db
Keenan Nemetz
15d ago
1670 // The Files tab puts the comment beneath that line; the conversation still
1671 // labels the card with where it points.
ed03b3d1
Keenan Nemetz
6h ago
1672 _, body := f.get("/" + f.repo + "/changes/" + reqID + "/files/code.txt")
f6f4b1db
Keenan Nemetz
15d ago
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)
9cc2f087
Keenan Nemetz
17d ago
1676 }
ed03b3d1
Keenan Nemetz
6h ago
1677 _, body = f.get("/" + f.repo + "/changes/" + reqID)
f6f4b1db
Keenan Nemetz
15d ago
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)
9cc2f087
Keenan Nemetz
17d ago
1682 }
1683
1684 // Old side: a line of the file as it stood at the merge base.
04e00cfc
Keenan Nemetz
16d ago
1685 out, errs, err = f.cli(clone, "change", "comment", reqID, "-m", "the old middle line",
9cc2f087
Keenan Nemetz
17d ago
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")
b492b557
Keenan Nemetz
3d ago
1694 if got := serverFile("changes/" + reqID + "/comments/" + oldCm + ".md"); !strings.Contains(got, "blob: "+strings.TrimSpace(oldBlob)) {
9cc2f087
Keenan Nemetz
17d ago
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)
04e00cfc
Keenan Nemetz
16d ago
1700 if _, errs, err := f.cli(clone, "change", "comment", reqID, "-m", "past the end",
9cc2f087
Keenan Nemetz
17d ago
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 }
04e00cfc
Keenan Nemetz
16d ago
1730 if _, errs, err := f.cli(clone, "change", "update", reqID, "--head", nextHead); err != nil {
9cc2f087
Keenan Nemetz
17d ago
1731 t.Fatalf("request update: %v\n%s", err, errs)
1732 }
ed03b3d1
Keenan Nemetz
6h ago
1733 _, body = f.get("/" + f.repo + "/changes/" + reqID)
f6f4b1db
Keenan Nemetz
15d ago
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)
9cc2f087
Keenan Nemetz
17d ago
1737 }
ed03b3d1
Keenan Nemetz
6h ago
1738 _, body = f.get("/" + f.repo + "/changes/" + reqID + "/files/code.txt")
f6f4b1db
Keenan Nemetz
15d ago
1739 if strings.Contains(body, `id="inline-c-`+newCm+`"`) {
1740 t.Fatalf("the stale comment kept its inline note:\n%s", body)
9cc2f087
Keenan Nemetz
17d ago
1741 }
1742 _ = next
1743 }
1744
c1e3e3ca
Keenan Nemetz
18d ago
1745 func (f *fixture) serverIssues(t *testing.T) string {
1746 t.Helper()
1747 out, _, _ := runGit(t, filepath.Join(f.cfg.ReposDir(), f.repo+".git"),
92b37587
Keenan Nemetz
18d ago
1748 "rev-parse", "--verify", "--quiet", "refs/heads/vole")
c1e3e3ca
Keenan Nemetz
18d ago
1749 return strings.TrimSpace(out)
1750 }
1751
1752 // TestIssueFiltersAndSearch covers the SQL paths behind the list page.
1753 func TestIssueFiltersAndSearch(t *testing.T) {
44530e74
Keenan Nemetz
16d ago
1754 t.Parallel()
c1e3e3ca
Keenan Nemetz
18d ago
1755 f := newFixture(t)
1756 f.publish(f.repo)
1757 files := map[string]string{}
307cbc2a
Keenan Nemetz
17d ago
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)
c1e3e3ca
Keenan Nemetz
18d ago
1760 }
307cbc2a
Keenan Nemetz
17d ago
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"})
c1e3e3ca
Keenan Nemetz
18d ago
1764
1765 work := f.issuesRepo(t, files)
92b37587
Keenan Nemetz
18d ago
1766 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "HEAD:refs/heads/vole"); err != nil {
c1e3e3ca
Keenan Nemetz
18d ago
1767 t.Fatalf("push: %s", errs)
1768 }
1769
1770 for _, c := range []struct{ query, want string }{
f09ef39e
Keenan Nemetz
15d ago
1771 {"?state=all", "Docs typo"},
c1e3e3ca
Keenan Nemetz
18d ago
1772 {"?label=bug", "Segfault on startup"},
1773 {"?state=closed", "Docs typo"},
1774 {"?q=parser", "Slow parser"},
1775 } {
ed03b3d1
Keenan Nemetz
6h ago
1776 code, body := f.get("/" + f.repo + "/issues" + c.query)
c1e3e3ca
Keenan Nemetz
18d ago
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.
ed03b3d1
Keenan Nemetz
6h ago
1785 if _, body := f.get("/" + f.repo + "/issues?label=docs"); strings.Contains(body, "Segfault") {
c1e3e3ca
Keenan Nemetz
18d ago
1786 t.Fatal("label filter did not exclude other issues")
1787 }
1788 // Negation case: searching a word only in the body.
ed03b3d1
Keenan Nemetz
6h ago
1789 if _, body := f.get("/" + f.repo + "/issues?q=throughput"); !strings.Contains(body, "Slow parser") {
c1e3e3ca
Keenan Nemetz
18d ago
1790 t.Fatal("body search did not match")
1791 }
1792 }
1793
67dd8854
Keenan Nemetz
17d ago
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.
1797 func TestIssueAssigneesRenderedFilteredAndValidated(t *testing.T) {
44530e74
Keenan Nemetz
16d ago
1798 t.Parallel()
67dd8854
Keenan Nemetz
17d ago
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.
ed03b3d1
Keenan Nemetz
6h ago
1817 code, body := f.get("/" + f.repo + "/issues/" + assigned)
67dd8854
Keenan Nemetz
17d ago
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.
ed03b3d1
Keenan Nemetz
6h ago
1825 if _, body := f.get("/" + f.repo + "/issues"); !strings.Contains(body, "Assignee") {
67dd8854
Keenan Nemetz
17d ago
1826 t.Fatalf("list has no assignee filters:\n%s", body)
1827 }
1828 // And the filter excludes issues with a different assignee.
ed03b3d1
Keenan Nemetz
6h ago
1829 _, body = f.get("/" + f.repo + "/issues?assignee=wilbur")
67dd8854
Keenan Nemetz
17d ago
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
79c88d72
Keenan Nemetz
17d ago
1866 // commentFile renders issues/<id>/comments/<cid>.md for fixtures.
1867 func commentFile(author, created, body string) string {
c5902451
Keenan Nemetz
16d ago
1868 d := metadata.NewDoc()
79c88d72
Keenan Nemetz
17d ago
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.
1886 func TestCommentEditsBelongToTheirAuthor(t *testing.T) {
44530e74
Keenan Nemetz
16d ago
1887 t.Parallel()
79c88d72
Keenan Nemetz
17d ago
1888 f := newFixture(t)
6c84d387
Keenan Nemetz
16d ago
1889 writerKey := f.addWriter(t) // wilbur: maintain access, not admin
79c88d72
Keenan Nemetz
17d ago
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.
ed03b3d1
Keenan Nemetz
6h ago
1929 if _, body := f.get("/" + f.repo + "/issues/" + issueID); !strings.Contains(body, "original words") {
79c88d72
Keenan Nemetz
17d ago
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 }
ed03b3d1
Keenan Nemetz
6h ago
1940 _, body := f.get("/" + f.repo + "/issues/" + issueID)
79c88d72
Keenan Nemetz
17d ago
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 }
ed03b3d1
Keenan Nemetz
6h ago
1957 if _, body := f.get("/" + f.repo + "/issues/" + issueID); strings.Contains(body, "second draft") {
79c88d72
Keenan Nemetz
17d ago
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 }
b1499c72
Keenan Nemetz
13d ago
1964 if !strings.Contains(string(audit), "\tcomment.delete\tkeenan\tdemo\t"+commentPath(wilburCm)) {
79c88d72
Keenan Nemetz
17d ago
1965 t.Fatalf("the admin override was not audited:\n%s", audit)
1966 }
1967 }
1968
c1e3e3ca
Keenan Nemetz
18d ago
1969 // TestCrossReferencesLinksIssues: a #<ulid> in a body becomes a link, and a
1970 // commit mentioning an issue shows as a backlink.
1971 func TestCrossReferencesLinksIssues(t *testing.T) {
44530e74
Keenan Nemetz
16d ago
1972 t.Parallel()
c1e3e3ca
Keenan Nemetz
18d ago
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)
92b37587
Keenan Nemetz
18d ago
1984 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "HEAD:refs/heads/vole"); err != nil {
c1e3e3ca
Keenan Nemetz
18d ago
1985 t.Fatalf("push issues: %s", errs)
1986 }
1987
ed03b3d1
Keenan Nemetz
6h ago
1988 _, body := f.get("/" + f.repo + "/issues/" + other)
1989 if !strings.Contains(body, `href="/demo/issues/`+target+`"`) {
c1e3e3ca
Keenan Nemetz
18d ago
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>"):]
ed03b3d1
Keenan Nemetz
6h ago
1994 if strings.Contains(fence[:min(len(fence), 400)], `href="/demo/issues/`+target) {
c1e3e3ca
Keenan Nemetz
18d ago
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)
ed03b3d1
Keenan Nemetz
6h ago
2017 if _, body := f.get("/" + f.repo + "/issues/" + target); !strings.Contains(body, "Referenced in commits") {
c1e3e3ca
Keenan Nemetz
18d ago
2018 t.Fatalf("backlink section missing:\n%s", body)
2019 }
2020 }
2021
2022 func min(a, b int) int {
2023 if a < b {
2024 return a
2025 }
2026 return b
2027 }
2028
92b37587
Keenan Nemetz
18d ago
2029 // TestMetadataRejectsMergeCommits: metadata history is linear by policy, so a
2030 // merge node is refused rather than quietly accepted.
2031 func TestMetadataRejectsMergeCommits(t *testing.T) {
44530e74
Keenan Nemetz
16d ago
2032 t.Parallel()
92b37587
Keenan Nemetz
18d ago
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")
1eeca4e6
Keenan Nemetz
18d ago
2052 git("checkout", "-q", "scratch")
92b37587
Keenan Nemetz
18d ago
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.
2069 func TestMetadataForcePushIsAdminOnly(t *testing.T) {
44530e74
Keenan Nemetz
16d ago
2070 t.Parallel()
92b37587
Keenan Nemetz
18d ago
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 }
b1499c72
Keenan Nemetz
13d ago
2103 if !strings.Contains(string(audit), "\tpush.force\tkeenan\tdemo\trefs/heads/vole") {
92b37587
Keenan Nemetz
18d ago
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.
2110 func TestMetadataRejectsUnknownTopLevel(t *testing.T) {
44530e74
Keenan Nemetz
16d ago
2111 t.Parallel()
92b37587
Keenan Nemetz
18d ago
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.
2141 func TestConcurrentCLIWritesAllLand(t *testing.T) {
44530e74
Keenan Nemetz
16d ago
2142 t.Parallel()
92b37587
Keenan Nemetz
18d ago
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 }