rin
Публичный Open-source lightweight git hosting.
release_assets_test.go · 216 строк · 7.0 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 // 1. Create a release with two uploaded assets (multipart form).
53 var buf bytes.Buffer
54 w := multipart.NewWriter(&buf)
55 fields := map[string]string{
56 "_csrf": sess.CSRFToken,
57 "tag_name": "v1.0.0",
58 "title": "First release",
59 "body": "Release notes",
60 "target_ref": branch,
61 }
62 for k, v := range fields {
63 if err := w.WriteField(k, v); err != nil {
64 t.Fatalf("write field %s: %v", k, err)
65 }
66 }
67 for name, content := range map[string]string{
68 "polar-linux-amd64.tar.gz": "binary-linux",
69 "../../evil.sh": "#!/bin/sh",
70 } {
71 fw, err := w.CreateFormFile("assets", name)
72 if err != nil {
73 t.Fatalf("create form file: %v", err)
74 }
75 if _, err := fw.Write([]byte(content)); err != nil {
76 t.Fatalf("write form file: %v", err)
77 }
78 }
79 w.Close()
80
81 req := httptest.NewRequest(http.MethodPost, "/alice/engine/releases/new", &buf)
82 req.Header.Set("Content-Type", w.FormDataContentType())
83 req.AddCookie(&http.Cookie{Name: middleware.SessionCookieName, Value: sess.ID})
84 resp, err := app.Test(req, 20000)
85 if err != nil {
86 t.Fatalf("POST release: %v", err)
87 }
88 if resp.StatusCode != http.StatusSeeOther {
89 body, _ := io.ReadAll(resp.Body)
90 t.Fatalf("expected 303 after release creation, got %d: %s", resp.StatusCode, body)
91 }
92
93 rel, err := repoSvc.GetRelease(ctx, r.ID, "v1.0.0")
94 if err != nil {
95 t.Fatalf("get release: %v", err)
96 }
97 byRelease, err := repoSvc.ListReleaseAssetsByRepo(ctx, r.ID)
98 if err != nil {
99 t.Fatalf("list assets: %v", err)
100 }
101 assets := byRelease[rel.ID]
102 if len(assets) != 2 {
103 t.Fatalf("expected 2 assets, got %d", len(assets))
104 }
105
106 // Path traversal must be neutralized: "../../evil.sh" becomes "evil.sh".
107 byName := map[string]string{}
108 for _, a := range assets {
109 byName[a.Filename] = a.StoredName
110 }
111 if _, ok := byName["evil.sh"]; !ok {
112 t.Fatalf("expected sanitized filename evil.sh, got %v", byName)
113 }
114
115 // 2. Releases page lists both files.
116 code, page := getPage(t, app, "/alice/engine/releases")
117 if code != http.StatusOK {
118 t.Fatalf("releases page status %d", code)
119 }
120 for _, name := range []string{"polar-linux-amd64.tar.gz", "evil.sh"} {
121 if !strings.Contains(page, name) {
122 t.Fatalf("releases page missing asset %q", name)
123 }
124 }
125
126 // 3. Download returns content with an attachment disposition.
127 var downloaded int64
128 for _, a := range assets {
129 if a.Filename != "polar-linux-amd64.tar.gz" {
130 continue
131 }
132 downloaded = a.ID
133 dlReq := httptest.NewRequest(http.MethodGet,
134 fmt.Sprintf("/alice/engine/releases/%d/assets/%d/download", rel.ID, a.ID), nil)
135 dlResp, err := app.Test(dlReq, 5000)
136 if err != nil {
137 t.Fatalf("download asset: %v", err)
138 }
139 if dlResp.StatusCode != http.StatusOK {
140 dlResp.Body.Close()
141 t.Fatalf("download status %d", dlResp.StatusCode)
142 }
143 data, _ := io.ReadAll(dlResp.Body)
144 dlResp.Body.Close()
145 if string(data) != "binary-linux" {
146 t.Fatalf("unexpected asset content %q", data)
147 }
148 if cd := dlResp.Header.Get("Content-Disposition"); !strings.Contains(cd, "polar-linux-amd64.tar.gz") {
149 t.Fatalf("unexpected content-disposition %q", cd)
150 }
151
152 rangeReq := httptest.NewRequest(http.MethodGet,
153 fmt.Sprintf("/alice/engine/releases/%d/assets/%d/download", rel.ID, a.ID), nil)
154 rangeReq.Header.Set("Range", "bytes=0-5")
155 rangeResp, err := app.Test(rangeReq, 5000)
156 if err != nil {
157 t.Fatalf("range download: %v", err)
158 }
159 if rangeResp.StatusCode != http.StatusPartialContent {
160 rangeResp.Body.Close()
161 t.Fatalf("expected 206 for range request, got %d", rangeResp.StatusCode)
162 }
163 rangeData, _ := io.ReadAll(rangeResp.Body)
164 rangeResp.Body.Close()
165 if string(rangeData) != "binary" {
166 t.Fatalf("unexpected range content %q", rangeData)
167 }
168 }
169 if downloaded == 0 {
170 t.Fatalf("asset not found for download")
171 }
172 // full download + range request both count.
173 if a, err := repoSvc.GetReleaseAsset(ctx, downloaded); err != nil || a.DownloadCount != 2 {
174 t.Fatalf("download counter not incremented: %+v err=%v", a, err)
175 }
176
177 // 4. Deleting a single asset removes the file from disk.
178 delBody := "_csrf=" + url.QueryEscape(sess.CSRFToken)
179 delReq := httptest.NewRequest(http.MethodPost,
180 fmt.Sprintf("/alice/engine/releases/%d/assets/%d/delete", rel.ID, downloaded), strings.NewReader(delBody))
181 delReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
182 delReq.AddCookie(&http.Cookie{Name: middleware.SessionCookieName, Value: sess.ID})
183 delResp, err := app.Test(delReq, 5000)
184 if err != nil {
185 t.Fatalf("delete asset: %v", err)
186 }
187 if delResp.StatusCode != http.StatusSeeOther {
188 t.Fatalf("expected 303 after asset deletion, got %d", delResp.StatusCode)
189 }
190 dir, err := repoSvc.ReleaseAssetsDir("alice", "engine", rel.ID)
191 if err != nil {
192 t.Fatalf("assets dir: %v", err)
193 }
194 if _, err := os.Stat(filepath.Join(dir, byName["polar-linux-amd64.tar.gz"])); !os.IsNotExist(err) {
195 t.Fatalf("asset file still on disk after deletion: %v", err)
196 }
197 if _, err := repoSvc.GetReleaseAsset(ctx, downloaded); err == nil {
198 t.Fatalf("asset row still present after deletion")
199 }
200
201 // 5. Deleting the release removes the remaining asset files.
202 relDelReq := httptest.NewRequest(http.MethodPost,
203 fmt.Sprintf("/alice/engine/releases/%d/delete", rel.ID), strings.NewReader(delBody))
204 relDelReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
205 relDelReq.AddCookie(&http.Cookie{Name: middleware.SessionCookieName, Value: sess.ID})
206 relDelResp, err := app.Test(relDelReq, 5000)
207 if err != nil {
208 t.Fatalf("delete release: %v", err)
209 }
210 if relDelResp.StatusCode != http.StatusSeeOther {
211 t.Fatalf("expected 303 after release deletion, got %d", relDelResp.StatusCode)
212 }
213 if _, err := os.Stat(dir); !os.IsNotExist(err) {
214 t.Fatalf("release assets dir still on disk after release deletion: %v", err)
215 }
216 }
217