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  }