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  }