git_test.go
| 1 | package web |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "net/http/httptest" |
| 6 | "os" |
| 7 | "os/exec" |
| 8 | "path/filepath" |
| 9 | "strings" |
| 10 | "testing" |
| 11 | |
| 12 | "github.com/go-chi/chi/v5" |
| 13 | |
| 14 | "hearthforge/internal/ci" |
| 15 | "hearthforge/internal/config" |
| 16 | "hearthforge/internal/db" |
| 17 | "hearthforge/internal/gitcmd" |
| 18 | ) |
| 19 | |
| 20 | const gitTestPassword = "testpass123" |
| 21 | |
| 22 | // runGit runs a git command in dir and fails the test on error. |
| 23 | func runGit(t *testing.T, dir string, args ...string) string { |
| 24 | t.Helper() |
| 25 | out, err := gitTry(dir, args...) |
| 26 | if err != nil { |
| 27 | t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out) |
| 28 | } |
| 29 | return out |
| 30 | } |
| 31 | |
| 32 | func gitTry(dir string, args ...string) (string, error) { |
| 33 | cmd := exec.Command("git", args...) |
| 34 | cmd.Dir = dir |
| 35 | cmd.Env = append(os.Environ(), |
| 36 | "GIT_CONFIG_GLOBAL=/dev/null", |
| 37 | "GIT_CONFIG_SYSTEM=/dev/null", |
| 38 | "GIT_TERMINAL_PROMPT=0", |
| 39 | "GIT_ASKPASS=echo", |
| 40 | "GIT_AUTHOR_NAME=Test", "GIT_AUTHOR_EMAIL=test@example.com", |
| 41 | "GIT_COMMITTER_NAME=Test", "GIT_COMMITTER_EMAIL=test@example.com", |
| 42 | ) |
| 43 | out, err := cmd.CombinedOutput() |
| 44 | return string(out), err |
| 45 | } |
| 46 | |
| 47 | // gitTestServer creates a temp data dir, a public repo with one commit and an |
| 48 | // httptest server that serves only the smart HTTP routes. |
| 49 | func gitTestServer(t *testing.T) (*Server, *httptest.Server) { |
| 50 | t.Helper() |
| 51 | ctx := context.Background() |
| 52 | dir := t.TempDir() |
| 53 | database, err := db.Open(filepath.Join(dir, "hearthforge.db")) |
| 54 | if err != nil { |
| 55 | t.Fatal(err) |
| 56 | } |
| 57 | t.Cleanup(func() { database.Close() }) |
| 58 | if _, err := database.InitAdmin(ctx, gitTestPassword); err != nil { |
| 59 | t.Fatal(err) |
| 60 | } |
| 61 | if _, err := database.CreateRepo(ctx, "smoke", nil, false, "main", db.NowISO()); err != nil { |
| 62 | t.Fatal(err) |
| 63 | } |
| 64 | |
| 65 | cfg := &config.Config{ |
| 66 | DataDir: dir, BaseURL: "http://localhost:3000", |
| 67 | CIMaxConcurrent: 1, MaxConcurrentArchives: 1, RateLimitDisabled: true, |
| 68 | } |
| 69 | if err := os.MkdirAll(cfg.ReposDir(), 0o755); err != nil { |
| 70 | t.Fatal(err) |
| 71 | } |
| 72 | g := gitcmd.New(cfg) |
| 73 | if err := g.Init(ctx, "smoke", "main"); err != nil { |
| 74 | t.Fatal(err) |
| 75 | } |
| 76 | // Seed one commit by pushing from a scratch working tree. |
| 77 | seed := t.TempDir() |
| 78 | runGit(t, seed, "init", "--initial-branch=main", seed) |
| 79 | if err := os.WriteFile(filepath.Join(seed, "README.md"), []byte("hello\n"), 0o644); err != nil { |
| 80 | t.Fatal(err) |
| 81 | } |
| 82 | runGit(t, seed, "add", "README.md") |
| 83 | runGit(t, seed, "commit", "-m", "initial") |
| 84 | runGit(t, seed, "push", g.RepoPath("smoke"), "main") |
| 85 | |
| 86 | // Bare repos reject a push to the checked-out branch unless told otherwise. |
| 87 | runGit(t, "", "-C", g.RepoPath("smoke"), "config", "receive.denyCurrentBranch", "ignore") |
| 88 | |
| 89 | s := &Server{Cfg: cfg, DB: database, Git: g, CI: ci.New(cfg, database)} |
| 90 | r := chi.NewRouter() |
| 91 | s.gitRoutes(r) |
| 92 | ts := httptest.NewServer(r) |
| 93 | t.Cleanup(ts.Close) |
| 94 | return s, ts |
| 95 | } |
| 96 | |
| 97 | func TestGitSmartHTTPCloneAndPush(t *testing.T) { |
| 98 | if _, err := exec.LookPath("git"); err != nil { |
| 99 | t.Skip("git not installed") |
| 100 | } |
| 101 | s, ts := gitTestServer(t) |
| 102 | ctx := context.Background() |
| 103 | |
| 104 | // Anonymous clone of a public repo works. |
| 105 | work := t.TempDir() |
| 106 | clone := filepath.Join(work, "smoke") |
| 107 | runGit(t, work, "clone", ts.URL+"/smoke.git", clone) |
| 108 | if _, err := os.Stat(filepath.Join(clone, "README.md")); err != nil { |
| 109 | t.Fatalf("cloned file missing: %v", err) |
| 110 | } |
| 111 | |
| 112 | // Anonymous push is rejected. |
| 113 | if err := os.WriteFile(filepath.Join(clone, "README.md"), []byte("changed\n"), 0o644); err != nil { |
| 114 | t.Fatal(err) |
| 115 | } |
| 116 | runGit(t, clone, "commit", "-am", "second commit") |
| 117 | if out, err := gitTry(clone, "push", "origin", "main"); err == nil { |
| 118 | t.Fatalf("anonymous push succeeded:\n%s", out) |
| 119 | } |
| 120 | |
| 121 | // Push with admin credentials in the URL succeeds. |
| 122 | authURL := strings.Replace(ts.URL, "http://", "http://admin:"+gitTestPassword+"@", 1) |
| 123 | runGit(t, clone, "push", authURL+"/smoke.git", "main") |
| 124 | |
| 125 | commits, err := s.Git.Log(ctx, "smoke", "main", 10, 0) |
| 126 | if err != nil { |
| 127 | t.Fatal(err) |
| 128 | } |
| 129 | if len(commits) != 2 || commits[0].Subject != "second commit" { |
| 130 | t.Fatalf("unexpected log: %+v", commits) |
| 131 | } |
| 132 | } |
| 133 |