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/markdown" ) // releaseTestServer builds a server with a repo that has one commit and the // tag v1.0.0, plus an admin session user. func releaseTestServer(t *testing.T) (http.Handler, *Server, *db.SessionUser) { t.Helper() for _, bin := range []string{"git", "ssh-keygen"} { if _, err := exec.LookPath(bin); err != nil { t.Skipf("%s not installed", bin) } } ctx := context.Background() dir := t.TempDir() 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) } cfg := &config.Config{ DataDir: dir, BaseURL: "http://localhost:3000", OwnerDisplayName: "Admin", CommitterName: "Admin", CommitterEmail: "admin@localhost", MaxTitleBytes: 500, MaxTextBodyBytes: 100000, MaxUploadBytes: 10 << 20, MaxConcurrentArchives: 2, RateLimitDisabled: true, SSHHostKeyPath: filepath.Join(dir, "ssh_host_key"), } s := &Server{Cfg: cfg, DB: d, MD: markdown.New(), Git: gitcmd.New(cfg)} if err := s.Git.EnsureSigningSetup(); err != nil { t.Fatal(err) } if err := s.Git.Init(ctx, "demo", "main"); err != nil { t.Fatal(err) } who := gitcmd.Ident{Name: "Admin", Email: "admin@localhost"} if _, err := s.Git.EditFile(ctx, "demo", "main", "", "hello.txt", []byte("hello\n"), "init", who); err != nil { t.Fatal(err) } if err := s.Git.CreateTag(ctx, "demo", "v1.0.0", "main", "tag it", who); err != nil { t.Fatal(err) } r := chi.NewRouter() s.releaseRoutes(r) return r, s, &db.SessionUser{ID: admin.ID, Username: db.AdminUsername, IsAdmin: true} } // multipartCreate builds the release creation body with one attached file. func multipartCreate(t *testing.T, fields map[string]string, filename string, content []byte) (string, *bytes.Buffer) { 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) } } if filename != "" { part, err := mw.CreateFormFile("files", filename) if err != nil { t.Fatal(err) } part.Write(content) } if err := mw.Close(); err != nil { t.Fatal(err) } return mw.FormDataContentType(), &body } func TestReleaseFlow(t *testing.T) { h, s, admin := releaseTestServer(t) assetBody := []byte("binary-like content for testing\n") ctype, body := multipartCreate(t, map[string]string{ "name": "Source release", "notes": "Initial **stable** release.", "include_source_code": "on", "create_tag": "on", "tag_name": "v1.1.0", "revision": "main", }, "release-asset.txt", assetBody) req := httptest.NewRequest("POST", "/demo/releases", body) req.Header.Set("Content-Type", ctype) req = req.WithContext(context.WithValue(req.Context(), userKey, admin)) res := httptest.NewRecorder() h.ServeHTTP(res, req) if res.Code != http.StatusFound { t.Fatalf("create status = %d, body %s", res.Code, res.Body.String()) } loc := res.Header().Get("Location") if loc != "/demo/releases/1" { t.Fatalf("Location = %q", loc) } dir := s.releaseDir(1) for _, name := range []string{ "source/demo-v1.1.0.zip", "source/demo-v1.1.0.tar.gz", "source/demo-v1.1.0.tar.zst", "assets/release-asset.txt", } { if _, err := os.Stat(filepath.Join(dir, name)); err != nil { t.Errorf("missing %s: %v", name, err) } } res = do(t, h, admin, "GET", loc, nil) if res.Code != http.StatusOK { t.Fatalf("detail status = %d", res.Code) } page := res.Body.String() for _, want := range []string{ "demo-v1.1.0.zip", "demo-v1.1.0.tar.gz", "demo-v1.1.0.tar.zst", "release-asset.txt", "Initial stable release.", } { if !strings.Contains(page, want) { t.Errorf("detail page missing %q", want) } } res = do(t, h, admin, "GET", "/demo/releases/1/assets/release-asset.txt", nil) if res.Code != http.StatusOK { t.Fatalf("asset download status = %d", res.Code) } if !bytes.Equal(res.Body.Bytes(), assetBody) { t.Errorf("asset body = %q", res.Body.String()) } if cd := res.Header().Get("Content-Disposition"); !strings.Contains(cd, "release-asset.txt") { t.Errorf("Content-Disposition = %q", cd) } res = do(t, h, admin, "GET", "/demo/releases/1/source/demo-v1.1.0.zip", nil) if res.Code != http.StatusOK { t.Fatalf("source download status = %d", res.Code) } res = do(t, h, admin, "GET", "/demo/releases", nil) if !strings.Contains(res.Body.String(), "Source release") { t.Error("list page does not show the release") } res = do(t, h, admin, "POST", "/demo/releases/1/delete", nil) if res.Code != http.StatusFound { t.Fatalf("delete status = %d", res.Code) } if _, err := os.Stat(dir); !os.IsNotExist(err) { t.Errorf("release directory still exists: %v", err) } } func TestReleaseValidation(t *testing.T) { h, _, admin := releaseTestServer(t) ctype, body := multipartCreate(t, map[string]string{}, "", nil) req := httptest.NewRequest("POST", "/demo/releases", body) req.Header.Set("Content-Type", ctype) req = req.WithContext(context.WithValue(req.Context(), userKey, admin)) res := httptest.NewRecorder() h.ServeHTTP(res, req) if !strings.Contains(res.Body.String(), "Release title is required") { t.Error("missing title error not shown") } ctype, body = multipartCreate(t, map[string]string{ "name": "Dup", "create_tag": "on", "tag_name": "v1.0.0", "revision": "main", }, "", nil) req = httptest.NewRequest("POST", "/demo/releases", body) req.Header.Set("Content-Type", ctype) req = req.WithContext(context.WithValue(req.Context(), userKey, admin)) res = httptest.NewRecorder() h.ServeHTTP(res, req) if !strings.Contains(res.Body.String(), "already exists") { t.Error("duplicate tag error not shown") } } func TestSyncReleases(t *testing.T) { _, s, _ := releaseTestServer(t) ctx := context.Background() // A directory with no row is removed. orphan := s.releaseDir(42) if err := os.MkdirAll(orphan, 0o755); err != nil { t.Fatal(err) } // A release with assets but no directory loses its row. id, err := s.DB.CreateRelease(ctx, 1, nil, "Stale", nil, false, db.NowISO()) if err != nil { t.Fatal(err) } if err := s.DB.AddReleaseAssets(ctx, id, []db.NewAsset{{Filename: "gone.txt", Size: 3}}, db.NowISO()); err != nil { t.Fatal(err) } if err := s.SyncReleases(ctx); err != nil { t.Fatal(err) } if _, err := os.Stat(orphan); !os.IsNotExist(err) { t.Error("orphaned directory was not removed") } rel, err := s.DB.ReleaseInRepo(ctx, 1, id) if err != nil { t.Fatal(err) } if rel != nil { t.Error("stale release row was not removed") } }