github.com/stolowski/snapd@v0.0.0-20210407085831-115137ce5a22/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 type fakeStore struct{} 42 43 var _ = check.Suite(&snapDownloadSuite{}) 44 45 type snapDownloadSuite struct { 46 apiBaseSuite 47 48 snaps []string 49 } 50 51 func (s *snapDownloadSuite) SetUpTest(c *check.C) { 52 s.apiBaseSuite.SetUpTest(c) 53 54 s.snaps = nil 55 56 s.daemonWithStore(c, s) 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 rsp := s.errorReq(c, req, nil) 193 194 c.Assert(rsp.Status, check.Equals, scen.status) 195 if scen.err == "" { 196 c.Errorf("error was expected") 197 } 198 result := rsp.Result 199 c.Check(result.(*daemon.ErrorResult).Message, check.Matches, scen.err) 200 } 201 } 202 203 func (s *snapDownloadSuite) TestStreamOneSnap(c *check.C) { 204 type scenario struct { 205 snapName string 206 dataJSON string 207 status int 208 resume int 209 noBody bool 210 err string 211 } 212 213 sec, err := daemon.DownloadTokensSecret(s.d) 214 c.Assert(err, check.IsNil) 215 216 fooResume3SS, err := daemon.NewSnapStream("foo-resume-3", storeSnaps["foo-resume-3"], sec) 217 c.Assert(err, check.IsNil) 218 tok, err := base64.RawURLEncoding.DecodeString(fooResume3SS.Token) 219 c.Assert(err, check.IsNil) 220 c.Assert(bytes.HasPrefix(tok, []byte(`{"snap-name":"foo-resume-3","filename":"foo-resume-3_1.snap","dl-info":{"`)), check.Equals, true) 221 222 brokenHashToken := base64.RawURLEncoding.EncodeToString(append(tok[:len(tok)-1], tok[len(tok)-1]-1)) 223 224 for _, t := range []scenario{ 225 { 226 snapName: "doom", 227 dataJSON: `{"snap-name": "doom"}`, 228 status: 404, 229 err: "snap not found", 230 }, 231 { 232 snapName: "download-error-trigger-snap", 233 dataJSON: `{"snap-name": "download-error-trigger-snap"}`, 234 status: 500, 235 err: "error triggered by download-error-trigger-snap", 236 }, 237 { 238 snapName: "bar", 239 dataJSON: `{"snap-name": "bar"}`, 240 status: 200, 241 err: "", 242 }, 243 { 244 snapName: "edge-bar", 245 dataJSON: `{"snap-name": "edge-bar", "channel":"edge"}`, 246 status: 200, 247 err: "", 248 }, 249 { 250 snapName: "rev7-bar", 251 dataJSON: `{"snap-name": "rev7-bar", "revision":"7"}`, 252 status: 200, 253 err: "", 254 }, 255 // happy resume 256 { 257 snapName: "foo-resume-3", 258 dataJSON: fmt.Sprintf(`{"snap-name": "foo-resume-3", "resume-token": %q}`, fooResume3SS.Token), 259 status: 206, 260 resume: 3, 261 err: "", 262 }, 263 // unhappy resume 264 { 265 snapName: "foo-resume-3", 266 dataJSON: fmt.Sprintf(`{"snap-name": "foo-resume-other", "resume-token": %q}`, fooResume3SS.Token), 267 status: 400, 268 resume: 3, 269 err: "resume snap name does not match original snap name", 270 }, 271 { 272 snapName: "foo-resume-3", 273 dataJSON: `{"snap-name": "foo-resume-3", "resume-token": "invalid token"}`, // not base64 274 status: 400, 275 resume: 3, 276 err: "download token is invalid", 277 }, 278 { 279 snapName: "foo-resume-3", 280 dataJSON: `{"snap-name": "foo-resume-3", "resume-token": "e30"}`, // too short token content 281 status: 400, 282 resume: 3, 283 err: "download token is invalid", 284 }, 285 { 286 snapName: "foo-resume-3", 287 dataJSON: fmt.Sprintf(`{"snap-name": "foo-resume-3", "resume-token": %q}`, brokenHashToken), // token with broken hash 288 status: 400, 289 resume: 3, 290 err: "download token is invalid", 291 }, 292 293 { 294 snapName: "foo-resume-3", 295 dataJSON: `{"snap-name": "foo-resume-3", "resume-stamp": ""}`, 296 status: 400, 297 resume: 3, 298 err: "cannot resume without a token", 299 }, 300 { 301 snapName: "foo-resume-3", 302 dataJSON: fmt.Sprintf(`{"snap-name": "foo-resume-3", "resume-stamp": %q}`, fooResume3SS.Token), 303 status: 500, 304 resume: -10, 305 // negative values are ignored and resume is set to 0 306 err: "foo-resume-3 should set resume position to 3 instead of 0", 307 }, 308 { 309 snapName: "foo-resume-3", 310 dataJSON: `{"snap-name": "foo-resume-3", "header-peek": true}`, 311 status: 400, 312 resume: 3, 313 err: "cannot request header-only peek when resuming", 314 }, 315 { 316 snapName: "foo-resume-3", 317 dataJSON: `{"snap-name": "foo-resume-3", "header-peek": true, "resume-token": "something"}`, 318 status: 400, 319 err: "cannot request header-only peek when resuming", 320 }, 321 { 322 snapName: "foo-resume-3", 323 dataJSON: `{"snap-name": "foo-resume-3", "header-peek": true, "resume-token": "something"}`, 324 resume: 3, 325 status: 400, 326 err: "cannot request header-only peek when resuming", 327 }, 328 } { 329 req, err := http.NewRequest("POST", "/v2/download", strings.NewReader(t.dataJSON)) 330 c.Assert(err, check.IsNil) 331 if t.resume != 0 { 332 req.Header.Add("Range", fmt.Sprintf("bytes=%d-", t.resume)) 333 } 334 335 rsp := s.req(c, req, nil) 336 337 if t.err != "" { 338 c.Check(rsp.(*daemon.Resp).Status, check.Equals, t.status, check.Commentf("unexpected result for %v", t.dataJSON)) 339 result := rsp.(*daemon.Resp).Result 340 c.Check(result.(*daemon.ErrorResult).Message, check.Matches, t.err, check.Commentf("unexpected result for %v", t.dataJSON)) 341 } else { 342 c.Assert(rsp, check.FitsTypeOf, &daemon.SnapStream{}, check.Commentf("unexpected result for %v", t.dataJSON)) 343 ss := rsp.(*daemon.SnapStream) 344 c.Assert(ss.SnapName, check.Equals, t.snapName, check.Commentf("invalid result %v for %v", rsp, t.dataJSON)) 345 c.Assert(ss.Info.Size, check.Equals, int64(len(snapContent))) 346 347 w := httptest.NewRecorder() 348 ss.ServeHTTP(w, nil) 349 350 expectedLength := fmt.Sprintf("%d", len(snapContent)-t.resume) 351 352 info := storeSnaps[t.snapName] 353 c.Assert(w.Code, check.Equals, t.status) 354 c.Assert(w.Header().Get("Content-Length"), check.Equals, expectedLength) 355 c.Assert(w.Header().Get("Content-Type"), check.Equals, "application/octet-stream") 356 c.Assert(w.Header().Get("Content-Disposition"), check.Equals, fmt.Sprintf("attachment; filename=%s_%s.snap", t.snapName, info.Revision)) 357 c.Assert(w.Header().Get("Snap-Sha3-384"), check.Equals, "sha3sha3sha3", check.Commentf("invalid sha3 for %v", t.snapName)) 358 c.Assert(w.Body.Bytes(), check.DeepEquals, []byte("SNAP")[t.resume:]) 359 c.Assert(w.Header().Get("Snap-Download-Token"), check.Equals, ss.Token) 360 if t.status == 206 { 361 c.Assert(w.Header().Get("Content-Range"), check.Equals, fmt.Sprintf("bytes %d-%d/%d", t.resume, len(snapContent)-1, len(snapContent))) 362 c.Assert(ss.Token, check.Not(check.HasLen), 0) 363 } 364 } 365 } 366 } 367 368 func (s *snapDownloadSuite) TestStreamOneSnapHeaderOnlyPeek(c *check.C) { 369 dataJSON := `{"snap-name": "bar", "header-peek": true}` 370 req, err := http.NewRequest("POST", "/v2/download", strings.NewReader(dataJSON)) 371 c.Assert(err, check.IsNil) 372 373 rsp := s.req(c, req, nil) 374 375 c.Assert(rsp, check.FitsTypeOf, &daemon.SnapStream{}) 376 ss := rsp.(*daemon.SnapStream) 377 c.Assert(ss.SnapName, check.Equals, "bar") 378 c.Assert(ss.Info.Size, check.Equals, int64(len(snapContent))) 379 380 w := httptest.NewRecorder() 381 ss.ServeHTTP(w, nil) 382 c.Assert(w.Code, check.Equals, 200) 383 384 // we get the relevant headers 385 c.Check(w.Header().Get("Content-Disposition"), check.Equals, "attachment; filename=bar_1.snap") 386 c.Check(w.Header().Get("Snap-Sha3-384"), check.Equals, "sha3sha3sha3") 387 // but no body 388 c.Check(w.Body.Bytes(), check.HasLen, 0) 389 } 390 391 func (s *snapDownloadSuite) TestStreamRangeHeaderErrors(c *check.C) { 392 dataJSON := `{"snap-name":"bar"}` 393 394 for _, t := range []string{ 395 // missing "-" at the end 396 "bytes=123", 397 // missing "bytes=" 398 "123-", 399 // real range, not supported 400 "bytes=1-2", 401 // almost 402 "bytes=1--", 403 } { 404 req, err := http.NewRequest("POST", "/v2/download", strings.NewReader(dataJSON)) 405 c.Assert(err, check.IsNil) 406 // missng "-" at the end 407 req.Header.Add("Range", t) 408 409 rsp := s.req(c, req, nil) 410 if dr, ok := rsp.(*daemon.Resp); ok { 411 c.Fatalf("unexpected daemon result (test broken): %v", dr.Result) 412 } 413 w := httptest.NewRecorder() 414 ss := rsp.(*daemon.SnapStream) 415 ss.ServeHTTP(w, nil) 416 // range header is invalid and ignored 417 c.Assert(w.Code, check.Equals, 200) 418 } 419 }