package handlers import ( "bytes" "context" "fmt" "io" "mime/multipart" "net/http" "net/http/httptest" "net/url" "os" "path/filepath" "strings" "testing" "polar/internal/git" "polar/internal/web/middleware" ) // TestReleaseAssetsUploadDownloadDelete walks the full release-asset flow: // multipart upload at release creation, listing, download, deletion and // cleanup when the release itself is removed. func TestReleaseAssetsUploadDownloadDelete(t *testing.T) { app, authSvc, userSvc, repoSvc := setupTestApp(t) ctx := context.Background() u, err := userSvc.Register(ctx, "alice", "alice@example.com", "strongpassword", true) if err != nil { t.Fatalf("register: %v", err) } sess, err := authSvc.CreateSession(ctx, u.ID) if err != nil { t.Fatalf("create session: %v", err) } r, err := repoSvc.Create(ctx, u, "engine", "Analytical Engine", false) if err != nil { t.Fatalf("create repo: %v", err) } diskPath, err := repoSvc.DiskPath("alice", "engine") if err != nil { t.Fatalf("disk path: %v", err) } branch, _ := git.EnsureDefaultBranch(ctx, diskPath, r.DefaultBranch) if _, err := git.WriteFilesCommit(ctx, diskPath, branch, []git.FileToWrite{ {Path: "README.md", Content: []byte("# engine")}, }, "Initial commit", "alice", "alice@example.com"); err != nil { t.Fatalf("seed commit: %v", err) } // The release form must offer the multipart asset picker. formReq := httptest.NewRequest(http.MethodGet, "/alice/engine/releases/new", nil) formReq.AddCookie(&http.Cookie{Name: middleware.SessionCookieName, Value: sess.ID}) formResp, err := app.Test(formReq, 5000) if err != nil { t.Fatalf("GET release form: %v", err) } formBody, _ := io.ReadAll(formResp.Body) formResp.Body.Close() if formResp.StatusCode != http.StatusOK || !strings.Contains(string(formBody), "multipart/form-data") || !strings.Contains(string(formBody), `name="assets"`) { t.Fatalf("release form missing asset upload field (status %d)", formResp.StatusCode) } // 1. Create a release with two uploaded assets (multipart form). var buf bytes.Buffer w := multipart.NewWriter(&buf) fields := map[string]string{ "_csrf": sess.CSRFToken, "tag_name": "v1.0.0", "title": "First release", "body": "Release notes", "target_ref": branch, } for k, v := range fields { if err := w.WriteField(k, v); err != nil { t.Fatalf("write field %s: %v", k, err) } } for name, content := range map[string]string{ "polar-linux-amd64.tar.gz": "binary-linux", "../../evil.sh": "#!/bin/sh", } { fw, err := w.CreateFormFile("assets", name) if err != nil { t.Fatalf("create form file: %v", err) } if _, err := fw.Write([]byte(content)); err != nil { t.Fatalf("write form file: %v", err) } } w.Close() req := httptest.NewRequest(http.MethodPost, "/alice/engine/releases/new", &buf) req.Header.Set("Content-Type", w.FormDataContentType()) req.AddCookie(&http.Cookie{Name: middleware.SessionCookieName, Value: sess.ID}) resp, err := app.Test(req, 20000) if err != nil { t.Fatalf("POST release: %v", err) } if resp.StatusCode != http.StatusSeeOther { body, _ := io.ReadAll(resp.Body) t.Fatalf("expected 303 after release creation, got %d: %s", resp.StatusCode, body) } rel, err := repoSvc.GetRelease(ctx, r.ID, "v1.0.0") if err != nil { t.Fatalf("get release: %v", err) } byRelease, err := repoSvc.ListReleaseAssetsByRepo(ctx, r.ID) if err != nil { t.Fatalf("list assets: %v", err) } assets := byRelease[rel.ID] if len(assets) != 2 { t.Fatalf("expected 2 assets, got %d", len(assets)) } // Path traversal must be neutralized: "../../evil.sh" becomes "evil.sh". byName := map[string]string{} for _, a := range assets { byName[a.Filename] = a.StoredName } if _, ok := byName["evil.sh"]; !ok { t.Fatalf("expected sanitized filename evil.sh, got %v", byName) } // 2. Releases page lists both files. code, page := getPage(t, app, "/alice/engine/releases") if code != http.StatusOK { t.Fatalf("releases page status %d", code) } for _, name := range []string{"polar-linux-amd64.tar.gz", "evil.sh"} { if !strings.Contains(page, name) { t.Fatalf("releases page missing asset %q", name) } } // 3. Download returns content with an attachment disposition. var downloaded int64 for _, a := range assets { if a.Filename != "polar-linux-amd64.tar.gz" { continue } downloaded = a.ID dlReq := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/alice/engine/releases/%d/assets/%d/download", rel.ID, a.ID), nil) dlResp, err := app.Test(dlReq, 5000) if err != nil { t.Fatalf("download asset: %v", err) } if dlResp.StatusCode != http.StatusOK { dlResp.Body.Close() t.Fatalf("download status %d", dlResp.StatusCode) } data, _ := io.ReadAll(dlResp.Body) dlResp.Body.Close() if string(data) != "binary-linux" { t.Fatalf("unexpected asset content %q", data) } if cd := dlResp.Header.Get("Content-Disposition"); !strings.Contains(cd, "polar-linux-amd64.tar.gz") { t.Fatalf("unexpected content-disposition %q", cd) } rangeReq := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/alice/engine/releases/%d/assets/%d/download", rel.ID, a.ID), nil) rangeReq.Header.Set("Range", "bytes=0-5") rangeResp, err := app.Test(rangeReq, 5000) if err != nil { t.Fatalf("range download: %v", err) } if rangeResp.StatusCode != http.StatusPartialContent { rangeResp.Body.Close() t.Fatalf("expected 206 for range request, got %d", rangeResp.StatusCode) } rangeData, _ := io.ReadAll(rangeResp.Body) rangeResp.Body.Close() if string(rangeData) != "binary" { t.Fatalf("unexpected range content %q", rangeData) } } if downloaded == 0 { t.Fatalf("asset not found for download") } // full download + range request both count. if a, err := repoSvc.GetReleaseAsset(ctx, downloaded); err != nil || a.DownloadCount != 2 { t.Fatalf("download counter not incremented: %+v err=%v", a, err) } // 4. Deleting a single asset removes the file from disk. delBody := "_csrf=" + url.QueryEscape(sess.CSRFToken) delReq := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/alice/engine/releases/%d/assets/%d/delete", rel.ID, downloaded), strings.NewReader(delBody)) delReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") delReq.AddCookie(&http.Cookie{Name: middleware.SessionCookieName, Value: sess.ID}) delResp, err := app.Test(delReq, 5000) if err != nil { t.Fatalf("delete asset: %v", err) } if delResp.StatusCode != http.StatusSeeOther { t.Fatalf("expected 303 after asset deletion, got %d", delResp.StatusCode) } dir, err := repoSvc.ReleaseAssetsDir("alice", "engine", rel.ID) if err != nil { t.Fatalf("assets dir: %v", err) } if _, err := os.Stat(filepath.Join(dir, byName["polar-linux-amd64.tar.gz"])); !os.IsNotExist(err) { t.Fatalf("asset file still on disk after deletion: %v", err) } if _, err := repoSvc.GetReleaseAsset(ctx, downloaded); err == nil { t.Fatalf("asset row still present after deletion") } // 5. Deleting the release removes the remaining asset files. relDelReq := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/alice/engine/releases/%d/delete", rel.ID), strings.NewReader(delBody)) relDelReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") relDelReq.AddCookie(&http.Cookie{Name: middleware.SessionCookieName, Value: sess.ID}) relDelResp, err := app.Test(relDelReq, 5000) if err != nil { t.Fatalf("delete release: %v", err) } if relDelResp.StatusCode != http.StatusSeeOther { t.Fatalf("expected 303 after release deletion, got %d", relDelResp.StatusCode) } if _, err := os.Stat(dir); !os.IsNotExist(err) { t.Fatalf("release assets dir still on disk after release deletion: %v", err) } }