package web import ( "bytes" "context" "mime/multipart" "net/http" "net/http/httptest" "os" "os/exec" "path/filepath" "strings" "testing" "github.com/go-chi/chi/v5" "hearthforge/internal/config" "hearthforge/internal/db" "hearthforge/internal/gitcmd" "hearthforge/internal/highlight" "hearthforge/internal/markdown" ) // patchTestServer builds a server with a bare repo that has one commit. func patchTestServer(t *testing.T) (http.Handler, *Server, *db.SessionUser) { t.Helper() if _, err := exec.LookPath("git"); err != nil { t.Skip("git not installed") } ctx := context.Background() dir := t.TempDir() cfg := &config.Config{ DataDir: dir, OwnerDisplayName: "Admin", MaxTitleBytes: 500, MaxTextBodyBytes: 100000, MaxUploadBytes: 10 << 20, MaxUserUploadBytes: 2 << 20, CommitterName: "Hearthforge", CommitterEmail: "forge@example.com", MaxConcurrentArchives: 2, RateLimitDisabled: true, } if err := os.MkdirAll(cfg.ReposDir(), 0o755); err != nil { t.Fatal(err) } // Every commit is ssh-signed with the host key, so the test needs one. cfg.SSHHostKeyPath = filepath.Join(dir, "ssh_host_key") if _, err := exec.LookPath("ssh-keygen"); err != nil { t.Skip("ssh-keygen not installed") } if out, err := exec.Command("ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", cfg.SSHHostKeyPath).CombinedOutput(); err != nil { t.Fatalf("ssh-keygen: %v\n%s", err, out) } d, err := db.Open(filepath.Join(dir, "hearthforge.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { d.Close() }) if _, err := d.InitAdmin(ctx, "hunter2"); err != nil { t.Fatal(err) } admin, err := d.UserByName(ctx, db.AdminUsername) if err != nil || admin == nil { t.Fatalf("admin lookup failed: %v", err) } if _, err := d.CreateRepo(ctx, "demo", nil, false, "main", db.NowISO()); err != nil { t.Fatal(err) } s := &Server{ Cfg: cfg, DB: d, MD: markdown.New(), HL: highlight.New(512 << 10), Git: gitcmd.New(cfg), Patches: gitcmd.NewPatchCache(), } if err := s.Git.Init(ctx, "demo", "main"); err != nil { t.Fatal(err) } who := gitcmd.Ident{Name: "Admin", Email: "admin@example.com"} if _, err := s.Git.EditFile(ctx, "demo", "main", "", "", "README.md", []byte("hello\n"), "init", who); err != nil { t.Fatal(err) } r := chi.NewRouter() s.patchRoutes(r) return r, s, &db.SessionUser{ID: admin.ID, Username: db.AdminUsername, IsAdmin: true} } // formatPatch clones the bare repo, adds a commit and returns its patch file. func formatPatch(t *testing.T, s *Server) string { t.Helper() work := filepath.Join(t.TempDir(), "clone") run := func(cwd string, args ...string) string { t.Helper() cmd := exec.Command("git", args...) cmd.Dir = cwd cmd.Env = append(os.Environ(), "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null", "GIT_AUTHOR_NAME=Patch Author", "GIT_AUTHOR_EMAIL=patcher@example.com", "GIT_COMMITTER_NAME=Patch Author", "GIT_COMMITTER_EMAIL=patcher@example.com") out, err := cmd.Output() if err != nil { t.Fatalf("git %v: %v", args, err) } return string(out) } run(filepath.Dir(work), "clone", "-q", s.Git.RepoPath("demo"), work) if err := os.WriteFile(filepath.Join(work, "README.md"), []byte("hello\nworld\n"), 0o644); err != nil { t.Fatal(err) } run(work, "add", "-A") run(work, "commit", "-q", "-m", "Add a world line") return run(work, "format-patch", "-1", "--stdout") } // postPatchFile sends a multipart form with the fields and the patch file. func postPatchFile(t *testing.T, h http.Handler, user *db.SessionUser, target string, fields map[string]string, patch string, ) *httptest.ResponseRecorder { t.Helper() var body bytes.Buffer mw := multipart.NewWriter(&body) for k, v := range fields { if err := mw.WriteField(k, v); err != nil { t.Fatal(err) } } part, err := mw.CreateFormFile("patch_file", "change.patch") if err != nil { t.Fatal(err) } part.Write([]byte(patch)) mw.Close() req := httptest.NewRequest("POST", target, &body) req.Header.Set("Content-Type", mw.FormDataContentType()) if user != nil { req = req.WithContext(context.WithValue(req.Context(), userKey, user)) } w := httptest.NewRecorder() h.ServeHTTP(w, req) return w } func TestPatchUploadDetailAndMerge(t *testing.T) { h, s, admin := patchTestServer(t) ctx := context.Background() patch := formatPatch(t, s) res := postPatchFile(t, h, admin, "/demo/patches", map[string]string{"title": "World line", "description": "Adds a line."}, patch) if res.Code != http.StatusFound { t.Fatalf("upload status = %d, body %s", res.Code, res.Body.String()) } if got := res.Header().Get("Location"); got != "/demo/patches/1" { t.Fatalf("upload redirected to %q", got) } res = do(t, h, admin, "GET", "/demo/patches/1", nil) if res.Code != http.StatusOK { t.Fatalf("detail status = %d", res.Code) } body := res.Body.String() if !strings.Contains(body, "World line") { t.Error("detail did not show the title") } if !strings.Contains(body, "Applies cleanly") { t.Error("detail did not show the apply check result") } res = do(t, h, admin, "GET", "/demo/patches/1?tab=changes", nil) body = res.Body.String() if !strings.Contains(body, "Add a world line") { t.Error("changes tab did not show the commit subject") } if !strings.Contains(body, "README.md") || !strings.Contains(body, "world") { t.Error("changes tab did not render the diff") } ref, err := s.DB.PatchRefByNumber(ctx, 1, 1) if err != nil || ref == nil { t.Fatalf("patch lookup failed: %v", err) } res = postPatchForm(t, h, admin, "/demo/patches/1/merge", "version="+ref.Version) if res.Code != http.StatusFound { t.Fatalf("merge status = %d, body %s", res.Code, res.Body.String()) } commits, err := s.Git.Log(ctx, "demo", "HEAD", 5, 0) if err != nil { t.Fatal(err) } if len(commits) != 2 || commits[0].Subject != "Add a world line" { t.Fatalf("log after merge = %+v", commits) } if commits[0].Author != "Patch Author" { t.Errorf("merge used author %q", commits[0].Author) } res = do(t, h, admin, "GET", "/demo/patches", nil) if !strings.Contains(res.Body.String(), "No open patches.") { t.Error("merged patch still shows in the open list") } } // postPatchForm sends a urlencoded body as the given user. func postPatchForm(t *testing.T, h http.Handler, user *db.SessionUser, target, body string) *httptest.ResponseRecorder { t.Helper() req := httptest.NewRequest("POST", target, strings.NewReader(body)) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") if user != nil { req = req.WithContext(context.WithValue(req.Context(), userKey, user)) } w := httptest.NewRecorder() h.ServeHTTP(w, req) return w } func TestPatchRejectsNonPatchFile(t *testing.T) { h, _, admin := patchTestServer(t) res := postPatchFile(t, h, admin, "/demo/patches", map[string]string{"title": "Bogus"}, "just some text\n") if res.Code != http.StatusOK { t.Fatalf("status = %d", res.Code) } if !strings.Contains(res.Body.String(), "does not appear to be a valid patch file") { t.Error("upload did not report an invalid patch file") } }