github.com/hugh712/snapd@v0.0.0-20200910133618-1a99902bd583/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/dirs" 37 "github.com/snapcore/snapd/overlord" 38 "github.com/snapcore/snapd/overlord/auth" 39 "github.com/snapcore/snapd/overlord/snapstate" 40 "github.com/snapcore/snapd/snap" 41 "github.com/snapcore/snapd/store" 42 "github.com/snapcore/snapd/store/storetest" 43 ) 44 45 type fakeStore struct{} 46 47 var _ = check.Suite(&snapDownloadSuite{}) 48 49 type snapDownloadSuite struct { 50 storetest.Store 51 d *daemon.Daemon 52 53 snaps []string 54 } 55 56 func (s *snapDownloadSuite) SetUpTest(c *check.C) { 57 s.snaps = nil 58 59 o := overlord.Mock() 60 s.d = daemon.NewWithOverlord(o) 61 62 st := o.State() 63 st.Lock() 64 defer st.Unlock() 65 snapstate.ReplaceStore(st, s) 66 dirs.SetRootDir(c.MkDir()) 67 } 68 69 var snapContent = "SNAP" 70 71 var storeSnaps = map[string]*snap.Info{ 72 "bar": { 73 SideInfo: snap.SideInfo{ 74 RealName: "bar", 75 Revision: snap.R(1), 76 }, 77 DownloadInfo: snap.DownloadInfo{ 78 Size: int64(len(snapContent)), 79 AnonDownloadURL: "http://localhost/bar", 80 Sha3_384: "sha3sha3sha3", 81 }, 82 }, 83 "edge-bar": { 84 SideInfo: snap.SideInfo{ 85 RealName: "edge-bar", 86 Revision: snap.R(1), 87 // this is the channel we expect in the test 88 Channel: "edge", 89 }, 90 DownloadInfo: snap.DownloadInfo{ 91 Size: int64(len(snapContent)), 92 AnonDownloadURL: "http://localhost/edge-bar", 93 Sha3_384: "sha3sha3sha3", 94 }, 95 }, 96 "rev7-bar": { 97 SideInfo: snap.SideInfo{ 98 RealName: "rev7-bar", 99 // this is the revision we expect in the test 100 Revision: snap.R(7), 101 }, 102 DownloadInfo: snap.DownloadInfo{ 103 Size: int64(len(snapContent)), 104 AnonDownloadURL: "http://localhost/rev7-bar", 105 Sha3_384: "sha3sha3sha3", 106 }, 107 }, 108 "download-error-trigger-snap": { 109 DownloadInfo: snap.DownloadInfo{ 110 Size: 100, 111 AnonDownloadURL: "http://localhost/foo", 112 Sha3_384: "sha3sha3sha3", 113 }, 114 }, 115 "foo-resume-3": { 116 SideInfo: snap.SideInfo{ 117 RealName: "foo-resume-3", 118 Revision: snap.R(1), 119 }, 120 DownloadInfo: snap.DownloadInfo{ 121 Size: int64(len(snapContent)), 122 AnonDownloadURL: "http://localhost/foo-resume-3", 123 Sha3_384: "sha3sha3sha3", 124 }, 125 }, 126 } 127 128 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) { 129 if assertQuery != nil { 130 panic("no assertion query support") 131 } 132 if len(actions) != 1 { 133 panic(fmt.Sprintf("unexpected amount of actions: %v", len(actions))) 134 } 135 action := actions[0] 136 if action.Action != "download" { 137 panic(fmt.Sprintf("unexpected action: %q", action.Action)) 138 } 139 info, ok := storeSnaps[action.InstanceName] 140 if !ok { 141 return nil, nil, store.ErrSnapNotFound 142 } 143 if action.Channel != info.Channel { 144 panic(fmt.Sprintf("unexpected channel %q for %v snap", action.Channel, action.InstanceName)) 145 } 146 if !action.Revision.Unset() && action.Revision != info.Revision { 147 panic(fmt.Sprintf("unexpected revision %q for %s snap", action.Revision, action.InstanceName)) 148 } 149 return []store.SnapActionResult{{Info: info}}, nil, nil 150 } 151 152 func (s *snapDownloadSuite) DownloadStream(ctx context.Context, name string, downloadInfo *snap.DownloadInfo, resume int64, user *auth.UserState) (io.ReadCloser, int, error) { 153 if name == "download-error-trigger-snap" { 154 return nil, 0, fmt.Errorf("error triggered by download-error-trigger-snap") 155 } 156 if name == "foo-resume-3" && resume != 3 { 157 return nil, 0, fmt.Errorf("foo-resume-3 should set resume position to 3 instead of %v", resume) 158 } 159 if _, ok := storeSnaps[name]; ok { 160 status := 200 161 if resume > 0 { 162 status = 206 163 } 164 return ioutil.NopCloser(bytes.NewReader([]byte(snapContent[resume:]))), status, nil 165 } 166 panic(fmt.Sprintf("internal error: trying to download %s but not in storeSnaps", name)) 167 } 168 169 func (s *snapDownloadSuite) TestDownloadSnapErrors(c *check.C) { 170 type scenario struct { 171 dataJSON string 172 status int 173 err string 174 } 175 176 for _, scen := range []scenario{ 177 { 178 dataJSON: `{"snap-name": ""}`, 179 status: 400, 180 err: "download operation requires one snap name", 181 }, 182 { 183 dataJSON: `{"}`, 184 status: 400, 185 err: `cannot decode request body into download operation: unexpected EOF`, 186 }, 187 { 188 dataJSON: `{"snap-name": "doom","channel":"latest/potato"}`, 189 status: 400, 190 err: `invalid risk in channel name: latest/potato`, 191 }, 192 } { 193 var err error 194 data := []byte(scen.dataJSON) 195 196 req, err := http.NewRequest("POST", "/v2/download", bytes.NewBuffer(data)) 197 c.Assert(err, check.IsNil) 198 rsp := daemon.PostSnapDownload(daemon.SnapDownloadCmd, req, nil) 199 200 c.Assert(rsp.(*daemon.Resp).Status, check.Equals, scen.status) 201 if scen.err == "" { 202 c.Errorf("error was expected") 203 } 204 result := rsp.(*daemon.Resp).Result 205 c.Check(result.(*daemon.ErrorResult).Message, check.Matches, scen.err) 206 } 207 } 208 209 func (s *snapDownloadSuite) TestStreamOneSnap(c *check.C) { 210 type scenario struct { 211 snapName string 212 dataJSON string 213 status int 214 resume int 215 noBody bool 216 err string 217 } 218 219 sec, err := daemon.DownloadTokensSecret(daemon.SnapDownloadCmd) 220 c.Assert(err, check.IsNil) 221 222 fooResume3SS, err := daemon.NewSnapStream("foo-resume-3", storeSnaps["foo-resume-3"], sec) 223 c.Assert(err, check.IsNil) 224 tok, err := base64.RawURLEncoding.DecodeString(fooResume3SS.Token) 225 c.Assert(err, check.IsNil) 226 c.Assert(bytes.HasPrefix(tok, []byte(`{"snap-name":"foo-resume-3","filename":"foo-resume-3_1.snap","dl-info":{"`)), check.Equals, true) 227 228 brokenHashToken := base64.RawURLEncoding.EncodeToString(append(tok[:len(tok)-1], tok[len(tok)-1]-1)) 229 230 for _, s := range []scenario{ 231 { 232 snapName: "doom", 233 dataJSON: `{"snap-name": "doom"}`, 234 status: 404, 235 err: "snap not found", 236 }, 237 { 238 snapName: "download-error-trigger-snap", 239 dataJSON: `{"snap-name": "download-error-trigger-snap"}`, 240 status: 500, 241 err: "error triggered by download-error-trigger-snap", 242 }, 243 { 244 snapName: "bar", 245 dataJSON: `{"snap-name": "bar"}`, 246 status: 200, 247 err: "", 248 }, 249 { 250 snapName: "edge-bar", 251 dataJSON: `{"snap-name": "edge-bar", "channel":"edge"}`, 252 status: 200, 253 err: "", 254 }, 255 { 256 snapName: "rev7-bar", 257 dataJSON: `{"snap-name": "rev7-bar", "revision":"7"}`, 258 status: 200, 259 err: "", 260 }, 261 // happy resume 262 { 263 snapName: "foo-resume-3", 264 dataJSON: fmt.Sprintf(`{"snap-name": "foo-resume-3", "resume-token": %q}`, fooResume3SS.Token), 265 status: 206, 266 resume: 3, 267 err: "", 268 }, 269 // unhappy resume 270 { 271 snapName: "foo-resume-3", 272 dataJSON: fmt.Sprintf(`{"snap-name": "foo-resume-other", "resume-token": %q}`, fooResume3SS.Token), 273 status: 400, 274 resume: 3, 275 err: "resume snap name does not match original snap name", 276 }, 277 { 278 snapName: "foo-resume-3", 279 dataJSON: `{"snap-name": "foo-resume-3", "resume-token": "invalid token"}`, // not base64 280 status: 400, 281 resume: 3, 282 err: "download token is invalid", 283 }, 284 { 285 snapName: "foo-resume-3", 286 dataJSON: `{"snap-name": "foo-resume-3", "resume-token": "e30"}`, // too short token content 287 status: 400, 288 resume: 3, 289 err: "download token is invalid", 290 }, 291 { 292 snapName: "foo-resume-3", 293 dataJSON: fmt.Sprintf(`{"snap-name": "foo-resume-3", "resume-token": %q}`, brokenHashToken), // token with broken hash 294 status: 400, 295 resume: 3, 296 err: "download token is invalid", 297 }, 298 299 { 300 snapName: "foo-resume-3", 301 dataJSON: `{"snap-name": "foo-resume-3", "resume-stamp": ""}`, 302 status: 400, 303 resume: 3, 304 err: "cannot resume without a token", 305 }, 306 { 307 snapName: "foo-resume-3", 308 dataJSON: fmt.Sprintf(`{"snap-name": "foo-resume-3", "resume-stamp": %q}`, fooResume3SS.Token), 309 status: 500, 310 resume: -10, 311 // negative values are ignored and resume is set to 0 312 err: "foo-resume-3 should set resume position to 3 instead of 0", 313 }, 314 { 315 snapName: "foo-resume-3", 316 dataJSON: `{"snap-name": "foo-resume-3", "header-peek": true}`, 317 status: 400, 318 resume: 3, 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 status: 400, 325 err: "cannot request header-only peek when resuming", 326 }, 327 { 328 snapName: "foo-resume-3", 329 dataJSON: `{"snap-name": "foo-resume-3", "header-peek": true, "resume-token": "something"}`, 330 resume: 3, 331 status: 400, 332 err: "cannot request header-only peek when resuming", 333 }, 334 } { 335 req, err := http.NewRequest("POST", "/v2/download", strings.NewReader(s.dataJSON)) 336 c.Assert(err, check.IsNil) 337 if s.resume != 0 { 338 req.Header.Add("Range", fmt.Sprintf("bytes=%d-", s.resume)) 339 } 340 341 rsp := daemon.SnapDownloadCmd.POST(daemon.SnapDownloadCmd, req, nil) 342 343 if s.err != "" { 344 c.Check(rsp.(*daemon.Resp).Status, check.Equals, s.status, check.Commentf("unexpected result for %v", s.dataJSON)) 345 result := rsp.(*daemon.Resp).Result 346 c.Check(result.(*daemon.ErrorResult).Message, check.Matches, s.err, check.Commentf("unexpected result for %v", s.dataJSON)) 347 } else { 348 c.Assert(rsp, check.FitsTypeOf, &daemon.SnapStream{}, check.Commentf("unexpected result for %v", s.dataJSON)) 349 ss := rsp.(*daemon.SnapStream) 350 c.Assert(ss.SnapName, check.Equals, s.snapName, check.Commentf("invalid result %v for %v", rsp, s.dataJSON)) 351 c.Assert(ss.Info.Size, check.Equals, int64(len(snapContent))) 352 353 w := httptest.NewRecorder() 354 ss.ServeHTTP(w, nil) 355 356 expectedLength := fmt.Sprintf("%d", len(snapContent)-s.resume) 357 358 info := storeSnaps[s.snapName] 359 c.Assert(w.Code, check.Equals, s.status) 360 c.Assert(w.Header().Get("Content-Length"), check.Equals, expectedLength) 361 c.Assert(w.Header().Get("Content-Type"), check.Equals, "application/octet-stream") 362 c.Assert(w.Header().Get("Content-Disposition"), check.Equals, fmt.Sprintf("attachment; filename=%s_%s.snap", s.snapName, info.Revision)) 363 c.Assert(w.Header().Get("Snap-Sha3-384"), check.Equals, "sha3sha3sha3", check.Commentf("invalid sha3 for %v", s.snapName)) 364 c.Assert(w.Body.Bytes(), check.DeepEquals, []byte("SNAP")[s.resume:]) 365 c.Assert(w.Header().Get("Snap-Download-Token"), check.Equals, ss.Token) 366 if s.status == 206 { 367 c.Assert(w.Header().Get("Content-Range"), check.Equals, fmt.Sprintf("bytes %d-%d/%d", s.resume, len(snapContent)-1, len(snapContent))) 368 c.Assert(ss.Token, check.Not(check.HasLen), 0) 369 } 370 } 371 } 372 } 373 374 func (s *snapDownloadSuite) TestStreamOneSnapHeaderOnlyPeek(c *check.C) { 375 dataJSON := `{"snap-name": "bar", "header-peek": true}` 376 req, err := http.NewRequest("POST", "/v2/download", strings.NewReader(dataJSON)) 377 c.Assert(err, check.IsNil) 378 379 rsp := daemon.SnapDownloadCmd.POST(daemon.SnapDownloadCmd, req, nil) 380 381 c.Assert(rsp, check.FitsTypeOf, &daemon.SnapStream{}) 382 ss := rsp.(*daemon.SnapStream) 383 c.Assert(ss.SnapName, check.Equals, "bar") 384 c.Assert(ss.Info.Size, check.Equals, int64(len(snapContent))) 385 386 w := httptest.NewRecorder() 387 ss.ServeHTTP(w, nil) 388 c.Assert(w.Code, check.Equals, 200) 389 390 // we get the relevant headers 391 c.Check(w.Header().Get("Content-Disposition"), check.Equals, "attachment; filename=bar_1.snap") 392 c.Check(w.Header().Get("Snap-Sha3-384"), check.Equals, "sha3sha3sha3") 393 // but no body 394 c.Check(w.Body.Bytes(), check.HasLen, 0) 395 } 396 397 func (s *snapDownloadSuite) TestStreamRangeHeaderErrors(c *check.C) { 398 dataJSON := `{"snap-name":"bar"}` 399 400 for _, s := range []string{ 401 // missing "-" at the end 402 "bytes=123", 403 // missing "bytes=" 404 "123-", 405 // real range, not supported 406 "bytes=1-2", 407 // almost 408 "bytes=1--", 409 } { 410 req, err := http.NewRequest("POST", "/v2/download", strings.NewReader(dataJSON)) 411 c.Assert(err, check.IsNil) 412 // missng "-" at the end 413 req.Header.Add("Range", s) 414 415 rsp := daemon.SnapDownloadCmd.POST(daemon.SnapDownloadCmd, req, nil) 416 if dr, ok := rsp.(*daemon.Resp); ok { 417 c.Fatalf("unexpected daemon result (test broken): %v", dr.Result) 418 } 419 w := httptest.NewRecorder() 420 ss := rsp.(*daemon.SnapStream) 421 ss.ServeHTTP(w, nil) 422 // range header is invalid and ignored 423 c.Assert(w.Code, check.Equals, 200) 424 } 425 }