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  }