github.com/mwhudson/juju@v0.0.0-20160512215208-90ff01f3497f/apiserver/gui_test.go (about)

     1  // Copyright 2016 Canonical Ltd.
     2  // Licensed under the AGPLv3, see LICENCE file for details.
     3  
     4  package apiserver_test
     5  
     6  import (
     7  	"archive/tar"
     8  	"bytes"
     9  	"crypto/sha256"
    10  	"encoding/json"
    11  	"fmt"
    12  	"io"
    13  	"io/ioutil"
    14  	"net/http"
    15  	"net/url"
    16  	"os"
    17  	"os/exec"
    18  	"path/filepath"
    19  	"runtime"
    20  	"strings"
    21  
    22  	jc "github.com/juju/testing/checkers"
    23  	"github.com/juju/version"
    24  	gc "gopkg.in/check.v1"
    25  
    26  	agenttools "github.com/juju/juju/agent/tools"
    27  	"github.com/juju/juju/apiserver"
    28  	"github.com/juju/juju/apiserver/params"
    29  	"github.com/juju/juju/state/binarystorage"
    30  	jujuversion "github.com/juju/juju/version"
    31  )
    32  
    33  const (
    34  	guiConfigPath = "templates/config.js.go"
    35  	guiIndexPath  = "templates/index.html.go"
    36  )
    37  
    38  type guiSuite struct {
    39  	authHttpSuite
    40  }
    41  
    42  var _ = gc.Suite(&guiSuite{})
    43  
    44  // guiURL returns the complete URL where the Juju GUI can be found, including
    45  // the given hash and pathAndquery.
    46  func (s *guiSuite) guiURL(c *gc.C, hash, pathAndquery string) string {
    47  	u := s.baseURL(c)
    48  	path := "/gui/" + s.modelUUID
    49  	if hash != "" {
    50  		path += "/" + hash
    51  	}
    52  	parts := strings.SplitN(pathAndquery, "?", 2)
    53  	u.Path = path + parts[0]
    54  	if len(parts) == 2 {
    55  		u.RawQuery = parts[1]
    56  	}
    57  	return u.String()
    58  }
    59  
    60  type guiSetupFunc func(c *gc.C, baseDir string, storage binarystorage.Storage) string
    61  
    62  var guiHandlerTests = []struct {
    63  	// about describes the test.
    64  	about string
    65  	// setup is optionally used to set up the test.
    66  	// It receives the Juju GUI base directory and an empty GUI storage.
    67  	// Optionally it can return a GUI archive hash which is used by the test
    68  	// to build the URL path for the HTTP request.
    69  	setup guiSetupFunc
    70  	// currentVersion optionally holds the GUI version that must be set as
    71  	// current right after setup is called and before the test is run.
    72  	currentVersion string
    73  	// pathAndquery holds the optional path and query for the request, for
    74  	// instance "/combo?file". If not provided, the "/" path is used.
    75  	pathAndquery string
    76  	// expectedStatus holds the expected response HTTP status.
    77  	// A 200 OK status is used by default.
    78  	expectedStatus int
    79  	// expectedContentType holds the expected response content type.
    80  	// If expectedError is provided this field is ignored.
    81  	expectedContentType string
    82  	// expectedBody holds the expected response body, only used if
    83  	// expectedError is not provided (see below).
    84  	expectedBody string
    85  	// expectedError holds the expected error message included in the response.
    86  	expectedError string
    87  }{{
    88  	about:          "metadata not found",
    89  	expectedStatus: http.StatusNotFound,
    90  	expectedError:  "Juju GUI not found",
    91  }, {
    92  	about: "GUI directory is a file",
    93  	setup: func(c *gc.C, baseDir string, storage binarystorage.Storage) string {
    94  		err := storage.Add(strings.NewReader(""), binarystorage.Metadata{
    95  			SHA256:  "fake-hash",
    96  			Version: "2.1.0",
    97  		})
    98  		c.Assert(err, jc.ErrorIsNil)
    99  		err = os.MkdirAll(baseDir, 0755)
   100  		c.Assert(err, jc.ErrorIsNil)
   101  		rootDir := filepath.Join(baseDir, "fake-hash")
   102  		err = ioutil.WriteFile(rootDir, nil, 0644)
   103  		c.Assert(err, jc.ErrorIsNil)
   104  		return ""
   105  	},
   106  	currentVersion: "2.1.0",
   107  	expectedStatus: http.StatusInternalServerError,
   108  	expectedError:  "cannot use Juju GUI root directory .*",
   109  }, {
   110  	about: "GUI directory is unaccessible",
   111  	setup: func(c *gc.C, baseDir string, storage binarystorage.Storage) string {
   112  		err := storage.Add(strings.NewReader(""), binarystorage.Metadata{
   113  			SHA256:  "fake-hash",
   114  			Version: "2.2.0",
   115  		})
   116  		c.Assert(err, jc.ErrorIsNil)
   117  		err = os.MkdirAll(baseDir, 0000)
   118  		c.Assert(err, jc.ErrorIsNil)
   119  		return ""
   120  	},
   121  	currentVersion: "2.2.0",
   122  	expectedStatus: http.StatusInternalServerError,
   123  	expectedError:  "cannot stat Juju GUI root directory: .*",
   124  }, {
   125  	about: "invalid GUI archive",
   126  	setup: func(c *gc.C, baseDir string, storage binarystorage.Storage) string {
   127  		err := storage.Add(strings.NewReader(""), binarystorage.Metadata{
   128  			SHA256:  "fake-hash",
   129  			Version: "2.3.0",
   130  		})
   131  		c.Assert(err, jc.ErrorIsNil)
   132  		return ""
   133  	},
   134  	currentVersion: "2.3.0",
   135  	expectedStatus: http.StatusInternalServerError,
   136  	expectedError:  "cannot uncompress Juju GUI archive: cannot parse archive: .*",
   137  }, {
   138  	about: "GUI current version not set",
   139  	setup: func(c *gc.C, baseDir string, storage binarystorage.Storage) string {
   140  		err := storage.Add(strings.NewReader(""), binarystorage.Metadata{
   141  			SHA256: "fake-hash",
   142  		})
   143  		c.Assert(err, jc.ErrorIsNil)
   144  		return ""
   145  	},
   146  	expectedStatus: http.StatusNotFound,
   147  	expectedError:  "Juju GUI not found",
   148  }, {
   149  	about: "index: sprite file not found",
   150  	setup: func(c *gc.C, baseDir string, storage binarystorage.Storage) string {
   151  		setupGUIArchive(c, storage, "2.0.42", nil)
   152  		return ""
   153  	},
   154  	currentVersion: "2.0.42",
   155  	expectedStatus: http.StatusInternalServerError,
   156  	expectedError:  "cannot read sprite file: .*",
   157  }, {
   158  	about: "index: template not found",
   159  	setup: func(c *gc.C, baseDir string, storage binarystorage.Storage) string {
   160  		setupGUIArchive(c, storage, "2.0.42", map[string]string{
   161  			apiserver.SpritePath: "",
   162  		})
   163  		return ""
   164  	},
   165  	currentVersion: "2.0.42",
   166  	expectedStatus: http.StatusInternalServerError,
   167  	expectedError:  "cannot parse template: .*: no such file or directory",
   168  }, {
   169  	about: "index: invalid template",
   170  	setup: func(c *gc.C, baseDir string, storage binarystorage.Storage) string {
   171  		setupGUIArchive(c, storage, "2.0.47", map[string]string{
   172  			guiIndexPath:         "{{.BadWolf.47}}",
   173  			apiserver.SpritePath: "",
   174  		})
   175  		return ""
   176  	},
   177  	currentVersion: "2.0.47",
   178  	expectedStatus: http.StatusInternalServerError,
   179  	expectedError:  `cannot parse template: template: index.html.go:1: unexpected ".47" .*`,
   180  }, {
   181  	about: "index: invalid template and context",
   182  	setup: func(c *gc.C, baseDir string, storage binarystorage.Storage) string {
   183  		setupGUIArchive(c, storage, "2.0.47", map[string]string{
   184  			guiIndexPath:         "{{range .debug}}{{end}}",
   185  			apiserver.SpritePath: "",
   186  		})
   187  		return ""
   188  	},
   189  	currentVersion: "2.0.47",
   190  	expectedStatus: http.StatusInternalServerError,
   191  	expectedError:  `cannot render template: template: .*: range can't iterate over .*`,
   192  }, {
   193  	about: "config: template not found",
   194  	setup: func(c *gc.C, baseDir string, storage binarystorage.Storage) string {
   195  		return setupGUIArchive(c, storage, "2.0.42", nil)
   196  	},
   197  	currentVersion: "2.0.42",
   198  	pathAndquery:   "/config.js",
   199  	expectedStatus: http.StatusInternalServerError,
   200  	expectedError:  "cannot parse template: .*: no such file or directory",
   201  }, {
   202  	about: "config: invalid template",
   203  	setup: func(c *gc.C, baseDir string, storage binarystorage.Storage) string {
   204  		return setupGUIArchive(c, storage, "2.0.47", map[string]string{
   205  			guiConfigPath: "{{.BadWolf.47}}",
   206  		})
   207  	},
   208  	currentVersion: "2.0.47",
   209  	pathAndquery:   "/config.js",
   210  	expectedStatus: http.StatusInternalServerError,
   211  	expectedError:  `cannot parse template: template: config.js.go:1: unexpected ".47" .*`,
   212  }, {
   213  	about: "config: invalid hash",
   214  	setup: func(c *gc.C, baseDir string, storage binarystorage.Storage) string {
   215  		setupGUIArchive(c, storage, "2.0.47", nil)
   216  		return "invalid"
   217  	},
   218  	currentVersion: "2.0.47",
   219  	pathAndquery:   "/config.js",
   220  	expectedStatus: http.StatusNotFound,
   221  	expectedError:  `resource with "invalid" hash not found`,
   222  }, {
   223  	about: "combo: invalid file name",
   224  	setup: func(c *gc.C, baseDir string, storage binarystorage.Storage) string {
   225  		return setupGUIArchive(c, storage, "1.0.0", nil)
   226  	},
   227  	currentVersion: "1.0.0",
   228  	pathAndquery:   "/combo?foo&%%",
   229  	expectedStatus: http.StatusBadRequest,
   230  	expectedError:  `cannot combine files: invalid file name "%": invalid URL escape "%%"`,
   231  }, {
   232  	about: "combo: invalid file path",
   233  	setup: func(c *gc.C, baseDir string, storage binarystorage.Storage) string {
   234  		return setupGUIArchive(c, storage, "1.0.0", nil)
   235  	},
   236  	currentVersion: "1.0.0",
   237  	pathAndquery:   "/combo?../../../../../../etc/passwd",
   238  	expectedStatus: http.StatusBadRequest,
   239  	expectedError:  `cannot combine files: forbidden file path "../../../../../../etc/passwd"`,
   240  }, {
   241  	about: "combo: invalid hash",
   242  	setup: func(c *gc.C, baseDir string, storage binarystorage.Storage) string {
   243  		setupGUIArchive(c, storage, "2.0.47", nil)
   244  		return "invalid"
   245  	},
   246  	currentVersion: "2.0.47",
   247  	pathAndquery:   "/combo?foo",
   248  	expectedStatus: http.StatusNotFound,
   249  	expectedError:  `resource with "invalid" hash not found`,
   250  }, {
   251  	about: "combo: success",
   252  	setup: func(c *gc.C, baseDir string, storage binarystorage.Storage) string {
   253  		return setupGUIArchive(c, storage, "1.0.0", map[string]string{
   254  			"static/gui/build/tng/picard.js":  "enterprise",
   255  			"static/gui/build/ds9/sisko.js":   "deep space nine",
   256  			"static/gui/build/voy/janeway.js": "voyager",
   257  			"static/gui/build/borg.js":        "cube",
   258  		})
   259  	},
   260  	currentVersion:      "1.0.0",
   261  	pathAndquery:        "/combo?voy/janeway.js&tng/picard.js&borg.js&ds9/sisko.js",
   262  	expectedStatus:      http.StatusOK,
   263  	expectedContentType: apiserver.JSMimeType,
   264  	expectedBody: `voyager
   265  /* janeway.js */
   266  enterprise
   267  /* picard.js */
   268  cube
   269  /* borg.js */
   270  deep space nine
   271  /* sisko.js */
   272  `,
   273  }, {
   274  	about: "combo: non-existing files ignored + different content types",
   275  	setup: func(c *gc.C, baseDir string, storage binarystorage.Storage) string {
   276  		return setupGUIArchive(c, storage, "1.0.0", map[string]string{
   277  			"static/gui/build/foo.css": "my-style",
   278  		})
   279  	},
   280  	currentVersion:      "1.0.0",
   281  	pathAndquery:        "/combo?no-such.css&foo.css&bad-wolf.css",
   282  	expectedStatus:      http.StatusOK,
   283  	expectedContentType: "text/css; charset=utf-8",
   284  	expectedBody: `my-style
   285  /* foo.css */
   286  `,
   287  }, {
   288  	about: "static files",
   289  	setup: func(c *gc.C, baseDir string, storage binarystorage.Storage) string {
   290  		return setupGUIArchive(c, storage, "1.0.0", map[string]string{
   291  			"static/file.js": "static file content",
   292  		})
   293  	},
   294  	currentVersion:      "1.0.0",
   295  	pathAndquery:        "/static/file.js",
   296  	expectedStatus:      http.StatusOK,
   297  	expectedContentType: apiserver.JSMimeType,
   298  	expectedBody:        "static file content",
   299  }, {
   300  	about: "static files: invalid hash",
   301  	setup: func(c *gc.C, baseDir string, storage binarystorage.Storage) string {
   302  		setupGUIArchive(c, storage, "2.0.47", nil)
   303  		return "bad-wolf"
   304  	},
   305  	currentVersion: "2.0.47",
   306  	pathAndquery:   "/static/file.js",
   307  	expectedStatus: http.StatusNotFound,
   308  	expectedError:  `resource with "bad-wolf" hash not found`,
   309  }, {
   310  	about: "static files: old version hash",
   311  	setup: func(c *gc.C, baseDir string, storage binarystorage.Storage) string {
   312  		setupGUIArchive(c, storage, "2.1.1", map[string]string{
   313  			"static/file.js": "static file version 2.1.1",
   314  		})
   315  		return setupGUIArchive(c, storage, "2.1.2", map[string]string{
   316  			"static/file.js": "static file version 2.1.2",
   317  		})
   318  	},
   319  	currentVersion: "2.1.1",
   320  	pathAndquery:   "/static/file.js",
   321  	expectedStatus: http.StatusNotFound,
   322  	expectedError:  `resource with ".*" hash not found`,
   323  }}
   324  
   325  func (s *guiSuite) TestGUIHandler(c *gc.C) {
   326  	if runtime.GOOS == "windows" {
   327  		// Skipping the tests on Windows is not a problem as the Juju GUI is
   328  		// only served from Linux machines.
   329  		c.Skip("bzip2 command not available")
   330  	}
   331  	sendRequest := func(setup guiSetupFunc, currentVersion, pathAndquery string) *http.Response {
   332  		// Set up the GUI base directory.
   333  		datadir := filepath.ToSlash(s.DataDir())
   334  		baseDir := filepath.FromSlash(agenttools.SharedGUIDir(datadir))
   335  		defer func() {
   336  			os.Chmod(baseDir, 0755)
   337  			os.Remove(baseDir)
   338  		}()
   339  
   340  		// Run specific test set up.
   341  		var hash string
   342  		if setup != nil {
   343  			storage, err := s.State.GUIStorage()
   344  			c.Assert(err, jc.ErrorIsNil)
   345  			defer storage.Close()
   346  
   347  			// Ensure the GUI storage is empty.
   348  			allMeta, err := storage.AllMetadata()
   349  			c.Assert(err, jc.ErrorIsNil)
   350  			c.Assert(allMeta, gc.HasLen, 0)
   351  
   352  			hash = setup(c, baseDir, storage)
   353  		}
   354  
   355  		// Set the current GUI version if required.
   356  		if currentVersion != "" {
   357  			err := s.State.GUISetVersion(version.MustParse(currentVersion))
   358  			c.Assert(err, jc.ErrorIsNil)
   359  		}
   360  
   361  		// Send a request to the test path.
   362  		if pathAndquery == "" {
   363  			pathAndquery = "/"
   364  		}
   365  		return s.sendRequest(c, httpRequestParams{
   366  			url: s.guiURL(c, hash, pathAndquery),
   367  		})
   368  	}
   369  
   370  	for i, test := range guiHandlerTests {
   371  		c.Logf("\n%d: %s", i, test.about)
   372  
   373  		// Reset the db so that the GUI storage is empty in each test.
   374  		s.Reset(c)
   375  
   376  		// Perform the request.
   377  		resp := sendRequest(test.setup, test.currentVersion, test.pathAndquery)
   378  
   379  		// Check the response.
   380  		if test.expectedStatus == 0 {
   381  			test.expectedStatus = http.StatusOK
   382  		}
   383  		if test.expectedError != "" {
   384  			test.expectedContentType = params.ContentTypeJSON
   385  		}
   386  		body := assertResponse(c, resp, test.expectedStatus, test.expectedContentType)
   387  		if test.expectedError == "" {
   388  			c.Assert(string(body), gc.Equals, test.expectedBody)
   389  		} else {
   390  			var jsonResp params.ErrorResult
   391  			err := json.Unmarshal(body, &jsonResp)
   392  			c.Assert(err, jc.ErrorIsNil, gc.Commentf("body: %s", body))
   393  			c.Assert(jsonResp.Error.Message, gc.Matches, test.expectedError)
   394  		}
   395  	}
   396  }
   397  
   398  func (s *guiSuite) TestGUIIndex(c *gc.C) {
   399  	storage, err := s.State.GUIStorage()
   400  	c.Assert(err, jc.ErrorIsNil)
   401  	defer storage.Close()
   402  
   403  	// Create a Juju GUI archive and save it into the storage.
   404  	indexContent := `
   405  <!DOCTYPE html>
   406  <html>
   407  <body>
   408      staticURL: {{.staticURL}}
   409      comboURL: {{.comboURL}}
   410      configURL: {{.configURL}}
   411      debug: {{.debug}}
   412      spriteContent: {{.spriteContent}}
   413  </body>
   414  </html>`
   415  	vers := version.MustParse("2.0.0")
   416  	hash := setupGUIArchive(c, storage, vers.String(), map[string]string{
   417  		guiIndexPath:         indexContent,
   418  		apiserver.SpritePath: "sprite content",
   419  	})
   420  	err = s.State.GUISetVersion(vers)
   421  	c.Assert(err, jc.ErrorIsNil)
   422  
   423  	expectedIndexContent := fmt.Sprintf(`
   424  <!DOCTYPE html>
   425  <html>
   426  <body>
   427      staticURL: /gui/%[1]s/%[2]s
   428      comboURL: /gui/%[1]s/%[2]s/combo
   429      configURL: /gui/%[1]s/%[2]s/config.js
   430      debug: false
   431      spriteContent: sprite content
   432  </body>
   433  </html>`, s.modelUUID, hash)
   434  	// Make a request for the Juju GUI index.
   435  	resp := s.sendRequest(c, httpRequestParams{
   436  		url: s.guiURL(c, "", "/"),
   437  	})
   438  	body := assertResponse(c, resp, http.StatusOK, "text/html; charset=utf-8")
   439  	c.Assert(string(body), gc.Equals, expectedIndexContent)
   440  
   441  	// Non-handled paths are served by the index handler.
   442  	resp = s.sendRequest(c, httpRequestParams{
   443  		url: s.guiURL(c, "", "/no-such-path/"),
   444  	})
   445  	body = assertResponse(c, resp, http.StatusOK, "text/html; charset=utf-8")
   446  	c.Assert(string(body), gc.Equals, expectedIndexContent)
   447  }
   448  
   449  func (s *guiSuite) TestGUIIndexVersions(c *gc.C) {
   450  	storage, err := s.State.GUIStorage()
   451  	c.Assert(err, jc.ErrorIsNil)
   452  	defer storage.Close()
   453  
   454  	// Create Juju GUI archives and save it into the storage.
   455  	setupGUIArchive(c, storage, "1.0.0", map[string]string{
   456  		guiIndexPath:         "index version 1.0.0",
   457  		apiserver.SpritePath: "sprite content",
   458  	})
   459  	vers2 := version.MustParse("2.0.0")
   460  	setupGUIArchive(c, storage, vers2.String(), map[string]string{
   461  		guiIndexPath:         "index version 2.0.0",
   462  		apiserver.SpritePath: "sprite content",
   463  	})
   464  	vers3 := version.MustParse("3.0.0")
   465  	setupGUIArchive(c, storage, vers3.String(), map[string]string{
   466  		guiIndexPath:         "index version 3.0.0",
   467  		apiserver.SpritePath: "sprite content",
   468  	})
   469  
   470  	// Check that the correct index version is served.
   471  	err = s.State.GUISetVersion(vers2)
   472  	c.Assert(err, jc.ErrorIsNil)
   473  	resp := s.sendRequest(c, httpRequestParams{
   474  		url: s.guiURL(c, "", "/"),
   475  	})
   476  	body := assertResponse(c, resp, http.StatusOK, "text/plain; charset=utf-8")
   477  	c.Assert(string(body), gc.Equals, "index version 2.0.0")
   478  
   479  	err = s.State.GUISetVersion(vers3)
   480  	c.Assert(err, jc.ErrorIsNil)
   481  	resp = s.sendRequest(c, httpRequestParams{
   482  		url: s.guiURL(c, "", "/"),
   483  	})
   484  	body = assertResponse(c, resp, http.StatusOK, "text/plain; charset=utf-8")
   485  	c.Assert(string(body), gc.Equals, "index version 3.0.0")
   486  }
   487  
   488  func (s *guiSuite) TestGUIConfig(c *gc.C) {
   489  	storage, err := s.State.GUIStorage()
   490  	c.Assert(err, jc.ErrorIsNil)
   491  	defer storage.Close()
   492  
   493  	// Create a Juju GUI archive and save it into the storage.
   494  	configContent := `
   495  var config = {
   496      // This is just an example and does not reflect the real Juju GUI config.
   497      base: '{{.base}}',
   498      host: '{{.host}}',
   499      socket: '{{.socket}}',
   500      staticURL: '{{.staticURL}}',
   501      uuid: '{{.uuid}}',
   502      version: '{{.version}}'
   503  };`
   504  	vers := version.MustParse("2.0.0")
   505  	hash := setupGUIArchive(c, storage, vers.String(), map[string]string{
   506  		guiConfigPath: configContent,
   507  	})
   508  	err = s.State.GUISetVersion(vers)
   509  	c.Assert(err, jc.ErrorIsNil)
   510  
   511  	expectedConfigContent := fmt.Sprintf(`
   512  var config = {
   513      // This is just an example and does not reflect the real Juju GUI config.
   514      base: '/gui/%[1]s/',
   515      host: '%[2]s',
   516      socket: '/model/$uuid/api',
   517      staticURL: '/gui/%[1]s/%[3]s',
   518      uuid: '%[1]s',
   519      version: '%[4]s'
   520  };`, s.modelUUID, s.baseURL(c).Host, hash, jujuversion.Current)
   521  
   522  	// Make a request for the Juju GUI config.
   523  	resp := s.sendRequest(c, httpRequestParams{
   524  		url: s.guiURL(c, hash, "/config.js"),
   525  	})
   526  	body := assertResponse(c, resp, http.StatusOK, apiserver.JSMimeType)
   527  	c.Assert(string(body), gc.Equals, expectedConfigContent)
   528  }
   529  
   530  func (s *guiSuite) TestGUIDirectory(c *gc.C) {
   531  	storage, err := s.State.GUIStorage()
   532  	c.Assert(err, jc.ErrorIsNil)
   533  	defer storage.Close()
   534  
   535  	// Create a Juju GUI archive and save it into the storage.
   536  	indexContent := "<!DOCTYPE html><html><body>Exterminate!</body></html>"
   537  	vers := version.MustParse("2.0.0")
   538  	hash := setupGUIArchive(c, storage, vers.String(), map[string]string{
   539  		guiIndexPath:         indexContent,
   540  		apiserver.SpritePath: "",
   541  	})
   542  	err = s.State.GUISetVersion(vers)
   543  	c.Assert(err, jc.ErrorIsNil)
   544  
   545  	// Initially the GUI directory on the server is empty.
   546  	baseDir := agenttools.SharedGUIDir(s.DataDir())
   547  	c.Assert(baseDir, jc.DoesNotExist)
   548  
   549  	// Make a request for the Juju GUI.
   550  	resp := s.sendRequest(c, httpRequestParams{
   551  		url: s.guiURL(c, "", "/"),
   552  	})
   553  	body := assertResponse(c, resp, http.StatusOK, "text/html; charset=utf-8")
   554  	c.Assert(string(body), gc.Equals, indexContent)
   555  
   556  	// Now the GUI is stored on disk, in a directory corresponding to its
   557  	// archive SHA256 hash.
   558  	indexPath := filepath.Join(baseDir, hash, guiIndexPath)
   559  	c.Assert(indexPath, jc.IsNonEmptyFile)
   560  	b, err := ioutil.ReadFile(indexPath)
   561  	c.Assert(err, jc.ErrorIsNil)
   562  	c.Assert(string(b), gc.Equals, indexContent)
   563  }
   564  
   565  type guiArchiveSuite struct {
   566  	authHttpSuite
   567  }
   568  
   569  var _ = gc.Suite(&guiArchiveSuite{})
   570  
   571  // guiURL returns the URL used to retrieve info on or upload Juju GUI archives.
   572  func (s *guiArchiveSuite) guiURL(c *gc.C) string {
   573  	u := s.baseURL(c)
   574  	u.Path = "/gui-archive"
   575  	return u.String()
   576  }
   577  
   578  func (s *guiArchiveSuite) TestGUIArchiveMethodNotAllowed(c *gc.C) {
   579  	resp := s.authRequest(c, httpRequestParams{
   580  		method: "PUT",
   581  		url:    s.guiURL(c),
   582  	})
   583  	body := assertResponse(c, resp, http.StatusMethodNotAllowed, params.ContentTypeJSON)
   584  	var jsonResp params.ErrorResult
   585  	err := json.Unmarshal(body, &jsonResp)
   586  	c.Assert(err, jc.ErrorIsNil, gc.Commentf("body: %s", body))
   587  	c.Assert(jsonResp.Error.Message, gc.Matches, `unsupported method: "PUT"`)
   588  }
   589  
   590  var guiArchiveGetTests = []struct {
   591  	about    string
   592  	versions []string
   593  	current  string
   594  }{{
   595  	about: "empty storage",
   596  }, {
   597  	about:    "one version",
   598  	versions: []string{"2.42.0"},
   599  }, {
   600  	about:    "one version (current)",
   601  	versions: []string{"2.42.0"},
   602  	current:  "2.42.0",
   603  }, {
   604  	about:    "multiple versions",
   605  	versions: []string{"2.42.0", "3.0.0", "2.47.1"},
   606  }, {
   607  	about:    "multiple versions (current)",
   608  	versions: []string{"2.42.0", "3.0.0", "2.47.1"},
   609  	current:  "3.0.0",
   610  }}
   611  
   612  func (s *guiArchiveSuite) TestGUIArchiveGet(c *gc.C) {
   613  	for i, test := range guiArchiveGetTests {
   614  		c.Logf("\n%d: %s", i, test.about)
   615  
   616  		uploadVersions := func(versions []string, current string) params.GUIArchiveResponse {
   617  			// Open the GUI storage.
   618  			storage, err := s.State.GUIStorage()
   619  			c.Assert(err, jc.ErrorIsNil)
   620  			defer storage.Close()
   621  
   622  			// Add the versions to the storage.
   623  			expectedVersions := make([]params.GUIArchiveVersion, len(versions))
   624  			for i, vers := range versions {
   625  				files := map[string]string{"file": fmt.Sprintf("content %d", i)}
   626  				v := version.MustParse(vers)
   627  				hash := setupGUIArchive(c, storage, vers, files)
   628  				expectedVersions[i] = params.GUIArchiveVersion{
   629  					Version: v,
   630  					SHA256:  hash,
   631  				}
   632  				if vers == current {
   633  					err := s.State.GUISetVersion(v)
   634  					c.Assert(err, jc.ErrorIsNil)
   635  					expectedVersions[i].Current = true
   636  				}
   637  			}
   638  			return params.GUIArchiveResponse{
   639  				Versions: expectedVersions,
   640  			}
   641  		}
   642  
   643  		// Reset the db so that the GUI storage is empty in each test.
   644  		s.Reset(c)
   645  
   646  		// Send the request to retrieve GUI version information.
   647  		expectedResponse := uploadVersions(test.versions, test.current)
   648  		resp := s.sendRequest(c, httpRequestParams{
   649  			url: s.guiURL(c),
   650  		})
   651  
   652  		// Check that a successful response is returned.
   653  		body := assertResponse(c, resp, http.StatusOK, params.ContentTypeJSON)
   654  		var jsonResponse params.GUIArchiveResponse
   655  		err := json.Unmarshal(body, &jsonResponse)
   656  		c.Assert(err, jc.ErrorIsNil, gc.Commentf("body: %s", body))
   657  		c.Assert(jsonResponse, jc.DeepEquals, expectedResponse)
   658  	}
   659  }
   660  
   661  var guiArchivePostErrorsTests = []struct {
   662  	about           string
   663  	contentType     string
   664  	query           string
   665  	noContentLength bool
   666  	expectedStatus  int
   667  	expectedError   string
   668  }{{
   669  	about:          "no content type",
   670  	expectedStatus: http.StatusBadRequest,
   671  	expectedError:  fmt.Sprintf(`invalid content type "": expected %q`, apiserver.BZMimeType),
   672  }, {
   673  	about:          "invalid content type",
   674  	contentType:    "text/html",
   675  	expectedStatus: http.StatusBadRequest,
   676  	expectedError:  fmt.Sprintf(`invalid content type "text/html": expected %q`, apiserver.BZMimeType),
   677  }, {
   678  	about:          "no version provided",
   679  	contentType:    apiserver.BZMimeType,
   680  	expectedStatus: http.StatusBadRequest,
   681  	expectedError:  "version parameter not provided",
   682  }, {
   683  	about:          "invalid version",
   684  	contentType:    apiserver.BZMimeType,
   685  	query:          "?version=bad-wolf",
   686  	expectedStatus: http.StatusBadRequest,
   687  	expectedError:  `invalid version parameter "bad-wolf"`,
   688  }, {
   689  	about:           "no content length provided",
   690  	contentType:     apiserver.BZMimeType,
   691  	query:           "?version=2.0.42&hash=sha",
   692  	noContentLength: true,
   693  	expectedStatus:  http.StatusBadRequest,
   694  	expectedError:   "content length not provided",
   695  }, {
   696  	about:          "no hash provided",
   697  	contentType:    apiserver.BZMimeType,
   698  	query:          "?version=2.0.42",
   699  	expectedStatus: http.StatusBadRequest,
   700  	expectedError:  "hash parameter not provided",
   701  }, {
   702  	about:          "content hash mismatch",
   703  	contentType:    apiserver.BZMimeType,
   704  	query:          "?version=2.0.42&hash=bad-wolf",
   705  	expectedStatus: http.StatusBadRequest,
   706  	expectedError:  "archive does not match provided hash",
   707  }}
   708  
   709  func (s *guiArchiveSuite) TestGUIArchivePostErrors(c *gc.C) {
   710  	type exoticReader struct {
   711  		io.Reader
   712  	}
   713  	for i, test := range guiArchivePostErrorsTests {
   714  		c.Logf("\n%d: %s", i, test.about)
   715  
   716  		// Prepare the request.
   717  		var r io.Reader = strings.NewReader("archive contents")
   718  		if test.noContentLength {
   719  			// net/http will automatically add a Content-Length header if it
   720  			// sees *strings.Reader, but not if it's a type it doesn't know.
   721  			r = exoticReader{r}
   722  		}
   723  
   724  		// Send the request and retrieve the error response.
   725  		resp := s.authRequest(c, httpRequestParams{
   726  			method:      "POST",
   727  			url:         s.guiURL(c) + test.query,
   728  			contentType: test.contentType,
   729  			body:        r,
   730  		})
   731  		body := assertResponse(c, resp, test.expectedStatus, params.ContentTypeJSON)
   732  		var jsonResp params.ErrorResult
   733  		err := json.Unmarshal(body, &jsonResp)
   734  		c.Assert(err, jc.ErrorIsNil, gc.Commentf("body: %s", body))
   735  		c.Assert(jsonResp.Error.Message, gc.Matches, test.expectedError)
   736  	}
   737  }
   738  
   739  func (s *guiArchiveSuite) TestGUIArchivePostErrorUnauthorized(c *gc.C) {
   740  	resp := s.sendRequest(c, httpRequestParams{
   741  		method:      "POST",
   742  		url:         s.guiURL(c) + "?version=2.0.0&hash=sha",
   743  		contentType: apiserver.BZMimeType,
   744  		body:        strings.NewReader("archive contents"),
   745  	})
   746  	body := assertResponse(c, resp, http.StatusUnauthorized, params.ContentTypeJSON)
   747  	var jsonResp params.ErrorResult
   748  	err := json.Unmarshal(body, &jsonResp)
   749  	c.Assert(err, jc.ErrorIsNil, gc.Commentf("body: %s", body))
   750  	c.Assert(jsonResp.Error.Message, gc.Matches, "cannot open state: no credentials provided")
   751  }
   752  
   753  func (s *guiArchiveSuite) TestGUIArchivePostSuccess(c *gc.C) {
   754  	// Create a GUI archive to be uploaded.
   755  	vers := "2.0.42"
   756  	r, hash, size := makeGUIArchive(c, vers, nil)
   757  
   758  	// Prepare and send the request to upload a new GUI archive.
   759  	v := url.Values{}
   760  	v.Set("version", vers)
   761  	v.Set("hash", hash)
   762  	resp := s.authRequest(c, httpRequestParams{
   763  		method:      "POST",
   764  		url:         s.guiURL(c) + "?" + v.Encode(),
   765  		contentType: apiserver.BZMimeType,
   766  		body:        r,
   767  	})
   768  
   769  	// Check that the response reflects a successful upload.
   770  	body := assertResponse(c, resp, http.StatusOK, params.ContentTypeJSON)
   771  	var jsonResponse params.GUIArchiveVersion
   772  	err := json.Unmarshal(body, &jsonResponse)
   773  	c.Assert(err, jc.ErrorIsNil, gc.Commentf("body: %s", body))
   774  	c.Assert(jsonResponse, jc.DeepEquals, params.GUIArchiveVersion{
   775  		Version: version.MustParse(vers),
   776  		SHA256:  hash,
   777  		Current: false,
   778  	})
   779  
   780  	// Check that the new archive is actually present in the GUI storage.
   781  	storage, err := s.State.GUIStorage()
   782  	c.Assert(err, jc.ErrorIsNil)
   783  	defer storage.Close()
   784  	allMeta, err := storage.AllMetadata()
   785  	c.Assert(err, jc.ErrorIsNil)
   786  	c.Assert(allMeta, gc.HasLen, 1)
   787  	c.Assert(allMeta[0].SHA256, gc.Equals, hash)
   788  	c.Assert(allMeta[0].Size, gc.Equals, size)
   789  }
   790  
   791  func (s *guiArchiveSuite) TestGUIArchivePostCurrent(c *gc.C) {
   792  	// Add an existing GUI archive and set it as the current one.
   793  	storage, err := s.State.GUIStorage()
   794  	c.Assert(err, jc.ErrorIsNil)
   795  	defer storage.Close()
   796  	vers := version.MustParse("2.0.47")
   797  	setupGUIArchive(c, storage, vers.String(), nil)
   798  	err = s.State.GUISetVersion(vers)
   799  	c.Assert(err, jc.ErrorIsNil)
   800  
   801  	// Create a GUI archive to be uploaded.
   802  	r, hash, _ := makeGUIArchive(c, vers.String(), map[string]string{"filename": "content"})
   803  
   804  	// Prepare and send the request to upload a new GUI archive.
   805  	v := url.Values{}
   806  	v.Set("version", vers.String())
   807  	v.Set("hash", hash)
   808  	resp := s.authRequest(c, httpRequestParams{
   809  		method:      "POST",
   810  		url:         s.guiURL(c) + "?" + v.Encode(),
   811  		contentType: apiserver.BZMimeType,
   812  		body:        r,
   813  	})
   814  
   815  	// Check that the response reflects a successful upload.
   816  	body := assertResponse(c, resp, http.StatusOK, params.ContentTypeJSON)
   817  	var jsonResponse params.GUIArchiveVersion
   818  	err = json.Unmarshal(body, &jsonResponse)
   819  	c.Assert(err, jc.ErrorIsNil, gc.Commentf("body: %s", body))
   820  	c.Assert(jsonResponse, jc.DeepEquals, params.GUIArchiveVersion{
   821  		Version: vers,
   822  		SHA256:  hash,
   823  		Current: true,
   824  	})
   825  }
   826  
   827  type guiVersionSuite struct {
   828  	authHttpSuite
   829  }
   830  
   831  var _ = gc.Suite(&guiVersionSuite{})
   832  
   833  // guiURL returns the URL used to select the Juju GUI archive version.
   834  func (s *guiVersionSuite) guiURL(c *gc.C) string {
   835  	u := s.baseURL(c)
   836  	u.Path = "/gui-version"
   837  	return u.String()
   838  }
   839  
   840  func (s *guiVersionSuite) TestGUIVersionMethodNotAllowed(c *gc.C) {
   841  	resp := s.authRequest(c, httpRequestParams{
   842  		method: "GET",
   843  		url:    s.guiURL(c),
   844  	})
   845  	body := assertResponse(c, resp, http.StatusMethodNotAllowed, params.ContentTypeJSON)
   846  	var jsonResp params.ErrorResult
   847  	err := json.Unmarshal(body, &jsonResp)
   848  	c.Assert(err, jc.ErrorIsNil, gc.Commentf("body: %s", body))
   849  	c.Assert(jsonResp.Error.Message, gc.Matches, `unsupported method: "GET"`)
   850  }
   851  
   852  var guiVersionPutTests = []struct {
   853  	about           string
   854  	contentType     string
   855  	body            interface{}
   856  	expectedStatus  int
   857  	expectedVersion string
   858  	expectedError   string
   859  }{{
   860  	about:          "no content type",
   861  	expectedStatus: http.StatusBadRequest,
   862  	expectedError:  fmt.Sprintf(`invalid content type "": expected %q`, params.ContentTypeJSON),
   863  }, {
   864  	about:          "invalid content type",
   865  	contentType:    "text/html",
   866  	expectedStatus: http.StatusBadRequest,
   867  	expectedError:  fmt.Sprintf(`invalid content type "text/html": expected %q`, params.ContentTypeJSON),
   868  }, {
   869  	about:          "invalid body",
   870  	contentType:    params.ContentTypeJSON,
   871  	body:           "bad wolf",
   872  	expectedStatus: http.StatusBadRequest,
   873  	expectedError:  "invalid request body: json: .*",
   874  }, {
   875  	about:       "non existing version",
   876  	contentType: params.ContentTypeJSON,
   877  	body: params.GUIVersionRequest{
   878  		Version: version.MustParse("2.0.1"),
   879  	},
   880  	expectedStatus: http.StatusNotFound,
   881  	expectedError:  `cannot find "2.0.1" GUI version in the storage: 2.0.1 binary metadata not found`,
   882  }, {
   883  	about:       "success: switch to new version",
   884  	contentType: params.ContentTypeJSON,
   885  	body: params.GUIVersionRequest{
   886  		Version: version.MustParse("2.47.0"),
   887  	},
   888  	expectedStatus:  http.StatusOK,
   889  	expectedVersion: "2.47.0",
   890  }, {
   891  	about:       "success: same version",
   892  	contentType: params.ContentTypeJSON,
   893  	body: params.GUIVersionRequest{
   894  		Version: version.MustParse("2.42.0"),
   895  	},
   896  	expectedStatus:  http.StatusOK,
   897  	expectedVersion: "2.42.0",
   898  }}
   899  
   900  func (s *guiVersionSuite) TestGUIVersionPut(c *gc.C) {
   901  	// Prepare the initial Juju state.
   902  	storage, err := s.State.GUIStorage()
   903  	c.Assert(err, jc.ErrorIsNil)
   904  	defer storage.Close()
   905  	setupGUIArchive(c, storage, "2.42.0", nil)
   906  	setupGUIArchive(c, storage, "2.47.0", nil)
   907  	err = s.State.GUISetVersion(version.MustParse("2.42.0"))
   908  	c.Assert(err, jc.ErrorIsNil)
   909  
   910  	for i, test := range guiVersionPutTests {
   911  		c.Logf("\n%d: %s", i, test.about)
   912  
   913  		// Prepare the request.
   914  		content, err := json.Marshal(test.body)
   915  		c.Assert(err, jc.ErrorIsNil)
   916  
   917  		// Send the request and retrieve the response.
   918  		resp := s.authRequest(c, httpRequestParams{
   919  			method:      "PUT",
   920  			url:         s.guiURL(c),
   921  			contentType: test.contentType,
   922  			body:        bytes.NewReader(content),
   923  		})
   924  		var body []byte
   925  		if test.expectedError != "" {
   926  			body = assertResponse(c, resp, test.expectedStatus, params.ContentTypeJSON)
   927  			var jsonResp params.ErrorResult
   928  			err := json.Unmarshal(body, &jsonResp)
   929  			c.Assert(err, jc.ErrorIsNil, gc.Commentf("body: %s", body))
   930  			c.Assert(jsonResp.Error.Message, gc.Matches, test.expectedError)
   931  		} else {
   932  			body = assertResponse(c, resp, test.expectedStatus, "text/plain; charset=utf-8")
   933  			c.Assert(body, gc.HasLen, 0)
   934  			vers, err := s.State.GUIVersion()
   935  			c.Assert(err, jc.ErrorIsNil)
   936  			c.Assert(vers.String(), gc.Equals, test.expectedVersion)
   937  		}
   938  	}
   939  }
   940  
   941  func (s *guiVersionSuite) TestGUIVersionPutErrorUnauthorized(c *gc.C) {
   942  	resp := s.sendRequest(c, httpRequestParams{
   943  		method:      "PUT",
   944  		url:         s.guiURL(c),
   945  		contentType: params.ContentTypeJSON,
   946  	})
   947  	body := assertResponse(c, resp, http.StatusUnauthorized, params.ContentTypeJSON)
   948  	var jsonResp params.ErrorResult
   949  	err := json.Unmarshal(body, &jsonResp)
   950  	c.Assert(err, jc.ErrorIsNil, gc.Commentf("body: %s", body))
   951  	c.Assert(jsonResp.Error.Message, gc.Matches, "cannot open state: no credentials provided")
   952  }
   953  
   954  // makeGUIArchive creates a Juju GUI tar.bz2 archive with the given files.
   955  // The files parameter maps file names (relative to the internal "jujugui"
   956  // directory) to their contents. This function returns a reader for the
   957  // archive, its hash and size.
   958  func makeGUIArchive(c *gc.C, vers string, files map[string]string) (r io.Reader, hash string, size int64) {
   959  	if runtime.GOOS == "windows" {
   960  		// Skipping the tests on Windows is not a problem as the Juju GUI is
   961  		// only served from Linux machines.
   962  		c.Skip("bzip2 command not available")
   963  	}
   964  	cmd := exec.Command("bzip2", "--compress", "--stdout", "--fast")
   965  
   966  	stdin, err := cmd.StdinPipe()
   967  	c.Assert(err, jc.ErrorIsNil)
   968  	stdout, err := cmd.StdoutPipe()
   969  	c.Assert(err, jc.ErrorIsNil)
   970  
   971  	err = cmd.Start()
   972  	c.Assert(err, jc.ErrorIsNil)
   973  
   974  	tw := tar.NewWriter(stdin)
   975  	baseDir := filepath.Join("jujugui-"+vers, "jujugui")
   976  	err = tw.WriteHeader(&tar.Header{
   977  		Name:     baseDir,
   978  		Mode:     0700,
   979  		Typeflag: tar.TypeDir,
   980  	})
   981  	c.Assert(err, jc.ErrorIsNil)
   982  	for path, content := range files {
   983  		name := filepath.Join(baseDir, path)
   984  		err = tw.WriteHeader(&tar.Header{
   985  			Name:     filepath.Dir(name),
   986  			Mode:     0700,
   987  			Typeflag: tar.TypeDir,
   988  		})
   989  		c.Assert(err, jc.ErrorIsNil)
   990  		err = tw.WriteHeader(&tar.Header{
   991  			Name: name,
   992  			Mode: 0600,
   993  			Size: int64(len(content)),
   994  		})
   995  		c.Assert(err, jc.ErrorIsNil)
   996  		_, err = io.WriteString(tw, content)
   997  		c.Assert(err, jc.ErrorIsNil)
   998  	}
   999  	err = tw.Close()
  1000  	c.Assert(err, jc.ErrorIsNil)
  1001  	err = stdin.Close()
  1002  	c.Assert(err, jc.ErrorIsNil)
  1003  
  1004  	h := sha256.New()
  1005  	r = io.TeeReader(stdout, h)
  1006  	b, err := ioutil.ReadAll(r)
  1007  	c.Assert(err, jc.ErrorIsNil)
  1008  
  1009  	err = cmd.Wait()
  1010  	c.Assert(err, jc.ErrorIsNil)
  1011  
  1012  	return bytes.NewReader(b), fmt.Sprintf("%x", h.Sum(nil)), int64(len(b))
  1013  }
  1014  
  1015  // setupGUIArchive creates a Juju GUI tar.bz2 archive with the given version
  1016  // and files and saves it into the given storage. The Juju GUI archive SHA256
  1017  // hash is returned.
  1018  func setupGUIArchive(c *gc.C, storage binarystorage.Storage, vers string, files map[string]string) (hash string) {
  1019  	r, hash, size := makeGUIArchive(c, vers, files)
  1020  	err := storage.Add(r, binarystorage.Metadata{
  1021  		Version: vers,
  1022  		Size:    size,
  1023  		SHA256:  hash,
  1024  	})
  1025  	c.Assert(err, jc.ErrorIsNil)
  1026  	return hash
  1027  }