1
// Copyright 2016 Canonical Ltd.
2
// Licensed under the AGPLv3, see LICENCE file for details.
21
jc "github.com/juju/testing/checkers"
22
gc "gopkg.in/check.v1"
24
agenttools "github.com/juju/juju/agent/tools"
25
"github.com/juju/juju/apiserver"
26
"github.com/juju/juju/apiserver/params"
27
"github.com/juju/juju/state/binarystorage"
28
"github.com/juju/juju/version"
32
guiConfigPath = "templates/config.js.go"
33
guiIndexPath = "templates/index.html.go"
36
type guiSuite struct {
40
var _ = gc.Suite(&guiSuite{})
42
// guiURL returns the complete URL where the Juju GUI can be found, including
43
// the given hash and pathAndquery.
44
func (s *guiSuite) guiURL(c *gc.C, hash, pathAndquery string) string {
46
path := "/gui/" + s.modelUUID
50
parts := strings.SplitN(pathAndquery, "?", 2)
51
u.Path = path + parts[0]
58
type guiSetupFunc func(c *gc.C, baseDir string, storage binarystorage.Storage) string
60
var guiHandlerTests = []struct {
61
// about describes the test.
63
// setup is optionally used to set up the test.
64
// It receives the Juju GUI base directory and an empty GUI storage.
65
// Optionally it can return a GUI archive hash which is used by the test
66
// to build the URL path for the HTTP request.
68
// pathAndquery holds the optional path and query for the request, for
69
// instance "/combo?file". If not provided, the "/" path is used.
71
// expectedStatus holds the expected response HTTP status.
72
// A 200 OK status is used by default.
74
// expectedContentType holds the expected response content type.
75
// If expectedError is provided this field is ignored.
76
expectedContentType string
77
// expectedBody holds the expected response body, only used if
78
// expectedError is not provided (see below).
80
// expectedError holds the expected error message included in the response.
83
about: "metadata not found",
84
expectedStatus: http.StatusNotFound,
85
expectedError: "Juju GUI not found",
87
about: "GUI directory is a file",
88
setup: func(c *gc.C, baseDir string, storage binarystorage.Storage) string {
89
err := storage.Add(strings.NewReader(""), binarystorage.Metadata{
92
c.Assert(err, jc.ErrorIsNil)
93
err = os.MkdirAll(baseDir, 0755)
94
c.Assert(err, jc.ErrorIsNil)
95
rootDir := filepath.Join(baseDir, "fake-hash")
96
err = ioutil.WriteFile(rootDir, nil, 0644)
97
c.Assert(err, jc.ErrorIsNil)
100
expectedStatus: http.StatusInternalServerError,
101
expectedError: "cannot use Juju GUI root directory .*",
103
about: "GUI directory is unaccessible",
104
setup: func(c *gc.C, baseDir string, storage binarystorage.Storage) string {
105
err := storage.Add(strings.NewReader(""), binarystorage.Metadata{
108
c.Assert(err, jc.ErrorIsNil)
109
err = os.MkdirAll(baseDir, 0000)
110
c.Assert(err, jc.ErrorIsNil)
113
expectedStatus: http.StatusInternalServerError,
114
expectedError: "cannot stat Juju GUI root directory: .*",
116
about: "invalid GUI archive",
117
setup: func(c *gc.C, baseDir string, storage binarystorage.Storage) string {
118
err := storage.Add(strings.NewReader(""), binarystorage.Metadata{
121
c.Assert(err, jc.ErrorIsNil)
124
expectedStatus: http.StatusInternalServerError,
125
expectedError: "cannot uncompress Juju GUI archive: cannot parse archive: .*",
127
about: "index: sprite file not found",
128
setup: func(c *gc.C, baseDir string, storage binarystorage.Storage) string {
129
setupGUIArchive(c, storage, "2.0.42", nil)
132
expectedStatus: http.StatusInternalServerError,
133
expectedError: "cannot read sprite file: .*",
135
about: "index: template not found",
136
setup: func(c *gc.C, baseDir string, storage binarystorage.Storage) string {
137
setupGUIArchive(c, storage, "2.0.42", map[string]string{
138
apiserver.SpritePath: "",
142
expectedStatus: http.StatusInternalServerError,
143
expectedError: "cannot parse template: .*: no such file or directory",
145
about: "index: invalid template",
146
setup: func(c *gc.C, baseDir string, storage binarystorage.Storage) string {
147
setupGUIArchive(c, storage, "2.0.47", map[string]string{
148
guiIndexPath: "{{.BadWolf.47}}",
149
apiserver.SpritePath: "",
153
expectedStatus: http.StatusInternalServerError,
154
expectedError: `cannot parse template: template: index.html.go:1: unexpected ".47" .*`,
156
about: "index: invalid template and context",
157
setup: func(c *gc.C, baseDir string, storage binarystorage.Storage) string {
158
setupGUIArchive(c, storage, "2.0.47", map[string]string{
159
guiIndexPath: "{{range .debug}}{{end}}",
160
apiserver.SpritePath: "",
164
expectedStatus: http.StatusInternalServerError,
165
expectedError: `cannot render template: template: .*: range can't iterate over .*`,
167
about: "config: template not found",
168
setup: func(c *gc.C, baseDir string, storage binarystorage.Storage) string {
169
return setupGUIArchive(c, storage, "2.0.42", nil)
171
pathAndquery: "/config.js",
172
expectedStatus: http.StatusInternalServerError,
173
expectedError: "cannot parse template: .*: no such file or directory",
175
about: "config: invalid template",
176
setup: func(c *gc.C, baseDir string, storage binarystorage.Storage) string {
177
return setupGUIArchive(c, storage, "2.0.47", map[string]string{
178
guiConfigPath: "{{.BadWolf.47}}",
181
pathAndquery: "/config.js",
182
expectedStatus: http.StatusInternalServerError,
183
expectedError: `cannot parse template: template: config.js.go:1: unexpected ".47" .*`,
185
about: "config: invalid hash",
186
setup: func(c *gc.C, baseDir string, storage binarystorage.Storage) string {
187
setupGUIArchive(c, storage, "2.0.47", nil)
190
pathAndquery: "/config.js",
191
expectedStatus: http.StatusNotFound,
192
expectedError: `resource with "invalid" hash not found`,
194
about: "combo: invalid file name",
195
setup: func(c *gc.C, baseDir string, storage binarystorage.Storage) string {
196
return setupGUIArchive(c, storage, "1.0.0", nil)
198
pathAndquery: "/combo?foo&%%",
199
expectedStatus: http.StatusBadRequest,
200
expectedError: `cannot combine files: invalid file name "%": invalid URL escape "%%"`,
202
about: "combo: invalid file path",
203
setup: func(c *gc.C, baseDir string, storage binarystorage.Storage) string {
204
return setupGUIArchive(c, storage, "1.0.0", nil)
206
pathAndquery: "/combo?../../../../../../etc/passwd",
207
expectedStatus: http.StatusBadRequest,
208
expectedError: `cannot combine files: forbidden file path "../../../../../../etc/passwd"`,
210
about: "combo: invalid hash",
211
setup: func(c *gc.C, baseDir string, storage binarystorage.Storage) string {
212
setupGUIArchive(c, storage, "2.0.47", nil)
215
pathAndquery: "/combo?foo",
216
expectedStatus: http.StatusNotFound,
217
expectedError: `resource with "invalid" hash not found`,
219
about: "combo: success",
220
setup: func(c *gc.C, baseDir string, storage binarystorage.Storage) string {
221
return setupGUIArchive(c, storage, "1.0.0", map[string]string{
222
"static/gui/build/tng/picard.js": "enterprise",
223
"static/gui/build/ds9/sisko.js": "deep space nine",
224
"static/gui/build/voy/janeway.js": "voyager",
225
"static/gui/build/borg.js": "cube",
228
pathAndquery: "/combo?voy/janeway.js&tng/picard.js&borg.js&ds9/sisko.js",
229
expectedStatus: http.StatusOK,
230
expectedContentType: apiserver.JSMimeType,
231
expectedBody: `voyager
241
about: "combo: non-existing files ignored + different content types",
242
setup: func(c *gc.C, baseDir string, storage binarystorage.Storage) string {
243
return setupGUIArchive(c, storage, "1.0.0", map[string]string{
244
"static/gui/build/foo.css": "my-style",
247
pathAndquery: "/combo?no-such.css&foo.css&bad-wolf.css",
248
expectedStatus: http.StatusOK,
249
expectedContentType: "text/css; charset=utf-8",
250
expectedBody: `my-style
254
about: "static files",
255
setup: func(c *gc.C, baseDir string, storage binarystorage.Storage) string {
256
return setupGUIArchive(c, storage, "1.0.0", map[string]string{
257
"static/file.js": "static file content",
260
pathAndquery: "/static/file.js",
261
expectedStatus: http.StatusOK,
262
expectedContentType: apiserver.JSMimeType,
263
expectedBody: "static file content",
265
about: "static files: invalid hash",
266
setup: func(c *gc.C, baseDir string, storage binarystorage.Storage) string {
267
setupGUIArchive(c, storage, "2.0.47", nil)
270
pathAndquery: "/static/file.js",
271
expectedStatus: http.StatusNotFound,
272
expectedError: `resource with "bad-wolf" hash not found`,
275
func (s *guiSuite) TestGUIHandler(c *gc.C) {
276
if runtime.GOOS == "windows" {
277
// Skipping the tests on Windows is not a problem as the Juju GUI is
278
// only served from Linux machines.
279
c.Skip("bzip2 command not available")
281
sendRequest := func(setup guiSetupFunc, pathAndquery string) *http.Response {
282
// Set up the GUI base directory.
283
datadir := filepath.ToSlash(s.DataDir())
284
baseDir := filepath.FromSlash(agenttools.SharedGUIDir(datadir))
286
os.Chmod(baseDir, 0755)
290
// Run specific test set up.
293
storage, err := s.State.GUIStorage()
294
c.Assert(err, jc.ErrorIsNil)
295
defer storage.Close()
297
// Ensure the GUI storage is empty.
298
allMeta, err := storage.AllMetadata()
299
c.Assert(err, jc.ErrorIsNil)
300
c.Assert(allMeta, gc.HasLen, 0)
302
hash = setup(c, baseDir, storage)
305
// Send a request to the test path.
306
if pathAndquery == "" {
309
return s.sendRequest(c, httpRequestParams{
310
url: s.guiURL(c, hash, pathAndquery),
314
for i, test := range guiHandlerTests {
315
c.Logf("\n%d: %s", i, test.about)
317
// Reset the db so that the GUI storage is empty in each test.
320
// Perform the request.
321
resp := sendRequest(test.setup, test.pathAndquery)
323
// Check the response.
324
if test.expectedStatus == 0 {
325
test.expectedStatus = http.StatusOK
327
if test.expectedError != "" {
328
test.expectedContentType = "application/json"
330
body := assertResponse(c, resp, test.expectedStatus, test.expectedContentType)
331
if test.expectedError == "" {
332
c.Assert(string(body), gc.Equals, test.expectedBody)
334
var jsonResp params.ErrorResult
335
err := json.Unmarshal(body, &jsonResp)
336
c.Assert(err, jc.ErrorIsNil, gc.Commentf("body: %s", body))
337
c.Assert(jsonResp.Error.Message, gc.Matches, test.expectedError)
342
func (s *guiSuite) TestGUIIndex(c *gc.C) {
343
storage, err := s.State.GUIStorage()
344
c.Assert(err, jc.ErrorIsNil)
345
defer storage.Close()
347
// Create a Juju GUI archive and save it into the storage.
352
comboURL: {{.comboURL}}
353
configURL: {{.configURL}}
355
spriteContent: {{.spriteContent}}
358
hash := setupGUIArchive(c, storage, "2.0.0", map[string]string{
359
guiIndexPath: indexContent,
360
apiserver.SpritePath: "sprite content",
362
expectedIndexContent := fmt.Sprintf(`
366
comboURL: /gui/%[1]s/%[2]s/combo
367
configURL: /gui/%[1]s/%[2]s/config.js
369
spriteContent: sprite content
371
</html>`, s.modelUUID, hash)
372
// Make a request for the Juju GUI index.
373
resp := s.sendRequest(c, httpRequestParams{
374
url: s.guiURL(c, "", "/"),
376
body := assertResponse(c, resp, http.StatusOK, "text/html; charset=utf-8")
377
c.Assert(string(body), gc.Equals, expectedIndexContent)
379
// Non-handled paths are served by the index handler.
380
resp = s.sendRequest(c, httpRequestParams{
381
url: s.guiURL(c, "", "/no-such-path/"),
383
body = assertResponse(c, resp, http.StatusOK, "text/html; charset=utf-8")
384
c.Assert(string(body), gc.Equals, expectedIndexContent)
387
func (s *guiSuite) TestGUIConfig(c *gc.C) {
388
storage, err := s.State.GUIStorage()
389
c.Assert(err, jc.ErrorIsNil)
390
defer storage.Close()
392
// Create a Juju GUI archive and save it into the storage.
395
// This is just an example and does not reflect the real Juju GUI config.
398
socket: '{{.socket}}',
400
version: '{{.version}}'
402
hash := setupGUIArchive(c, storage, "2.0.0", map[string]string{
403
guiConfigPath: configContent,
405
expectedConfigContent := fmt.Sprintf(`
407
// This is just an example and does not reflect the real Juju GUI config.
410
socket: '/model/$uuid/api',
413
};`, s.modelUUID, s.baseURL(c).Host, s.modelUUID, version.Current)
415
// Make a request for the Juju GUI config.
416
resp := s.sendRequest(c, httpRequestParams{
417
url: s.guiURL(c, hash, "/config.js"),
419
body := assertResponse(c, resp, http.StatusOK, apiserver.JSMimeType)
420
c.Assert(string(body), gc.Equals, expectedConfigContent)
423
func (s *guiSuite) TestGUIDirectory(c *gc.C) {
424
storage, err := s.State.GUIStorage()
425
c.Assert(err, jc.ErrorIsNil)
426
defer storage.Close()
428
// Create a Juju GUI archive and save it into the storage.
429
indexContent := "<!DOCTYPE html><html><body>Exterminate!</body></html>"
430
hash := setupGUIArchive(c, storage, "2.0.0", map[string]string{
431
guiIndexPath: indexContent,
432
apiserver.SpritePath: "",
435
// Initially the GUI directory on the server is empty.
436
baseDir := agenttools.SharedGUIDir(s.DataDir())
437
c.Assert(baseDir, jc.DoesNotExist)
439
// Make a request for the Juju GUI.
440
resp := s.sendRequest(c, httpRequestParams{
441
url: s.guiURL(c, "", "/"),
443
body := assertResponse(c, resp, http.StatusOK, "text/html; charset=utf-8")
444
c.Assert(string(body), gc.Equals, indexContent)
446
// Now the GUI is stored on disk, in a directory corresponding to its
447
// archive SHA256 hash.
448
indexPath := filepath.Join(baseDir, hash, guiIndexPath)
449
c.Assert(indexPath, jc.IsNonEmptyFile)
450
b, err := ioutil.ReadFile(indexPath)
451
c.Assert(err, jc.ErrorIsNil)
452
c.Assert(string(b), gc.Equals, indexContent)
455
// makeGUIArchive creates a Juju GUI tar.bz2 archive with the given files.
456
// The files parameter maps file names (relative to the internal "jujugui"
457
// directory) to their contents. This function returns a reader for the
458
// archive, its hash and size.
459
func makeGUIArchive(c *gc.C, vers string, files map[string]string) (r io.Reader, hash string, size int64) {
460
if runtime.GOOS == "windows" {
461
// Skipping the tests on Windows is not a problem as the Juju GUI is
462
// only served from Linux machines.
463
c.Skip("bzip2 command not available")
465
cmd := exec.Command("bzip2", "--compress", "--stdout", "--fast")
467
stdin, err := cmd.StdinPipe()
468
c.Assert(err, jc.ErrorIsNil)
469
stdout, err := cmd.StdoutPipe()
470
c.Assert(err, jc.ErrorIsNil)
473
c.Assert(err, jc.ErrorIsNil)
475
tw := tar.NewWriter(stdin)
476
baseDir := filepath.Join("jujugui-"+vers, "jujugui")
477
err = tw.WriteHeader(&tar.Header{
480
Typeflag: tar.TypeDir,
482
c.Assert(err, jc.ErrorIsNil)
483
for path, content := range files {
484
name := filepath.Join(baseDir, path)
485
err = tw.WriteHeader(&tar.Header{
486
Name: filepath.Dir(name),
488
Typeflag: tar.TypeDir,
490
c.Assert(err, jc.ErrorIsNil)
491
err = tw.WriteHeader(&tar.Header{
494
Size: int64(len(content)),
496
c.Assert(err, jc.ErrorIsNil)
497
_, err = io.WriteString(tw, content)
498
c.Assert(err, jc.ErrorIsNil)
501
c.Assert(err, jc.ErrorIsNil)
503
c.Assert(err, jc.ErrorIsNil)
506
r = io.TeeReader(stdout, h)
507
b, err := ioutil.ReadAll(r)
508
c.Assert(err, jc.ErrorIsNil)
511
c.Assert(err, jc.ErrorIsNil)
513
return bytes.NewReader(b), fmt.Sprintf("%x", h.Sum(nil)), int64(len(b))
516
// setupGUIArchive creates a Juju GUI tar.bz2 archive with the given version
517
// and files and saves it into the given storage. The Juju GUI archive SHA256
519
func setupGUIArchive(c *gc.C, storage binarystorage.Storage, vers string, files map[string]string) (hash string) {
520
r, hash, size := makeGUIArchive(c, vers, files)
521
err := storage.Add(r, binarystorage.Metadata{
526
c.Assert(err, jc.ErrorIsNil)