github.com/freiheit-com/kuberpult@v1.24.2-0.20240328135542-315d5630abe6/tests/integration-tests/release_test.go (about) 1 /*This file is part of kuberpult. 2 3 Kuberpult is free software: you can redistribute it and/or modify 4 it under the terms of the Expat(MIT) License as published by 5 the Free Software Foundation. 6 7 Kuberpult is distributed in the hope that it will be useful, 8 but WITHOUT ANY WARRANTY; without even the implied warranty of 9 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 10 MIT License for more details. 11 12 You should have received a copy of the MIT License 13 along with kuberpult. If not, see <https://directory.fsf.org/wiki/License:Expat>. 14 15 Copyright 2023 freiheit.com*/ 16 17 package integration_tests 18 19 import ( 20 "bytes" 21 "encoding/json" 22 "fmt" 23 "io" 24 "mime/multipart" 25 "net/http" 26 "os/exec" 27 "strings" 28 "testing" 29 30 "github.com/freiheit-com/kuberpult/pkg/ptr" 31 "github.com/google/go-cmp/cmp" 32 "github.com/google/go-cmp/cmp/cmpopts" 33 ) 34 35 const ( 36 devEnv = "dev" 37 stageEnv = "staging" 38 frontendPort = "8081" 39 ) 40 41 // Used to compare two error message strings, needed because errors.Is(fmt.Errorf(text),fmt.Errorf(text)) == false 42 type errMatcher struct { 43 msg string 44 } 45 46 func (e errMatcher) Error() string { 47 return e.msg 48 } 49 50 func (e errMatcher) Is(err error) bool { 51 return e.Error() == err.Error() 52 } 53 54 func postWithForm(client *http.Client, url string, values map[string]io.Reader, files map[string]io.Reader) (*http.Response, error) { 55 // Prepare a form that you will submit to that URL. 56 var b bytes.Buffer 57 var err error 58 multipartWriter := multipart.NewWriter(&b) 59 for key, r := range values { 60 var fw io.Writer 61 if x, ok := r.(io.Closer); ok { 62 defer x.Close() 63 } 64 if fw, err = multipartWriter.CreateFormField(key); err != nil { 65 return nil, err 66 } 67 if _, err = io.Copy(fw, r); err != nil { 68 return nil, err 69 } 70 } 71 for key, r := range files { 72 var fw io.Writer 73 if x, ok := r.(io.Closer); ok { 74 defer x.Close() 75 } 76 // Add a file 77 if fw, err = multipartWriter.CreateFormFile(key, key); err != nil { 78 return nil, err 79 } 80 if _, err = io.Copy(fw, r); err != nil { 81 return nil, err 82 } 83 84 } 85 // Don't forget to close the multipart writer. 86 // If you don't close it, your request will be missing the terminating boundary. 87 err = multipartWriter.Close() 88 if err != nil { 89 return nil, err 90 } 91 92 // Now that you have a form, you can submit it to your handler. 93 req, err := http.NewRequest("POST", url, &b) 94 if err != nil { 95 return nil, err 96 } 97 // Don't forget to set the content type, this will contain the boundary. 98 req.Header.Set("Content-Type", multipartWriter.FormDataContentType()) 99 100 // Submit the request 101 res, err := client.Do(req) 102 if err != nil { 103 return nil, err 104 } 105 return res, nil 106 } 107 108 // calls the release endpoint with files for manifests + signatures 109 func callRelease(values map[string]io.Reader, files map[string]io.Reader) (int, string, error) { 110 formResult, err := postWithForm(http.DefaultClient, "http://localhost:"+frontendPort+"/release", values, files) 111 if err != nil { 112 return 0, "", err 113 } 114 defer formResult.Body.Close() 115 resBody, err := io.ReadAll(formResult.Body) 116 return formResult.StatusCode, string(resBody), err 117 } 118 119 // calls the release endpoint with files for manifests + signatures 120 func callCreateGroupLock(t *testing.T, envGroup, lockId string, requestBody *putLockRequest) (int, string, error) { 121 var buf bytes.Buffer 122 jsonBytes, err := json.Marshal(&requestBody) 123 if err != nil { 124 return 0, "", err 125 } 126 buf.Write(jsonBytes) 127 128 url := fmt.Sprintf("http://localhost:%s/environment-groups/%s/locks/%s", frontendPort, envGroup, lockId) 129 t.Logf("GroupLock url: %s", url) 130 t.Logf("GroupLock body: %s", buf.String()) 131 req, err := http.NewRequest(http.MethodPut, url, &buf) 132 if err != nil { 133 return 0, "", err 134 } 135 req.Header.Set("Content-Type", "application/json") 136 client := &http.Client{} 137 resp, err := client.Do(req) 138 if err != nil { 139 return 0, "", err 140 } 141 defer resp.Body.Close() 142 responseBuf := new(strings.Builder) 143 _, err = io.Copy(responseBuf, resp.Body) 144 if err != nil { 145 return 0, "", err 146 } 147 148 return resp.StatusCode, responseBuf.String(), err 149 } 150 151 func CalcSignature(t *testing.T, manifest string) string { 152 cmd := exec.Command("gpg", "--keyring", "trustedkeys-kuberpult.gpg", "--local-user", "kuberpult-kind@example.com", "--detach", "--sign", "--armor") 153 cmd.Stdin = strings.NewReader(manifest) 154 theSignature, err := cmd.CombinedOutput() 155 if err != nil { 156 t.Error(err.Error()) 157 t.Errorf("output: %s", string(theSignature)) 158 t.Fail() 159 } 160 t.Logf("signature: " + string(theSignature)) 161 return string(theSignature) 162 } 163 164 func TestReleaseCalls(t *testing.T) { 165 theManifest := "I am a manifest\n- foo\nfoo" 166 167 testCases := []struct { 168 name string 169 inputApp string 170 inputManifest string 171 inputSignature string 172 inputManifestEnv string 173 inputSignatureEnv string // usually the same as inputManifestEnv 174 inputVersion *string // actually an int, but for testing purposes it may be a string 175 expectedStatusCode int 176 }{ 177 { 178 name: "Simple invocation of /release endpoint", 179 inputApp: "my-app", 180 inputManifest: theManifest, 181 inputSignature: CalcSignature(t, theManifest), 182 inputManifestEnv: devEnv, 183 inputSignatureEnv: devEnv, 184 inputVersion: nil, 185 expectedStatusCode: 201, 186 }, 187 { 188 // Note that this test is not repeatable. Once the version exists, it cannot be overridden. 189 // To repeat the test, we would have to reset the manifest repo. 190 name: "Simple invocation of /release endpoint with valid version", 191 inputApp: "my-app-" + appSuffix, 192 inputManifest: theManifest, 193 inputSignature: CalcSignature(t, theManifest), 194 inputManifestEnv: devEnv, 195 inputSignatureEnv: devEnv, 196 inputVersion: ptr.FromString("99"), 197 expectedStatusCode: 201, 198 }, 199 { 200 // this is the same test, but this time we expect 200, because the release already exists: 201 name: "Simple invocation of /release endpoint with valid version", 202 inputApp: "my-app-" + appSuffix, 203 inputManifest: theManifest, 204 inputSignature: CalcSignature(t, theManifest), 205 inputManifestEnv: devEnv, 206 inputSignatureEnv: devEnv, 207 inputVersion: ptr.FromString("99"), 208 expectedStatusCode: 200, 209 }, 210 { 211 name: "Simple invocation of /release endpoint with invalid version", 212 inputApp: "my-app", 213 inputManifest: theManifest, 214 inputSignature: CalcSignature(t, theManifest), 215 inputManifestEnv: devEnv, 216 inputSignatureEnv: devEnv, 217 inputVersion: ptr.FromString("notanumber"), 218 expectedStatusCode: 400, 219 }, 220 { 221 name: "too long app name", 222 inputApp: "my-app-is-way-too-long-dont-you-think-so-too", 223 inputManifest: theManifest, 224 inputSignature: CalcSignature(t, theManifest), 225 inputManifestEnv: devEnv, 226 inputSignatureEnv: devEnv, 227 inputVersion: nil, 228 expectedStatusCode: 400, 229 }, 230 { 231 name: "invalid signature", 232 inputApp: "my-app2", 233 inputManifest: theManifest, 234 inputSignature: "not valid!", 235 inputManifestEnv: devEnv, 236 inputSignatureEnv: devEnv, 237 inputVersion: nil, 238 expectedStatusCode: 400, 239 }, 240 { 241 name: "Valid signature, but at the wrong place", 242 inputApp: "my-app", 243 inputManifest: theManifest, 244 inputSignature: CalcSignature(t, theManifest), 245 inputManifestEnv: devEnv, 246 inputSignatureEnv: stageEnv, // !! 247 inputVersion: nil, 248 expectedStatusCode: 400, 249 }, 250 } 251 252 for _, tc := range testCases { 253 t.Run(tc.name, func(t *testing.T) { 254 255 values := map[string]io.Reader{ 256 "application": strings.NewReader(tc.inputApp), 257 } 258 if tc.inputVersion != nil { 259 values["version"] = strings.NewReader(ptr.ToString(tc.inputVersion)) 260 } 261 files := map[string]io.Reader{ 262 "manifests[" + tc.inputManifestEnv + "]": strings.NewReader(tc.inputManifest), 263 "signatures[" + tc.inputSignatureEnv + "]": strings.NewReader(tc.inputSignature), 264 } 265 266 actualStatusCode, body, err := callRelease(values, files) 267 if err != nil { 268 t.Fatalf("callRelease failed: %s", err.Error()) 269 } 270 if actualStatusCode != tc.expectedStatusCode { 271 t.Errorf("expected code %v but got %v. Body: %s", tc.expectedStatusCode, actualStatusCode, body) 272 } 273 }) 274 } 275 } 276 277 type putLockRequest struct { 278 Message string `json:"message"` 279 Signature string `json:"signature,omitempty"` 280 } 281 282 func TestGroupLock(t *testing.T) { 283 testCases := []struct { 284 name string 285 inputEnvGroup string 286 expectedStatusCode int 287 }{ 288 { 289 name: "Simple invocation of group lock endpoint", 290 inputEnvGroup: "prod", 291 expectedStatusCode: 201, 292 }, 293 } 294 295 for index, tc := range testCases { 296 t.Run(tc.name, func(t *testing.T) { 297 298 lockId := fmt.Sprintf("lockIdIntegration%d", index) 299 inputSignature := CalcSignature(t, tc.inputEnvGroup+lockId) 300 requestBody := &putLockRequest{ 301 Message: "hello world", 302 Signature: inputSignature, 303 } 304 actualStatusCode, respBody, err := callCreateGroupLock(t, tc.inputEnvGroup, lockId, requestBody) 305 if err != nil { 306 t.Fatalf("callCreateGroupLock failed: %s", err.Error()) 307 } 308 if actualStatusCode != tc.expectedStatusCode { 309 t.Errorf("expected code %v but got %v. Body: '%s'", tc.expectedStatusCode, actualStatusCode, respBody) 310 } 311 }) 312 } 313 } 314 315 func TestAppParameter(t *testing.T) { 316 testCases := []struct { 317 name string 318 inputNumberAppParam int 319 expectedStatusCode int 320 expectedError error 321 expectedBody string 322 }{ 323 { 324 name: "0 app names", 325 inputNumberAppParam: 0, 326 expectedStatusCode: 400, 327 expectedBody: "Must provide application name", 328 }, 329 { 330 name: "1 app name", 331 inputNumberAppParam: 1, 332 expectedStatusCode: 201, 333 expectedBody: "{\"Success\":{}}\n", 334 }, 335 // having multiple app names would be a bit harder to test 336 } 337 338 for _, tc := range testCases { 339 t.Run(tc.name, func(t *testing.T) { 340 341 values := map[string]io.Reader{} 342 for i := 0; i < tc.inputNumberAppParam; i++ { 343 values["application"] = strings.NewReader("app1") 344 } 345 346 files := map[string]io.Reader{} 347 files["manifests[dev]"] = strings.NewReader("manifest") 348 files["signatures[dev]"] = strings.NewReader(CalcSignature(t, "manifest")) 349 350 actualStatusCode, actualBody, err := callRelease(values, files) 351 if diff := cmp.Diff(tc.expectedError, err, cmpopts.EquateErrors()); diff != "" { 352 t.Errorf("error mismatch (-want, +got):\n%s", diff) 353 } 354 if actualStatusCode != tc.expectedStatusCode { 355 t.Errorf("expected code %v but got %v", tc.expectedStatusCode, actualStatusCode) 356 } 357 if diff := cmp.Diff(tc.expectedBody, actualBody); diff != "" { 358 t.Errorf("response body mismatch (-want, +got):\n%s", diff) 359 } 360 }) 361 } 362 } 363 364 func TestManifestParameterMissing(t *testing.T) { 365 testCases := []struct { 366 name string 367 expectedStatusCode int 368 expectedBody string 369 }{ 370 { 371 name: "missing manifest", 372 expectedStatusCode: 400, 373 expectedBody: "No manifest files provided", 374 }, 375 } 376 377 for _, tc := range testCases { 378 t.Run(tc.name, func(t *testing.T) { 379 380 values := map[string]io.Reader{} 381 values["application"] = strings.NewReader("app1") 382 383 files := map[string]io.Reader{} 384 385 actualStatusCode, actualBody, err := callRelease(values, files) 386 387 if err != nil { 388 t.Errorf("form error %s", err.Error()) 389 } 390 391 if actualStatusCode != tc.expectedStatusCode { 392 t.Errorf("expected code %v but got %v", tc.expectedStatusCode, actualStatusCode) 393 } 394 if diff := cmp.Diff(tc.expectedBody, actualBody); diff != "" { 395 t.Errorf("response body mismatch (-want, +got):\n%s", diff) 396 } 397 }) 398 } 399 } 400 401 func TestServeHttpInvalidInput(t *testing.T) { 402 tcs := []struct { 403 Name string 404 ExpectedStatus int 405 ExpectedBody string 406 FormMetaData string 407 }{{ 408 Name: "Error when no boundary provided", 409 ExpectedStatus: 400, 410 ExpectedBody: "Invalid body: no multipart boundary param in Content-Type", 411 FormMetaData: "multipart/form-data;", 412 }, { 413 Name: "Error when no content provided", 414 ExpectedStatus: 400, 415 ExpectedBody: "Invalid body: multipart: NextPart: EOF", 416 FormMetaData: "multipart/form-data;boundary=nonExistantBoundary;", 417 }} 418 419 for _, tc := range tcs { 420 tc := tc 421 t.Run(tc.Name, func(t *testing.T) { 422 t.Parallel() 423 var buf bytes.Buffer 424 body := multipart.NewWriter(&buf) 425 body.Close() 426 427 if resp, err := http.Post("http://localhost:"+frontendPort+"/release", tc.FormMetaData, &buf); err != nil { 428 t.Logf("response failure %s", err.Error()) 429 t.Fatal(err) 430 } else { 431 t.Logf("response: %v", resp.StatusCode) 432 if resp.StatusCode != tc.ExpectedStatus { 433 t.Fatalf("expected http status %d, received %d", tc.ExpectedStatus, resp.StatusCode) 434 } 435 bodyBytes, err := io.ReadAll(resp.Body) 436 if err != nil { 437 t.Fatal(err) 438 } 439 if diff := cmp.Diff(tc.ExpectedBody, string(bodyBytes)); diff != "" { 440 t.Errorf("response body mismatch (-want, +got):\n%s", diff) 441 } 442 } 443 }) 444 } 445 } 446 447 func TestServeHttpBasics(t *testing.T) { 448 noCachingHeader := "no-cache,no-store,must-revalidate,max-age=0" 449 yesCachingHeader := "max-age=604800" 450 headerMapWithoutCaching := map[string]string{ 451 "Cache-Control": noCachingHeader, 452 } 453 headerMapWithCaching := map[string]string{ 454 "Cache-Control": yesCachingHeader, 455 } 456 457 var jsPath = "" 458 var cssPath = "" 459 { 460 // find index.html to figure out what the name of the css and js files are: 461 resp, err := http.Get("http://localhost:" + frontendPort + "/") 462 if err != nil { 463 t.Logf("response failure %s", err.Error()) 464 t.Fatal(err) 465 } 466 if resp.StatusCode != 200 { 467 t.Fatalf("expected http status %d, received %d", 200, resp.StatusCode) 468 } 469 bodyBytes, err := io.ReadAll(resp.Body) 470 if err != nil { 471 t.Fatal(err) 472 } 473 bodyString := string(bodyBytes) 474 475 prefixJs := "/static/js/main." 476 afterJs1 := strings.SplitAfter(bodyString, prefixJs) 477 afterJs2 := strings.SplitAfter(afterJs1[1], ".js") 478 jsPath = prefixJs + afterJs2[0] 479 480 prefixCss := "/static/css/main." 481 afterCss1 := strings.SplitAfter(bodyString, prefixCss) 482 afterCss2 := strings.SplitAfter(afterCss1[1], ".css") 483 cssPath = prefixCss + afterCss2[0] 484 } 485 486 tcs := []struct { 487 Name string 488 Endpoint string 489 ExpectedStatus int 490 ExpectedHeaders map[string]string 491 }{ 492 { 493 Name: "Http works and returns caching headers for root", 494 Endpoint: "/", 495 ExpectedStatus: 200, 496 ExpectedHeaders: headerMapWithoutCaching, 497 }, 498 { 499 Name: "Http works and returns caching headers for /index.html", 500 Endpoint: "/index.html", 501 ExpectedStatus: 200, 502 ExpectedHeaders: headerMapWithoutCaching, 503 }, 504 { 505 Name: "Http works and returns caching headers for /ui", 506 Endpoint: "/ui", 507 ExpectedStatus: 200, 508 ExpectedHeaders: headerMapWithoutCaching, 509 }, 510 { 511 Name: "Http works and returns correct headers for js", 512 Endpoint: jsPath, 513 ExpectedStatus: 200, 514 ExpectedHeaders: headerMapWithCaching, 515 }, 516 { 517 Name: "Http works and returns correct headers for css", 518 Endpoint: cssPath, 519 ExpectedStatus: 200, 520 ExpectedHeaders: headerMapWithCaching, 521 }, 522 } 523 524 for _, tc := range tcs { 525 tc := tc 526 t.Run(tc.Name, func(t *testing.T) { 527 t.Parallel() 528 var buf bytes.Buffer 529 body := multipart.NewWriter(&buf) 530 body.Close() 531 532 if resp, err := http.Get("http://localhost:" + frontendPort + tc.Endpoint); err != nil { 533 t.Logf("response failure %s", err.Error()) 534 t.Fatal(err) 535 } else { 536 t.Logf("response: %v", resp.StatusCode) 537 if resp.StatusCode != tc.ExpectedStatus { 538 t.Fatalf("expected http status %d, received %d", tc.ExpectedStatus, resp.StatusCode) 539 } 540 541 for key := range tc.ExpectedHeaders { 542 expectedValue, _ := tc.ExpectedHeaders[key] 543 actualValue := resp.Header.Get(key) 544 if expectedValue != actualValue { 545 t.Fatalf("Http header with key %v: Expected %v but got %v", key, expectedValue, actualValue) 546 } 547 } 548 549 _, err := io.ReadAll(resp.Body) 550 if err != nil { 551 t.Fatal(err) 552 } 553 } 554 }) 555 } 556 }