releases_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/markdown"
21)
22
23// releaseTestServer builds a server with a repo that has one commit and the
24// tag v1.0.0, plus an admin session user.
25func releaseTestServer(t *testing.T) (http.Handler, *Server, *db.SessionUser) {
26 t.Helper()
27 for _, bin := range []string{"git", "ssh-keygen"} {
28 if _, err := exec.LookPath(bin); err != nil {
29 t.Skipf("%s not installed", bin)
30 }
31 }
32 ctx := context.Background()
33 dir := t.TempDir()
34 d, err := db.Open(filepath.Join(dir, "hearthforge.db"))
35 if err != nil {
36 t.Fatal(err)
37 }
38 t.Cleanup(func() { d.Close() })
39 if _, err := d.InitAdmin(ctx, "hunter2"); err != nil {
40 t.Fatal(err)
41 }
42 admin, err := d.UserByName(ctx, db.AdminUsername)
43 if err != nil || admin == nil {
44 t.Fatalf("admin lookup failed: %v", err)
45 }
46 if _, err := d.CreateRepo(ctx, "demo", nil, false, "main", db.NowISO()); err != nil {
47 t.Fatal(err)
48 }
49
50 cfg := &config.Config{
51 DataDir: dir,
52 BaseURL: "http://localhost:3000",
53 OwnerDisplayName: "Admin",
54 CommitterName: "Admin",
55 CommitterEmail: "admin@localhost",
56 MaxTitleBytes: 500,
57 MaxTextBodyBytes: 100000,
58 MaxUploadBytes: 10 << 20,
59 MaxConcurrentArchives: 2,
60 RateLimitDisabled: true,
61 SSHHostKeyPath: filepath.Join(dir, "ssh_host_key"),
62 }
63 s := &Server{Cfg: cfg, DB: d, MD: markdown.New(), Git: gitcmd.New(cfg)}
64 if err := s.Git.EnsureSigningSetup(); err != nil {
65 t.Fatal(err)
66 }
67 if err := s.Git.Init(ctx, "demo", "main"); err != nil {
68 t.Fatal(err)
69 }
70 who := gitcmd.Ident{Name: "Admin", Email: "admin@localhost"}
71 if _, err := s.Git.EditFile(ctx, "demo", "main", "", "", "hello.txt",
72 []byte("hello\n"), "init", who); err != nil {
73 t.Fatal(err)
74 }
75 if err := s.Git.CreateTag(ctx, "demo", "v1.0.0", "main", "tag it", who); err != nil {
76 t.Fatal(err)
77 }
78
79 r := chi.NewRouter()
80 s.releaseRoutes(r)
81 return r, s, &db.SessionUser{ID: admin.ID, Username: db.AdminUsername, IsAdmin: true}
82}
83
84// multipartCreate builds the release creation body with one attached file.
85func multipartCreate(t *testing.T, fields map[string]string, filename string, content []byte) (string, *bytes.Buffer) {
86 t.Helper()
87 var body bytes.Buffer
88 mw := multipart.NewWriter(&body)
89 for k, v := range fields {
90 if err := mw.WriteField(k, v); err != nil {
91 t.Fatal(err)
92 }
93 }
94 if filename != "" {
95 part, err := mw.CreateFormFile("files", filename)
96 if err != nil {
97 t.Fatal(err)
98 }
99 part.Write(content)
100 }
101 if err := mw.Close(); err != nil {
102 t.Fatal(err)
103 }
104 return mw.FormDataContentType(), &body
105}
106
107func TestReleaseFlow(t *testing.T) {
108 h, s, admin := releaseTestServer(t)
109 assetBody := []byte("binary-like content for testing\n")
110
111 ctype, body := multipartCreate(t, map[string]string{
112 "name": "Source release",
113 "notes": "Initial **stable** release.",
114 "include_source_code": "on",
115 "create_tag": "on",
116 "tag_name": "v1.1.0",
117 "revision": "main",
118 }, "release-asset.txt", assetBody)
119
120 req := httptest.NewRequest("POST", "/demo/releases", body)
121 req.Header.Set("Content-Type", ctype)
122 req = req.WithContext(context.WithValue(req.Context(), userKey, admin))
123 res := httptest.NewRecorder()
124 h.ServeHTTP(res, req)
125 if res.Code != http.StatusFound {
126 t.Fatalf("create status = %d, body %s", res.Code, res.Body.String())
127 }
128 loc := res.Header().Get("Location")
129 if loc != "/demo/releases/1" {
130 t.Fatalf("Location = %q", loc)
131 }
132
133 dir := s.releaseDir(1)
134 for _, name := range []string{
135 "source/demo-v1.1.0.zip",
136 "source/demo-v1.1.0.tar.gz",
137 "source/demo-v1.1.0.tar.zst",
138 "assets/release-asset.txt",
139 } {
140 if _, err := os.Stat(filepath.Join(dir, name)); err != nil {
141 t.Errorf("missing %s: %v", name, err)
142 }
143 }
144
145 res = do(t, h, admin, "GET", loc, nil)
146 if res.Code != http.StatusOK {
147 t.Fatalf("detail status = %d", res.Code)
148 }
149 page := res.Body.String()
150 for _, want := range []string{
151 "demo-v1.1.0.zip", "demo-v1.1.0.tar.gz", "demo-v1.1.0.tar.zst",
152 "release-asset.txt", "Initial <strong>stable</strong> release.",
153 } {
154 if !strings.Contains(page, want) {
155 t.Errorf("detail page missing %q", want)
156 }
157 }
158
159 res = do(t, h, admin, "GET", "/demo/releases/1/assets/release-asset.txt", nil)
160 if res.Code != http.StatusOK {
161 t.Fatalf("asset download status = %d", res.Code)
162 }
163 if !bytes.Equal(res.Body.Bytes(), assetBody) {
164 t.Errorf("asset body = %q", res.Body.String())
165 }
166 if cd := res.Header().Get("Content-Disposition"); !strings.Contains(cd, "release-asset.txt") {
167 t.Errorf("Content-Disposition = %q", cd)
168 }
169
170 res = do(t, h, admin, "GET", "/demo/releases/1/source/demo-v1.1.0.zip", nil)
171 if res.Code != http.StatusOK {
172 t.Fatalf("source download status = %d", res.Code)
173 }
174
175 res = do(t, h, admin, "GET", "/demo/releases", nil)
176 if !strings.Contains(res.Body.String(), "Source release") {
177 t.Error("list page does not show the release")
178 }
179
180 res = do(t, h, admin, "POST", "/demo/releases/1/delete", nil)
181 if res.Code != http.StatusFound {
182 t.Fatalf("delete status = %d", res.Code)
183 }
184 if _, err := os.Stat(dir); !os.IsNotExist(err) {
185 t.Errorf("release directory still exists: %v", err)
186 }
187}
188
189func TestReleaseValidation(t *testing.T) {
190 h, _, admin := releaseTestServer(t)
191
192 ctype, body := multipartCreate(t, map[string]string{}, "", nil)
193 req := httptest.NewRequest("POST", "/demo/releases", body)
194 req.Header.Set("Content-Type", ctype)
195 req = req.WithContext(context.WithValue(req.Context(), userKey, admin))
196 res := httptest.NewRecorder()
197 h.ServeHTTP(res, req)
198 if !strings.Contains(res.Body.String(), "Release title is required") {
199 t.Error("missing title error not shown")
200 }
201
202 ctype, body = multipartCreate(t, map[string]string{
203 "name": "Dup", "create_tag": "on", "tag_name": "v1.0.0", "revision": "main",
204 }, "", nil)
205 req = httptest.NewRequest("POST", "/demo/releases", body)
206 req.Header.Set("Content-Type", ctype)
207 req = req.WithContext(context.WithValue(req.Context(), userKey, admin))
208 res = httptest.NewRecorder()
209 h.ServeHTTP(res, req)
210 if !strings.Contains(res.Body.String(), "already exists") {
211 t.Error("duplicate tag error not shown")
212 }
213}
214
215func TestSyncReleases(t *testing.T) {
216 _, s, _ := releaseTestServer(t)
217 ctx := context.Background()
218
219 // A directory with no row is removed.
220 orphan := s.releaseDir(42)
221 if err := os.MkdirAll(orphan, 0o755); err != nil {
222 t.Fatal(err)
223 }
224 // A release with assets but no directory loses its row.
225 id, err := s.DB.CreateRelease(ctx, 1, nil, "Stale", nil, false, db.NowISO())
226 if err != nil {
227 t.Fatal(err)
228 }
229 if err := s.DB.AddReleaseAssets(ctx, id, []db.NewAsset{{Filename: "gone.txt", Size: 3}}, db.NowISO()); err != nil {
230 t.Fatal(err)
231 }
232 if err := s.SyncReleases(ctx); err != nil {
233 t.Fatal(err)
234 }
235 if _, err := os.Stat(orphan); !os.IsNotExist(err) {
236 t.Error("orphaned directory was not removed")
237 }
238 rel, err := s.DB.ReleaseInRepo(ctx, 1, id)
239 if err != nil {
240 t.Fatal(err)
241 }
242 if rel != nil {
243 t.Error("stale release row was not removed")
244 }
245}
246