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  }