rin
Публичный Open-source lightweight git hosting.
release_assets_test.go · 231 строка · 7.6 KB
Raw
1 package handlers
2
3 import (
4 "bytes"
5 "context"
6 "fmt"
7 "io"
8 "mime/multipart"
9 "net/http"
10 "net/http/httptest"
11 "net/url"
12 "os"
13 "path/filepath"
14 "strings"
15 "testing"
16
17 "polar/internal/git"
18 "polar/internal/web/middleware"
19 )
20
21 // TestReleaseAssetsUploadDownloadDelete walks the full release-asset flow:
22 // multipart upload at release creation, listing, download, deletion and
23 // cleanup when the release itself is removed.
24 func TestReleaseAssetsUploadDownloadDelete(t *testing.T) {
25 app, authSvc, userSvc, repoSvc := setupTestApp(t)
26 ctx := context.Background()
27
28 u, err := userSvc.Register(ctx, "alice", "alice@example.com", "strongpassword", true)
29 if err != nil {
30 t.Fatalf("register: %v", err)
31 }
32 sess, err := authSvc.CreateSession(ctx, u.ID)
33 if err != nil {
34 t.Fatalf("create session: %v", err)
35 }
36 r, err := repoSvc.Create(ctx, u, "engine", "Analytical Engine", false)
37 if err != nil {
38 t.Fatalf("create repo: %v", err)
39 }
40
41 diskPath, err := repoSvc.DiskPath("alice", "engine")
42 if err != nil {
43 t.Fatalf("disk path: %v", err)
44 }
45 branch, _ := git.EnsureDefaultBranch(ctx, diskPath, r.DefaultBranch)
46 if _, err := git.WriteFilesCommit(ctx, diskPath, branch, []git.FileToWrite{
47 {Path: "README.md", Content: []byte("# engine")},
48 }, "Initial commit", "alice", "alice@example.com"); err != nil {
49 t.Fatalf("seed commit: %v", err)
50 }
51
52 // The release form must offer the multipart asset picker.
53 formReq := httptest.NewRequest(http.MethodGet, "/alice/engine/releases/new", nil)
54 formReq.AddCookie(&http.Cookie{Name: middleware.SessionCookieName, Value: sess.ID})
55 formResp, err := app.Test(formReq, 5000)
56 if err != nil {
57 t.Fatalf("GET release form: %v", err)
58 }
59 formBody, _ := io.ReadAll(formResp.Body)
60 formResp.Body.Close()
61 if formResp.StatusCode != http.StatusOK ||
62 !strings.Contains(string(formBody), "multipart/form-data") ||
63 !strings.Contains(string(formBody), `name="assets"`) {
64 t.Fatalf("release form missing asset upload field (status %d)", formResp.StatusCode)
65 }
66
67 // 1. Create a release with two uploaded assets (multipart form).
68 var buf bytes.Buffer
69 w := multipart.NewWriter(&buf)
70 fields := map[string]string{
71 "_csrf": sess.CSRFToken,
72 "tag_name": "v1.0.0",
73 "title": "First release",
74 "body": "Release notes",
75 "target_ref": branch,
76 }
77 for k, v := range fields {
78 if err := w.WriteField(k, v); err != nil {
79 t.Fatalf("write field %s: %v", k, err)
80 }
81 }
82 for name, content := range map[string]string{
83 "polar-linux-amd64.tar.gz": "binary-linux",
84 "../../evil.sh": "#!/bin/sh",
85 } {
86 fw, err := w.CreateFormFile("assets", name)
87 if err != nil {
88 t.Fatalf("create form file: %v", err)
89 }
90 if _, err := fw.Write([]byte(content)); err != nil {
91 t.Fatalf("write form file: %v", err)
92 }
93 }
94 w.Close()
95
96 req := httptest.NewRequest(http.MethodPost, "/alice/engine/releases/new", &buf)
97 req.Header.Set("Content-Type", w.FormDataContentType())
98 req.AddCookie(&http.Cookie{Name: middleware.SessionCookieName, Value: sess.ID})
99 resp, err := app.Test(req, 20000)
100 if err != nil {
101 t.Fatalf("POST release: %v", err)
102 }
103 if resp.StatusCode != http.StatusSeeOther {
104 body, _ := io.ReadAll(resp.Body)
105 t.Fatalf("expected 303 after release creation, got %d: %s", resp.StatusCode, body)
106 }
107
108 rel, err := repoSvc.GetRelease(ctx, r.ID, "v1.0.0")
109 if err != nil {
110 t.Fatalf("get release: %v", err)
111 }
112 byRelease, err := repoSvc.ListReleaseAssetsByRepo(ctx, r.ID)
113 if err != nil {
114 t.Fatalf("list assets: %v", err)
115 }
116 assets := byRelease[rel.ID]
117 if len(assets) != 2 {
118 t.Fatalf("expected 2 assets, got %d", len(assets))
119 }
120
121 // Path traversal must be neutralized: "../../evil.sh" becomes "evil.sh".
122 byName := map[string]string{}
123 for _, a := range assets {
124 byName[a.Filename] = a.StoredName
125 }
126 if _, ok := byName["evil.sh"]; !ok {
127 t.Fatalf("expected sanitized filename evil.sh, got %v", byName)
128 }
129
130 // 2. Releases page lists both files.
131 code, page := getPage(t, app, "/alice/engine/releases")
132 if code != http.StatusOK {
133 t.Fatalf("releases page status %d", code)
134 }
135 for _, name := range []string{"polar-linux-amd64.tar.gz", "evil.sh"} {
136 if !strings.Contains(page, name) {
137 t.Fatalf("releases page missing asset %q", name)
138 }
139 }
140
141 // 3. Download returns content with an attachment disposition.
142 var downloaded int64
143 for _, a := range assets {
144 if a.Filename != "polar-linux-amd64.tar.gz" {
145 continue
146 }
147 downloaded = a.ID
148 dlReq := httptest.NewRequest(http.MethodGet,
149 fmt.Sprintf("/alice/engine/releases/%d/assets/%d/download", rel.ID, a.ID), nil)
150 dlResp, err := app.Test(dlReq, 5000)
151 if err != nil {
152 t.Fatalf("download asset: %v", err)
153 }
154 if dlResp.StatusCode != http.StatusOK {
155 dlResp.Body.Close()
156 t.Fatalf("download status %d", dlResp.StatusCode)
157 }
158 data, _ := io.ReadAll(dlResp.Body)
159 dlResp.Body.Close()
160 if string(data) != "binary-linux" {
161 t.Fatalf("unexpected asset content %q", data)
162 }
163 if cd := dlResp.Header.Get("Content-Disposition"); !strings.Contains(cd, "polar-linux-amd64.tar.gz") {
164 t.Fatalf("unexpected content-disposition %q", cd)
165 }
166
167 rangeReq := httptest.NewRequest(http.MethodGet,
168 fmt.Sprintf("/alice/engine/releases/%d/assets/%d/download", rel.ID, a.ID), nil)
169 rangeReq.Header.Set("Range", "bytes=0-5")
170 rangeResp, err := app.Test(rangeReq, 5000)
171 if err != nil {
172 t.Fatalf("range download: %v", err)
173 }
174 if rangeResp.StatusCode != http.StatusPartialContent {
175 rangeResp.Body.Close()
176 t.Fatalf("expected 206 for range request, got %d", rangeResp.StatusCode)
177 }
178 rangeData, _ := io.ReadAll(rangeResp.Body)
179 rangeResp.Body.Close()
180 if string(rangeData) != "binary" {
181 t.Fatalf("unexpected range content %q", rangeData)
182 }
183 }
184 if downloaded == 0 {
185 t.Fatalf("asset not found for download")
186 }
187 // full download + range request both count.
188 if a, err := repoSvc.GetReleaseAsset(ctx, downloaded); err != nil || a.DownloadCount != 2 {
189 t.Fatalf("download counter not incremented: %+v err=%v", a, err)
190 }
191
192 // 4. Deleting a single asset removes the file from disk.
193 delBody := "_csrf=" + url.QueryEscape(sess.CSRFToken)
194 delReq := httptest.NewRequest(http.MethodPost,
195 fmt.Sprintf("/alice/engine/releases/%d/assets/%d/delete", rel.ID, downloaded), strings.NewReader(delBody))
196 delReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
197 delReq.AddCookie(&http.Cookie{Name: middleware.SessionCookieName, Value: sess.ID})
198 delResp, err := app.Test(delReq, 5000)
199 if err != nil {
200 t.Fatalf("delete asset: %v", err)
201 }
202 if delResp.StatusCode != http.StatusSeeOther {
203 t.Fatalf("expected 303 after asset deletion, got %d", delResp.StatusCode)
204 }
205 dir, err := repoSvc.ReleaseAssetsDir("alice", "engine", rel.ID)
206 if err != nil {
207 t.Fatalf("assets dir: %v", err)
208 }
209 if _, err := os.Stat(filepath.Join(dir, byName["polar-linux-amd64.tar.gz"])); !os.IsNotExist(err) {
210 t.Fatalf("asset file still on disk after deletion: %v", err)
211 }
212 if _, err := repoSvc.GetReleaseAsset(ctx, downloaded); err == nil {
213 t.Fatalf("asset row still present after deletion")
214 }
215
216 // 5. Deleting the release removes the remaining asset files.
217 relDelReq := httptest.NewRequest(http.MethodPost,
218 fmt.Sprintf("/alice/engine/releases/%d/delete", rel.ID), strings.NewReader(delBody))
219 relDelReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
220 relDelReq.AddCookie(&http.Cookie{Name: middleware.SessionCookieName, Value: sess.ID})
221 relDelResp, err := app.Test(relDelReq, 5000)
222 if err != nil {
223 t.Fatalf("delete release: %v", err)
224 }
225 if relDelResp.StatusCode != http.StatusSeeOther {
226 t.Fatalf("expected 303 after release deletion, got %d", relDelResp.StatusCode)
227 }
228 if _, err := os.Stat(dir); !os.IsNotExist(err) {
229 t.Fatalf("release assets dir still on disk after release deletion: %v", err)
230 }
231 }
232