patches_test.go
⎇
Raw
1package web
2
3import (
4 "bytes"
5 "context"
6 "mime/multipart"
7 "net/http"
8 "net/http/httptest"
9 "os"
10 "os/exec"
11 "path/filepath"
12 "strings"
13 "testing"
14
15 "github.com/go-chi/chi/v5"
16
17 "hearthforge/internal/config"
18 "hearthforge/internal/db"
19 "hearthforge/internal/gitcmd"
20 "hearthforge/internal/highlight"
21 "hearthforge/internal/markdown"
22)
23
24// patchTestServer builds a server with a bare repo that has one commit.
25func patchTestServer(t *testing.T) (http.Handler, *Server, *db.SessionUser) {
26 t.Helper()
27 if _, err := exec.LookPath("git"); err != nil {
28 t.Skip("git not installed")
29 }
30 ctx := context.Background()
31 dir := t.TempDir()
32 cfg := &config.Config{
33 DataDir: dir,
34 OwnerDisplayName: "Admin",
35 MaxTitleBytes: 500,
36 MaxTextBodyBytes: 100000,
37 MaxUploadBytes: 10 << 20,
38 MaxUserUploadBytes: 2 << 20,
39 CommitterName: "Hearthforge",
40 CommitterEmail: "forge@example.com",
41 MaxConcurrentArchives: 2,
42 RateLimitDisabled: true,
43 }
44 if err := os.MkdirAll(cfg.ReposDir(), 0o755); err != nil {
45 t.Fatal(err)
46 }
47 // Every commit is ssh-signed with the host key, so the test needs one.
48 cfg.SSHHostKeyPath = filepath.Join(dir, "ssh_host_key")
49 if _, err := exec.LookPath("ssh-keygen"); err != nil {
50 t.Skip("ssh-keygen not installed")
51 }
52 if out, err := exec.Command("ssh-keygen", "-q", "-t", "ed25519", "-N", "",
53 "-f", cfg.SSHHostKeyPath).CombinedOutput(); err != nil {
54 t.Fatalf("ssh-keygen: %v\n%s", err, out)
55 }
56 d, err := db.Open(filepath.Join(dir, "hearthforge.db"))
57 if err != nil {
58 t.Fatal(err)
59 }
60 t.Cleanup(func() { d.Close() })
61 if _, err := d.InitAdmin(ctx, "hunter2"); err != nil {
62 t.Fatal(err)
63 }
64 admin, err := d.UserByName(ctx, db.AdminUsername)
65 if err != nil || admin == nil {
66 t.Fatalf("admin lookup failed: %v", err)
67 }
68 if _, err := d.CreateRepo(ctx, "demo", nil, false, "main", db.NowISO()); err != nil {
69 t.Fatal(err)
70 }
71
72 s := &Server{
73 Cfg: cfg,
74 DB: d,
75 MD: markdown.New(),
76 HL: highlight.New(512 << 10),
77 Git: gitcmd.New(cfg),
78 Patches: gitcmd.NewPatchCache(),
79 }
80 if err := s.Git.Init(ctx, "demo", "main", ""); err != nil {
81 t.Fatal(err)
82 }
83 who := gitcmd.Ident{Name: "Admin", Email: "admin@example.com"}
84 if _, err := s.Git.EditFile(ctx, "demo", "main", "", "", "README.md",
85 []byte("hello\n"), "init", who); err != nil {
86 t.Fatal(err)
87 }
88
89 r := chi.NewRouter()
90 s.patchRoutes(r)
91 return r, s, &db.SessionUser{ID: admin.ID, Username: db.AdminUsername, IsAdmin: true}
92}
93
94// formatPatch clones the bare repo, adds a commit and returns its patch file.
95func formatPatch(t *testing.T, s *Server) string {
96 t.Helper()
97 work := filepath.Join(t.TempDir(), "clone")
98 run := func(cwd string, args ...string) string {
99 t.Helper()
100 cmd := exec.Command("git", args...)
101 cmd.Dir = cwd
102 cmd.Env = append(os.Environ(),
103 "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null",
104 "GIT_AUTHOR_NAME=Patch Author", "GIT_AUTHOR_EMAIL=patcher@example.com",
105 "GIT_COMMITTER_NAME=Patch Author", "GIT_COMMITTER_EMAIL=patcher@example.com")
106 out, err := cmd.Output()
107 if err != nil {
108 t.Fatalf("git %v: %v", args, err)
109 }
110 return string(out)
111 }
112 run(filepath.Dir(work), "clone", "-q", s.Git.RepoPath("demo"), work)
113 if err := os.WriteFile(filepath.Join(work, "README.md"), []byte("hello\nworld\n"), 0o644); err != nil {
114 t.Fatal(err)
115 }
116 run(work, "add", "-A")
117 run(work, "commit", "-q", "-m", "Add a world line")
118 return run(work, "format-patch", "-1", "--stdout")
119}
120
121// postPatchFile sends a multipart form with the fields and the patch file.
122func postPatchFile(t *testing.T, h http.Handler, user *db.SessionUser, target string,
123 fields map[string]string, patch string,
124) *httptest.ResponseRecorder {
125 t.Helper()
126 var body bytes.Buffer
127 mw := multipart.NewWriter(&body)
128 for k, v := range fields {
129 if err := mw.WriteField(k, v); err != nil {
130 t.Fatal(err)
131 }
132 }
133 part, err := mw.CreateFormFile("patch_file", "change.patch")
134 if err != nil {
135 t.Fatal(err)
136 }
137 part.Write([]byte(patch))
138 mw.Close()
139
140 req := httptest.NewRequest("POST", target, &body)
141 req.Header.Set("Content-Type", mw.FormDataContentType())
142 if user != nil {
143 req = req.WithContext(context.WithValue(req.Context(), userKey, user))
144 }
145 w := httptest.NewRecorder()
146 h.ServeHTTP(w, req)
147 return w
148}
149
150func TestPatchUploadDetailAndMerge(t *testing.T) {
151 h, s, admin := patchTestServer(t)
152 ctx := context.Background()
153 patch := formatPatch(t, s)
154
155 res := postPatchFile(t, h, admin, "/demo/patches",
156 map[string]string{"title": "World line", "description": "Adds a line."}, patch)
157 if res.Code != http.StatusFound {
158 t.Fatalf("upload status = %d, body %s", res.Code, res.Body.String())
159 }
160 if got := res.Header().Get("Location"); got != "/demo/patches/1" {
161 t.Fatalf("upload redirected to %q", got)
162 }
163
164 res = do(t, h, admin, "GET", "/demo/patches/1", nil)
165 if res.Code != http.StatusOK {
166 t.Fatalf("detail status = %d", res.Code)
167 }
168 body := res.Body.String()
169 if !strings.Contains(body, "World line") {
170 t.Error("detail did not show the title")
171 }
172 if !strings.Contains(body, "Applies cleanly") {
173 t.Error("detail did not show the apply check result")
174 }
175
176 res = do(t, h, admin, "GET", "/demo/patches/1?tab=changes", nil)
177 body = res.Body.String()
178 if !strings.Contains(body, "Add a world line") {
179 t.Error("changes tab did not show the commit subject")
180 }
181 if !strings.Contains(body, "README.md") || !strings.Contains(body, "world") {
182 t.Error("changes tab did not render the diff")
183 }
184
185 ref, err := s.DB.PatchRefByNumber(ctx, 1, 1)
186 if err != nil || ref == nil {
187 t.Fatalf("patch lookup failed: %v", err)
188 }
189 res = postPatchForm(t, h, admin, "/demo/patches/1/merge", "version="+ref.Version)
190 if res.Code != http.StatusFound {
191 t.Fatalf("merge status = %d, body %s", res.Code, res.Body.String())
192 }
193
194 commits, err := s.Git.Log(ctx, "demo", "HEAD", 5, 0)
195 if err != nil {
196 t.Fatal(err)
197 }
198 if len(commits) != 2 || commits[0].Subject != "Add a world line" {
199 t.Fatalf("log after merge = %+v", commits)
200 }
201 if commits[0].Author != "Patch Author" {
202 t.Errorf("merge used author %q", commits[0].Author)
203 }
204
205 res = do(t, h, admin, "GET", "/demo/patches", nil)
206 if !strings.Contains(res.Body.String(), "No open patches.") {
207 t.Error("merged patch still shows in the open list")
208 }
209}
210
211// postPatchForm sends a urlencoded body as the given user.
212func postPatchForm(t *testing.T, h http.Handler, user *db.SessionUser, target, body string) *httptest.ResponseRecorder {
213 t.Helper()
214 req := httptest.NewRequest("POST", target, strings.NewReader(body))
215 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
216 if user != nil {
217 req = req.WithContext(context.WithValue(req.Context(), userKey, user))
218 }
219 w := httptest.NewRecorder()
220 h.ServeHTTP(w, req)
221 return w
222}
223
224func TestPatchRejectsNonPatchFile(t *testing.T) {
225 h, _, admin := patchTestServer(t)
226 res := postPatchFile(t, h, admin, "/demo/patches",
227 map[string]string{"title": "Bogus"}, "just some text\n")
228 if res.Code != http.StatusOK {
229 t.Fatalf("status = %d", res.Code)
230 }
231 if !strings.Contains(res.Body.String(), "does not appear to be a valid patch file") {
232 t.Error("upload did not report an invalid patch file")
233 }
234}
235