github.com/ethanhsieh/snapd@v0.0.0-20210615102523-3db9b8e4edc5/daemon/api_download_test.go (about) 1 // -*- Mode: Go; indent-tabs-mode: t -*- 2 3 /* 4 * Copyright (C) 2019 Canonical Ltd 5 * 6 * This program is free software: you can redistribute it and/or modify 7 * it under the terms of the GNU General Public License version 3 as 8 * published by the Free Software Foundation. 9 * 10 * This program is distributed in the hope that it will be useful, 11 * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 * GNU General Public License for more details. 14 * 15 * You should have received a copy of the GNU General Public License 16 * along with this program. If not, see <http://www.gnu.org/licenses/>. 17 * 18 */ 19 20 package daemon_test 21 22 import ( 23 "bytes" 24 "context" 25 "encoding/base64" 26 "fmt" 27 "io" 28 "io/ioutil" 29 "net/http" 30 "net/http/httptest" 31 "strings" 32 33 "gopkg.in/check.v1" 34 35 "github.com/snapcore/snapd/daemon" 36 "github.com/snapcore/snapd/overlord/auth" 37 "github.com/snapcore/snapd/snap" 38 "github.com/snapcore/snapd/store" 39 ) 40 41 var _ = check.Suite(&snapDownloadSuite{}) 42 43 type snapDownloadSuite struct { 44 apiBaseSuite 45 46 snaps []string 47 } 48 49 func (s *snapDownloadSuite) SetUpTest(c *check.C) { 50 s.apiBaseSuite.SetUpTest(c) 51 52 s.snaps = nil 53 54 s.daemonWithStore(c, s) 55 56 s.expectWriteAccess(daemon.AuthenticatedAccess{Polkit: "io.snapcraft.snapd.manage"}) 57 } 58 59 var snapContent = "SNAP" 60 61 var storeSnaps = map[string]*snap.Info{ 62 "bar": { 63 SideInfo: snap.SideInfo{ 64 RealName: "bar", 65 Revision: snap.R(1), 66 }, 67 DownloadInfo: snap.DownloadInfo{ 68 Size: int64(len(snapContent)), 69 AnonDownloadURL: "http://localhost/bar", 70 Sha3_384: "sha3sha3sha3", 71 }, 72 }, 73 "edge-bar": { 74 SideInfo: snap.SideInfo{ 75 RealName: "edge-bar", 76 Revision: snap.R(1), 77 // this is the channel we expect in the test 78 Channel: "edge", 79 }, 80 DownloadInfo: snap.DownloadInfo{ 81 Size: int64(len(snapContent)), 82 AnonDownloadURL: "http://localhost/edge-bar", 83 Sha3_384: "sha3sha3sha3", 84 }, 85 }, 86 "rev7-bar": { 87 SideInfo: snap.SideInfo{ 88 RealName: "rev7-bar", 89 // this is the revision we expect in the test 90 Revision: snap.R(7), 91 }, 92 DownloadInfo: snap.DownloadInfo{ 93 Size: int64(len(snapContent)), 94 AnonDownloadURL: "http://localhost/rev7-bar", 95 Sha3_384: "sha3sha3sha3", 96 }, 97 }, 98 "download-error-trigger-snap": { 99 DownloadInfo: snap.DownloadInfo{ 100 Size: 100, 101 AnonDownloadURL: "http://localhost/foo", 102 Sha3_384: "sha3sha3sha3", 103 }, 104 }, 105 "foo-resume-3": { 106 SideInfo: snap.SideInfo{ 107 RealName: "foo-resume-3", 108 Revision: snap.R(1), 109 }, 110 DownloadInfo: snap.DownloadInfo{ 111 Size: int64(len(snapContent)), 112 AnonDownloadURL: "http://localhost/foo-resume-3", 113 Sha3_384: "sha3sha3sha3", 114 }, 115 }, 116 } 117 118 func (s *snapDownloadSuite) SnapAction(ctx context.Context, currentSnaps []*store.CurrentSnap, actions []*store.SnapAction, assertQuery store.AssertionQuery, user *auth.UserState, opts *store.RefreshOptions) ([]store.SnapActionResult, []store.AssertionResult, error) { 119 s.pokeStateLock() 120 121 if assertQuery != nil { 122 panic("no assertion query support") 123 } 124 if len(actions) != 1 { 125 panic(fmt.Sprintf("unexpected amount of actions: %v", len(actions))) 126 } 127 action := actions[0] 128 if action.Action != "download" { 129 panic(fmt.Sprintf("unexpected action: %q", action.Action)) 130 } 131 info, ok := storeSnaps[action.InstanceName] 132 if !ok { 133 return nil, nil, store.ErrSnapNotFound 134 } 135 if action.Channel != info.Channel { 136 panic(fmt.Sprintf("unexpected channel %q for %v snap", action.Channel, action.InstanceName)) 137 } 138 if !action.Revision.Unset() && action.Revision != info.Revision { 139 panic(fmt.Sprintf("unexpected revision %q for %s snap", action.Revision, action.InstanceName)) 140 } 141 return []store.SnapActionResult{{Info: info}}, nil, nil 142 } 143 144 func (s *snapDownloadSuite) DownloadStream(ctx context.Context, name string, downloadInfo *snap.DownloadInfo, resume int64, user *auth.UserState) (io.ReadCloser, int, error) { 145 s.pokeStateLock() 146 147 if name == "download-error-trigger-snap" { 148 return nil, 0, fmt.Errorf("error triggered by download-error-trigger-snap") 149 } 150 if name == "foo-resume-3" && resume != 3 { 151 return nil, 0, fmt.Errorf("foo-resume-3 should set resume position to 3 instead of %v", resume) 152 } 153 if _, ok := storeSnaps[name]; ok { 154 status := 200 155 if resume > 0 { 156 status = 206 157 } 158 return ioutil.NopCloser(bytes.NewReader([]byte(snapContent[resume:]))), status, nil 159 } 160 panic(fmt.Sprintf("internal error: trying to download %s but not in storeSnaps", name)) 161 } 162 163 func (s *snapDownloadSuite) TestDownloadSnapErrors(c *check.C) { 164 type scenario struct { 165 dataJSON string 166 status int 167 err string 168 } 169 170 for _, scen := range []scenario{ 171 { 172 dataJSON: `{"snap-name": ""}`, 173 status: 400, 174 err: "download operation requires one snap name", 175 }, 176 { 177 dataJSON: `{"}`, 178 status: 400, 179 err: `cannot decode request body into download operation: unexpected EOF`, 180 }, 181 { 182 dataJSON: `{"snap-name": "doom","channel":"latest/potato"}`, 183 status: 400, 184 err: `invalid risk in channel name: latest/potato`, 185 }, 186 } { 187 var err error 188 data := []byte(scen.dataJSON) 189 190 req, err := http.NewRequest("POST", "/v2/download", bytes.NewBuffer(data)) 191 c.Assert(err, check.IsNil) 192 rspe := s.errorReq(c, req, nil) 193 194 c.Assert(rspe.Status, check.Equals, scen.status) 195 if scen.err == "" { 196 c.Errorf("error was expected") 197 } 198 c.Check(rspe.Message, check.Matches, scen.err) 199 } 200 } 201 202 func (s *snapDownloadSuite) TestStreamOneSnap(c *check.C) { 203 type scenario struct { 204 snapName string 205 dataJSON string 206 status int 207 resume int 208 err string 209 } 210 211 sec, err := daemon.DownloadTokensSecret(s.d) 212 c.Assert(err, check.IsNil) 213 214 fooResume3SS, err := daemon.NewSnapStream("foo-resume-3", storeSnaps["foo-resume-3"], sec) 215 c.Assert(err, check.IsNil) 216 tok, err := base64.RawURLEncoding.DecodeString(fooResume3SS.Token) 217 c.Assert(err, check.IsNil) 218 c.Assert(bytes.HasPrefix(tok, []byte(`{"snap-name":"foo-resume-3","filename":"foo-resume-3_1.snap","dl-info":{"`)), check.Equals, true) 219 220 brokenHashToken := base64.RawURLEncoding.EncodeToString(append(tok[:len(tok)-1], tok[len(tok)-1]-1)) 221 222 for _, t := range []scenario{ 223 { 224 snapName: "doom", 225 dataJSON: `{"snap-name": "doom"}`, 226 status: 404, 227 err: "snap not found", 228 }, 229 { 230 snapName: "download-error-trigger-snap", 231 dataJSON: `{"snap-name": "download-error-trigger-snap"}`, 232 status: 500, 233 err: "error triggered by download-error-trigger-snap", 234 }, 235 { 236 snapName: "bar", 237 dataJSON: `{"snap-name": "bar"}`, 238 status: 200, 239 err: "", 240 }, 241 { 242 snapName: "edge-bar", 243 dataJSON: `{"snap-name": "edge-bar", "channel":"edge"}`, 244 status: 200, 245 err: "", 246 }, 247 { 248 snapName: "rev7-bar", 249 dataJSON: `{"snap-name": "rev7-bar", "revision":"7"}`, 250 status: 200, 251 err: "", 252 }, 253 // happy resume 254 { 255 snapName: "foo-resume-3", 256 dataJSON: fmt.Sprintf(`{"snap-name": "foo-resume-3", "resume-token": %q}`, fooResume3SS.Token), 257 status: 206, 258 resume: 3, 259 err: "", 260 }, 261 // unhappy resume 262 { 263 snapName: "foo-resume-3", 264 dataJSON: fmt.Sprintf(`{"snap-name": "foo-resume-other", "resume-token": %q}`, fooResume3SS.Token), 265 status: 400, 266 resume: 3, 267 err: "resume snap name does not match original snap name", 268 }, 269 { 270 snapName: "foo-resume-3", 271 dataJSON: `{"snap-name": "foo-resume-3", "resume-token": "invalid token"}`, // not base64 272 status: 400, 273 resume: 3, 274 err: "download token is invalid", 275 }, 276 { 277 snapName: "foo-resume-3", 278 dataJSON: `{"snap-name": "foo-resume-3", "resume-token": "e30"}`, // too short token content 279 status: 400, 280 resume: 3, 281 err: "download token is invalid", 282 }, 283 { 284 snapName: "foo-resume-3", 285 dataJSON: fmt.Sprintf(`{"snap-name": "foo-resume-3", "resume-token": %q}`, brokenHashToken), // token with broken hash 286 status: 400, 287 resume: 3, 288 err: "download token is invalid", 289 }, 290 291 { 292 snapName: "foo-resume-3", 293 dataJSON: `{"snap-name": "foo-resume-3", "resume-stamp": ""}`, 294 status: 400, 295 resume: 3, 296 err: "cannot resume without a token", 297 }, 298 { 299 snapName: "foo-resume-3", 300 dataJSON: fmt.Sprintf(`{"snap-name": "foo-resume-3", "resume-stamp": %q}`, fooResume3SS.Token), 301 status: 500, 302 resume: -10, 303 // negative values are ignored and resume is set to 0 304 err: "foo-resume-3 should set resume position to 3 instead of 0", 305 }, 306 { 307 snapName: "foo-resume-3", 308 dataJSON: `{"snap-name": "foo-resume-3", "header-peek": true}`, 309 status: 400, 310 resume: 3, 311 err: "cannot request header-only peek when resuming", 312 }, 313 { 314 snapName: "foo-resume-3", 315 dataJSON: `{"snap-name": "foo-resume-3", "header-peek": true, "resume-token": "something"}`, 316 status: 400, 317 err: "cannot request header-only peek when resuming", 318 }, 319 { 320 snapName: "foo-resume-3", 321 dataJSON: `{"snap-name": "foo-resume-3", "header-peek": true, "resume-token": "something"}`, 322 resume: 3, 323 status: 400, 324 err: "cannot request header-only peek when resuming", 325 }, 326 } { 327 req, err := http.NewRequest("POST", "/v2/download", strings.NewReader(t.dataJSON)) 328 c.Assert(err, check.IsNil) 329 if t.resume != 0 { 330 req.Header.Add("Range", fmt.Sprintf("bytes=%d-", t.resume)) 331 } 332 333 rsp := s.req(c, req, nil) 334 335 if t.err != "" { 336 rspe := rsp.(*daemon.APIError) 337 c.Check(rspe.Status, check.Equals, t.status, check.Commentf("unexpected result for %v", t.dataJSON)) 338 c.Check(rspe.Message, check.Matches, t.err, check.Commentf("unexpected result for %v", t.dataJSON)) 339 } else { 340 c.Assert(rsp, check.FitsTypeOf, &daemon.SnapStream{}, check.Commentf("unexpected result for %v", t.dataJSON)) 341 ss := rsp.(*daemon.SnapStream) 342 c.Assert(ss.SnapName, check.Equals, t.snapName, check.Commentf("invalid result %v for %v", rsp, t.dataJSON)) 343 c.Assert(ss.Info.Size, check.Equals, int64(len(snapContent))) 344 345 w := httptest.NewRecorder() 346 ss.ServeHTTP(w, nil) 347 348 expectedLength := fmt.Sprintf("%d", len(snapContent)-t.resume) 349 350 info := storeSnaps[t.snapName] 351 c.Assert(w.Code, check.Equals, t.status) 352 c.Assert(w.Header().Get("Content-Length"), check.Equals, expectedLength) 353 c.Assert(w.Header().Get("Content-Type"), check.Equals, "application/octet-stream") 354 c.Assert(w.Header().Get("Content-Disposition"), check.Equals, fmt.Sprintf("attachment; filename=%s_%s.snap", t.snapName, info.Revision)) 355 c.Assert(w.Header().Get("Snap-Sha3-384"), check.Equals, "sha3sha3sha3", check.Commentf("invalid sha3 for %v", t.snapName)) 356 c.Assert(w.Body.Bytes(), check.DeepEquals, []byte("SNAP")[t.resume:]) 357 c.Assert(w.Header().Get("Snap-Download-Token"), check.Equals, ss.Token) 358 if t.status == 206 { 359 c.Assert(w.Header().Get("Content-Range"), check.Equals, fmt.Sprintf("bytes %d-%d/%d", t.resume, len(snapContent)-1, len(snapContent))) 360 c.Assert(ss.Token, check.Not(check.HasLen), 0) 361 } 362 } 363 } 364 } 365 366 func (s *snapDownloadSuite) TestStreamOneSnapHeaderOnlyPeek(c *check.C) { 367 dataJSON := `{"snap-name": "bar", "header-peek": true}` 368 req, err := http.NewRequest("POST", "/v2/download", strings.NewReader(dataJSON)) 369 c.Assert(err, check.IsNil) 370 371 rsp := s.req(c, req, nil) 372 373 c.Assert(rsp, check.FitsTypeOf, &daemon.SnapStream{}) 374 ss := rsp.(*daemon.SnapStream) 375 c.Assert(ss.SnapName, check.Equals, "bar") 376 c.Assert(ss.Info.Size, check.Equals, int64(len(snapContent))) 377 378 w := httptest.NewRecorder() 379 ss.ServeHTTP(w, nil) 380 c.Assert(w.Code, check.Equals, 200) 381 382 // we get the relevant headers 383 c.Check(w.Header().Get("Content-Disposition"), check.Equals, "attachment; filename=bar_1.snap") 384 c.Check(w.Header().Get("Snap-Sha3-384"), check.Equals, "sha3sha3sha3") 385 // but no body 386 c.Check(w.Body.Bytes(), check.HasLen, 0) 387 } 388 389 func (s *snapDownloadSuite) TestStreamRangeHeaderErrors(c *check.C) { 390 dataJSON := `{"snap-name":"bar"}` 391 392 for _, t := range []string{ 393 // missing "-" at the end 394 "bytes=123", 395 // missing "bytes=" 396 "123-", 397 // real range, not supported 398 "bytes=1-2", 399 // almost 400 "bytes=1--", 401 } { 402 req, err := http.NewRequest("POST", "/v2/download", strings.NewReader(dataJSON)) 403 c.Assert(err, check.IsNil) 404 // missng "-" at the end 405 req.Header.Add("Range", t) 406 407 rsp := s.req(c, req, nil) 408 if dr, ok := rsp.(daemon.StructuredResponse); ok { 409 c.Fatalf("unexpected daemon result (test broken): %v", dr.JSON().Result) 410 } 411 w := httptest.NewRecorder() 412 ss := rsp.(*daemon.SnapStream) 413 ss.ServeHTTP(w, nil) 414 // range header is invalid and ignored 415 c.Assert(w.Code, check.Equals, 200) 416 } 417 }