// Package e2e runs chapter 45.1's test, because an unexercised portability claim goes false. package e2e import ( "context" "fmt" "net/http/httptest" "os" "os/exec" "path/filepath" "strings" "testing" "github.com/barerepo/server/internal/cache" "github.com/barerepo/server/internal/config" "github.com/barerepo/server/internal/gitread" "github.com/barerepo/server/internal/httpd" "github.com/barerepo/server/internal/repocfg" "github.com/barerepo/server/internal/store" "github.com/barerepo/server/internal/thread" "github.com/barerepo/server/internal/transport" ) var binary string func TestMain(m *testing.M) { dir, err := os.MkdirTemp("", "barerepo-e2e-") if err != nil { panic(err) } binary = filepath.Join(dir, "barerepo") build := exec.Command("go", "build", "-o", binary, "../cmd/barerepo") if out, err := build.CombinedOutput(); err != nil { fmt.Fprintf(os.Stderr, "building barerepo: %v\n%s", err, out) os.Exit(1) } code := m.Run() os.RemoveAll(dir) os.Exit(code) } // instance is one barerepo, so the test can push a mirror to a second one. type instance struct { t *testing.T cfg config.Config db *store.DB srv *httpd.Server http *httptest.Server // keys remembers each account's key, so a test can sign as that account. keys map[string]string } // keyOf is the private half of the key the account was made with. func (in *instance) keyOf(name string) string { in.t.Helper() key, ok := in.keys[name] if !ok { in.t.Fatalf("no key was recorded for %s", name) } return key } func newInstance(t *testing.T) *instance { t.Helper() root := t.TempDir() cfg := config.Default() cfg.Paths.Repos = filepath.Join(root, "repos") cfg.Paths.Cache = filepath.Join(root, "cache") cfg.Paths.Artifacts = filepath.Join(root, "artifacts") cfg.Database.URL = "sqlite://" + filepath.Join(root, "barerepo.db") cfg.Path = filepath.Join(root, "barerepo.toml") body := fmt.Sprintf("[paths]\nrepos = %q\ncache = %q\nartifacts = %q\n\n[database]\nurl = %q\n", cfg.Paths.Repos, cfg.Paths.Cache, cfg.Paths.Artifacts, cfg.Database.URL) if err := os.WriteFile(cfg.Path, []byte(body), 0o600); err != nil { t.Fatal(err) } for _, d := range []string{cfg.Paths.Repos, cfg.Paths.Cache, cfg.Paths.Artifacts} { if err := os.MkdirAll(d, 0o750); err != nil { t.Fatal(err) } } // The server writes this on every key change, and the hook checks signatures against it. store.SignersPath = filepath.Join(cfg.Paths.Cache, "allowed_signers") db, err := store.Open(context.Background(), cfg.Database.URL) if err != nil { t.Fatal(err) } t.Cleanup(func() { db.Close() }) // Every test runs warm, because a cache that returns the wrong answer only does so on a hit. shared := cache.New(cfg.Paths.Cache) gitread.Cache = shared repocfg.Cache = shared thread.Cache = shared httpd.Cache = shared srv := &httpd.Server{Cfg: cfg, DB: db, Transport: &transport.Server{Cfg: cfg, DB: db, Bin: binary}} ts := httptest.NewServer(srv.Handler()) t.Cleanup(ts.Close) // The external url must be the one the server actually got, so clone urls point at it. cfg.Server.ExternalURL = ts.URL srv.Cfg = cfg srv.Transport.Cfg = cfg body += fmt.Sprintf("\n[server]\nexternal_url = %q\n", ts.URL) if err := os.WriteFile(cfg.Path, []byte(body), 0o600); err != nil { t.Fatal(err) } return &instance{t: t, cfg: cfg, db: db, srv: srv, http: ts} } // account makes an account with a fresh key and returns a push token. func (in *instance) account(name string) string { in.t.Helper() dir := in.t.TempDir() key := filepath.Join(dir, "id") run(in.t, "", "ssh-keygen", "-t", "ed25519", "-N", "", "-C", name, "-f", key, "-q") pub, err := os.ReadFile(key + ".pub") if err != nil { in.t.Fatal(err) } if _, err := in.db.CreateAccount(context.Background(), name, string(pub), false); err != nil { in.t.Fatal(err) } if in.keys == nil { in.keys = map[string]string{} } in.keys[name] = key tok, _, err := in.db.CreateToken(context.Background(), "gt", name, "", "test") if err != nil { in.t.Fatal(err) } return tok } func (in *instance) url(token, path string) string { return strings.Replace(in.http.URL, "://", "://x:"+token+"@", 1) + path } func run(t *testing.T, dir string, name string, args ...string) string { t.Helper() cmd := exec.Command(name, args...) cmd.Dir = dir cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=tester", "GIT_AUTHOR_EMAIL=t@x", "GIT_COMMITTER_NAME=tester", "GIT_COMMITTER_EMAIL=t@x", "GIT_TERMINAL_PROMPT=0", "GIT_CONFIG_COUNT=1", "GIT_CONFIG_KEY_0=credential.helper", "GIT_CONFIG_VALUE_0=") out, err := cmd.CombinedOutput() if err != nil { t.Fatalf("%s %s: %v\n%s", name, strings.Join(args, " "), err, out) } return strings.TrimSpace(string(out)) } func try(t *testing.T, dir string, name string, args ...string) (string, error) { t.Helper() cmd := exec.Command(name, args...) cmd.Dir = dir cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=tester", "GIT_AUTHOR_EMAIL=t@x", "GIT_COMMITTER_NAME=tester", "GIT_COMMITTER_EMAIL=t@x", "GIT_TERMINAL_PROMPT=0", "GIT_CONFIG_COUNT=1", "GIT_CONFIG_KEY_0=credential.helper", "GIT_CONFIG_VALUE_0=") out, err := cmd.CombinedOutput() return string(out), err } // TestPortability is chapter 45.1, step for step. func TestPortability(t *testing.T) { if _, err := exec.LookPath("git"); err != nil { t.Skip("git is not installed") } origin := newInstance(t) john := origin.account("john") lisa := origin.account("lisa") // 1. Create a repository. Push code. work := t.TempDir() run(t, work, "git", "init", "-q", "-b", "master") write(t, work, "config.go", "package main\n\nfunc Load() error {\n\treturn nil\n}\n") write(t, work, ".barerepo/config", "[repo]\nvisibility = \"public\"\n\n[build]\ncommand = \"true\"\n") run(t, work, "git", "add", "-A") run(t, work, "git", "commit", "-qm", "first") run(t, work, "git", "push", "-q", origin.url(john, "/john/johnbot"), "master") // 2. Open a thread. Reply to it. dir := repoDir(t, origin, "john", "johnbot") head := run(t, dir, "git", "rev-parse", "HEAD") note(t, dir, 1, "title: does this work behind a socks proxy?\nstate: open\nauthor: lisa\n", head, "author: lisa\ntime: 1\n\nasking about proxies.\n") appendNote(t, dir, 1, head, "author: john\ntime: 2\n\nit should.\n") // 3. Push a proposal from a second account. Comment on a line. other := t.TempDir() run(t, other, "git", "clone", "-q", origin.url(lisa, "/john/johnbot"), other+"/c") clone := other + "/c" write(t, clone, "config.go", "package main\n\nfunc Load() error {\n\treturn fmt.Errorf(\"no\")\n}\n") run(t, clone, "git", "commit", "-qam", "return a real error") // Thread 1 was pushed straight in, as chapter 35.5 allows, so allocation steps over it. out := run(t, clone, "git", "push", origin.url(lisa, "/john/johnbot"), "HEAD:refs/proposals/new") if !strings.Contains(out, "refs/proposals/2") { t.Fatalf("the proposal did not step over the thread that already had 1:\n%s", out) } blob := run(t, dir, "git", "rev-parse", "refs/proposals/2:config.go") tip := run(t, dir, "git", "rev-parse", "refs/proposals/2") appendNote(t, dir, 1, tip, "author: john\ntime: 3\nanchor: config.go:4\nblob: "+blob+"\nside: new\n\nis fmt imported?\n") // 4. Merge the proposal. run(t, clone, "git", "fetch", "-q", origin.url(lisa, "/john/johnbot"), "refs/proposals/2:prop-2") run(t, clone, "git", "checkout", "-q", "master") run(t, clone, "git", "merge", "-q", "--no-edit", "prop-2") run(t, clone, "git", "push", "-q", origin.url(john, "/john/johnbot"), "master") // 5. A second branch and a tag, because chapter 40.1 says every branch and every tag. run(t, clone, "git", "checkout", "-q", "-b", "topic") write(t, clone, "topic.go", "package main\n") run(t, clone, "git", "add", "-A") run(t, clone, "git", "commit", "-qm", "a topic branch") run(t, clone, "git", "push", "-q", origin.url(john, "/john/johnbot"), "topic") run(t, clone, "git", "checkout", "-q", "master") run(t, clone, "git", "tag", "-a", "v1.0.0", "-m", "the first release") run(t, clone, "git", "push", "-q", origin.url(john, "/john/johnbot"), "v1.0.0") tag := run(t, dir, "git", "rev-parse", "v1.0.0") // A release body is a note on the tag, and chapter 22.1 says it clones with the repository. run(t, dir, "git", "notes", "--ref=refs/notes/releases", "add", "-m", "what changed here", tag) // 6. Run a build, recorded where a mirror will find it, since the protocol is tested elsewhere. merged := run(t, dir, "git", "rev-parse", "refs/heads/master") runNote(t, dir, merged) // 7. git clone --mirror the repository. mirror := filepath.Join(t.TempDir(), "johnbot.git") run(t, "", "git", "clone", "-q", "--mirror", origin.url(john, "/john/johnbot"), mirror) // 8. Delete the original from the server. if err := os.RemoveAll(dir); err != nil { t.Fatal(err) } if _, err := try(t, "", "git", "ls-remote", origin.url(john, "/john/johnbot")); err == nil { t.Fatal("the original still answers after being deleted") } // 9. Push the mirror to a second barerepo instance. second := newInstance(t) dave := second.account("dave") run(t, mirror, "git", "push", "--mirror", second.url(dave, "/dave/johnbot")) // 10. Confirm everything is present on the second instance. moved := repoDir(t, second, "dave", "johnbot") checks := []struct{ what, got, want string }{ {"code", run(t, moved, "git", "show", "master:config.go"), "fmt.Errorf"}, {"history", run(t, moved, "git", "log", "--format=%s", "master"), "first"}, {"the merge", run(t, moved, "git", "log", "--format=%s", "master"), "return a real error"}, {"config", run(t, moved, "git", "show", "master:.barerepo/config"), "visibility"}, {"the thread", run(t, moved, "git", "cat-file", "blob", "refs/notes/threads/1:meta"), "socks proxy"}, {"comments", run(t, moved, "git", "notes", "--ref=threads/1", "show", head), "asking about proxies"}, {"the reply", run(t, moved, "git", "notes", "--ref=threads/1", "show", head), "it should"}, {"the line comment", run(t, moved, "git", "notes", "--ref=threads/1", "show", tip), "anchor: config.go:4"}, {"the anchored blob", run(t, moved, "git", "notes", "--ref=threads/1", "show", tip), blob}, {"run results", run(t, moved, "git", "notes", "--ref=runs", "show", merged), "uproar.local"}, {"every branch", run(t, moved, "git", "show", "topic:topic.go"), "package main"}, {"every tag", run(t, moved, "git", "cat-file", "-p", "v1.0.0"), "the first release"}, {"the release body", run(t, moved, "git", "notes", "--ref=refs/notes/releases", "show", tag), "what changed here"}, } for _, c := range checks { if !strings.Contains(c.got, c.want) { t.Errorf("%s did not survive the move: wanted %q in\n%s", c.what, c.want, c.got) } } // The proposal ref travels too, per chapter 40.1. if _, err := try(t, moved, "git", "rev-parse", "--verify", "refs/proposals/2"); err != nil { t.Error("the proposal ref did not survive the move") } // The commented content is still fetchable, which chapter 43.4 needs to show it. if _, err := try(t, moved, "git", "cat-file", "-e", blob); err != nil { t.Error("the blob a comment was anchored to is gone") } } func write(t *testing.T, dir, name, body string) { t.Helper() path := filepath.Join(dir, name) if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(path, []byte(body), 0o644); err != nil { t.Fatal(err) } } func repoDir(t *testing.T, in *instance, owner, name string) string { t.Helper() return filepath.Join(in.cfg.Paths.Repos, owner, name+".git") } // note writes a thread the way the server does: a meta blob and one note. func note(t *testing.T, dir string, n int, meta, object, comment string) { t.Helper() metaBlob := hashObject(t, dir, meta) noteBlob := hashObject(t, dir, comment) tree := mktree(t, dir, "100644 blob "+metaBlob+"\tmeta\n100644 blob "+noteBlob+"\t"+object+"\n") commit := run(t, dir, "git", "commit-tree", tree, "-m", "open thread") run(t, dir, "git", "update-ref", fmt.Sprintf("refs/notes/threads/%d", n), commit) } func appendNote(t *testing.T, dir string, n int, object, record string) { t.Helper() ref := fmt.Sprintf("refs/notes/threads/%d", n) existing, err := try(t, dir, "git", "cat-file", "blob", ref+":"+object) body := record if err == nil { body = strings.TrimRight(existing, "\n") + "\n--\n" + record } listing := run(t, dir, "git", "ls-tree", ref) var entries strings.Builder for _, line := range strings.Split(listing, "\n") { if line == "" || strings.HasSuffix(line, "\t"+object) { continue } entries.WriteString(line + "\n") } entries.WriteString("100644 blob " + hashObject(t, dir, body) + "\t" + object + "\n") tree := mktree(t, dir, entries.String()) commit := run(t, dir, "git", "commit-tree", tree, "-p", ref, "-m", "reply") run(t, dir, "git", "update-ref", ref, commit) } func runNote(t *testing.T, dir, sha string) { t.Helper() body := `{"runner":"uproar.local","labels":["build"],"ref":"refs/heads/master","started":1,"duration":18,"exit":0,"output":"ok\n"}` tree := mktree(t, dir, "100644 blob "+hashObject(t, dir, body+"\n")+"\t"+sha+"\n") commit := run(t, dir, "git", "commit-tree", tree, "-m", "run") run(t, dir, "git", "update-ref", "refs/notes/runs", commit) } func hashObject(t *testing.T, dir, body string) string { t.Helper() cmd := exec.Command("git", "hash-object", "-w", "--stdin") cmd.Dir = dir cmd.Stdin = strings.NewReader(body) out, err := cmd.Output() if err != nil { t.Fatal(err) } return strings.TrimSpace(string(out)) } func mktree(t *testing.T, dir, entries string) string { t.Helper() cmd := exec.Command("git", "mktree") cmd.Dir = dir cmd.Stdin = strings.NewReader(entries) out, err := cmd.Output() if err != nil { t.Fatalf("mktree: %v", err) } return strings.TrimSpace(string(out)) }