releases_test.go
| 1 | package web |
| 2 | |
| 3 | import ( |
| 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. |
| 25 | func 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. |
| 85 | func 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 | |
| 107 | func 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 | |
| 189 | func 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 | |
| 215 | func 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 |