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 }