From a4802350e626e9aaf5200106e7afd5fb492c9cb9 Mon Sep 17 00:00:00 2001 From: Kristin Laemmert Date: Thu, 7 Nov 2024 08:29:57 -0500 Subject: [PATCH] feat(api domain): extend options for Get requests (#766) * feat(domains): api domain options API Domain options now include: parameters (alternative to supplying in the URL) Headers Timeout Proxy configuration --- src/pkg/common/schemas/validation.json | 29 ++- src/pkg/common/types.go | 6 +- src/pkg/domains/api/api.go | 74 ++++---- src/pkg/domains/api/http_request.go | 73 ++++++++ src/pkg/domains/api/spec.go | 90 +++++++++ src/pkg/domains/api/spec_test.go | 177 ++++++++++++++++++ src/pkg/domains/api/types.go | 50 +++-- src/pkg/domains/api/types_test.go | 113 ++++++++--- src/test/e2e/api_validation_test.go | 84 ++++++++- .../component-definition.yaml.tmpl | 67 +++++++ 10 files changed, 669 insertions(+), 94 deletions(-) create mode 100644 src/pkg/domains/api/http_request.go create mode 100644 src/pkg/domains/api/spec.go create mode 100644 src/pkg/domains/api/spec_test.go create mode 100644 src/test/e2e/scenarios/api-validations/component-definition.yaml.tmpl diff --git a/src/pkg/common/schemas/validation.json b/src/pkg/common/schemas/validation.json index efabd43d..3564c2f3 100644 --- a/src/pkg/common/schemas/validation.json +++ b/src/pkg/common/schemas/validation.json @@ -362,9 +362,36 @@ "url": { "type": "string", "format": "uri" + }, + "parameters": { + "type": "object", + "additionalProperties": { "type": "string"} + }, + "options": { + "$ref": "#/definitions/api-options" } } - } + }, + "required": ["name", "url"] + }, + "options": { + "$ref": "#/definitions/api-options" + } + }, + "required": ["requests"] + }, + "api-options": { + "type": "object", + "properties": { + "timeout": { + "type": "string" + }, + "proxy": { + "type": "string" + }, + "headers": { + "type": "object", + "additionalProperties": { "type": "string"} } } }, diff --git a/src/pkg/common/types.go b/src/pkg/common/types.go index e401eb42..e5db7ae0 100644 --- a/src/pkg/common/types.go +++ b/src/pkg/common/types.go @@ -10,6 +10,8 @@ import ( "github.com/defenseunicorns/go-oscal/src/pkg/uuid" oscalValidation "github.com/defenseunicorns/go-oscal/src/pkg/validation" oscalTypes_1_1_2 "github.com/defenseunicorns/go-oscal/src/types/oscal-1-1-2" + "sigs.k8s.io/yaml" + "github.com/defenseunicorns/lula/src/config" "github.com/defenseunicorns/lula/src/pkg/common/schemas" "github.com/defenseunicorns/lula/src/pkg/domains/api" @@ -18,7 +20,6 @@ import ( "github.com/defenseunicorns/lula/src/pkg/providers/kyverno" "github.com/defenseunicorns/lula/src/pkg/providers/opa" "github.com/defenseunicorns/lula/src/types" - "sigs.k8s.io/yaml" ) // Define base errors for validations @@ -153,7 +154,8 @@ func (validation *Validation) ToLulaValidation(uuid string) (lulaValidation type domain, err := GetDomain(validation.Domain) if domain == nil { return lulaValidation, fmt.Errorf("%w: %s", ErrInvalidDomain, validation.Domain.Type) - } else if err != nil { + } + if err != nil { return lulaValidation, fmt.Errorf("%w: %v", ErrInvalidDomain, err) } lulaValidation.Domain = &domain diff --git a/src/pkg/domains/api/api.go b/src/pkg/domains/api/api.go index 14dce2b1..a9926cfa 100644 --- a/src/pkg/domains/api/api.go +++ b/src/pkg/domains/api/api.go @@ -1,57 +1,49 @@ package api import ( - "bytes" - "encoding/json" + "context" "fmt" - "io" - "net/http" "github.com/defenseunicorns/lula/src/types" ) -func MakeRequests(Requests []Request) (types.DomainResources, error) { - collection := make(map[string]interface{}, 0) - - for _, request := range Requests { - transport := &http.Transport{} - client := &http.Client{Transport: transport} - - resp, err := client.Get(request.URL) - if err != nil { - return nil, err - } - if resp.StatusCode != 200 { - return nil, - fmt.Errorf("expected status code 200 but got %d", resp.StatusCode) - } - - defer resp.Body.Close() - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err +func (a ApiDomain) makeRequests(ctx context.Context) (types.DomainResources, error) { + select { + case <-ctx.Done(): + return nil, fmt.Errorf("canceled: %s", ctx.Err()) + default: + collection := make(map[string]interface{}, 0) + + // defaultOpts apply to all requests, but may be overridden by adding an + // options block to an individual request. + var defaultOpts *ApiOpts + if a.Spec.Options == nil { + // This isn't likely to be nil in real usage, since CreateApiDomain + // parses and mutates specs. + defaultOpts = new(ApiOpts) + defaultOpts.timeout = &defaultTimeout + } else { + defaultOpts = a.Spec.Options } - contentType := resp.Header.Get("Content-Type") - if contentType == "application/json" { - - var prettyBuff bytes.Buffer - err := json.Indent(&prettyBuff, body, "", " ") - if err != nil { - return nil, err + // configure the default HTTP client using any top-level Options. Individual + // requests with overrides will get bespoke clients. + defaultClient := clientFromOpts(defaultOpts) + + for _, request := range a.Spec.Requests { + var responseType interface{} + var err error + if request.Options == nil { + responseType, err = doHTTPReq(ctx, defaultClient, *request.reqURL, defaultOpts.Headers, request.reqParameters, responseType) + } else { + client := clientFromOpts(request.Options) + responseType, err = doHTTPReq(ctx, client, *request.reqURL, request.Options.Headers, request.reqParameters, responseType) } - prettyJson := prettyBuff.String() - - var tempData interface{} - err = json.Unmarshal([]byte(prettyJson), &tempData) if err != nil { - return nil, err + return collection, err } - collection[request.Name] = tempData - - } else { - return nil, fmt.Errorf("content type %s is not supported", contentType) + collection[request.Name] = responseType } + return collection, nil } - return collection, nil } diff --git a/src/pkg/domains/api/http_request.go b/src/pkg/domains/api/http_request.go new file mode 100644 index 00000000..0a5c6d87 --- /dev/null +++ b/src/pkg/domains/api/http_request.go @@ -0,0 +1,73 @@ +package api + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" +) + +func doHTTPReq[T any](ctx context.Context, client http.Client, url url.URL, headers map[string]string, queryParameters url.Values, respTy T) (T, error) { + // append any query parameters. + q := url.Query() + + for k, v := range queryParameters { + // using Add instead of set incase the input URL already had a query encoded + q.Add(k, strings.Join(v, ",")) + } + // set the query to the encoded parameters + url.RawQuery = q.Encode() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url.String(), nil) + if err != nil { + return respTy, err + } + // add each header to the request + for k, v := range headers { + req.Header.Set(k, v) + } + + // do the thing + res, err := client.Do(req) + if err != nil { + return respTy, err + } + + if res == nil { + return respTy, fmt.Errorf("error: calling %s returned empty response", url.Redacted()) + } + defer res.Body.Close() + + responseData, err := io.ReadAll(res.Body) + if err != nil { + return respTy, err + } + + if res.StatusCode != http.StatusOK { + return respTy, fmt.Errorf("expected status code 200 but got %d", res.StatusCode) + } + + var responseObject T + err = json.Unmarshal(responseData, &responseObject) + + if err != nil { + return respTy, fmt.Errorf("error unmarshaling response: %w", err) + } + + return responseObject, nil +} + +func clientFromOpts(opts *ApiOpts) http.Client { + transport := &http.Transport{} + if opts.proxyURL != nil { + transport.Proxy = http.ProxyURL(opts.proxyURL) + } + c := http.Client{Transport: transport} + if opts.timeout != nil { + c.Timeout = *opts.timeout + } + return c +} diff --git a/src/pkg/domains/api/spec.go b/src/pkg/domains/api/spec.go new file mode 100644 index 00000000..7896aead --- /dev/null +++ b/src/pkg/domains/api/spec.go @@ -0,0 +1,90 @@ +package api + +import ( + "errors" + "fmt" + "net/url" + "time" +) + +var defaultTimeout = 30 * time.Second + +// validateAndMutateSpec validates the spec values and applies any defaults or +// other mutations or normalizations necessary. The original values are not modified. +// validateAndMutateSpec will validate the entire object and may return multiple +// errors. +func validateAndMutateSpec(spec *ApiSpec) (errs error) { + if spec == nil { + return errors.New("spec is required") + } + if len(spec.Requests) == 0 { + errs = errors.Join(errs, errors.New("some requests must be specified")) + } + + if spec.Options == nil { + spec.Options = &ApiOpts{} + } + err := validateAndMutateOptions(spec.Options) + if err != nil { + errs = errors.Join(errs, err) + } + + for i := range spec.Requests { + if spec.Requests[i].Name == "" { + errs = errors.Join(errs, errors.New("request name cannot be empty")) + } + if spec.Requests[i].URL == "" { + errs = errors.Join(errs, errors.New("request url cannot be empty")) + } + reqUrl, err := url.Parse(spec.Requests[i].URL) + if err != nil { + errs = errors.Join(errs, errors.New("invalid request url")) + } else { + spec.Requests[i].reqURL = reqUrl + } + if spec.Requests[i].Params != nil { + queryParameters := url.Values{} + for k, v := range spec.Requests[i].Params { + queryParameters.Add(k, v) + } + spec.Requests[i].reqParameters = queryParameters + } + if spec.Requests[i].Options != nil { + err = validateAndMutateOptions(spec.Requests[i].Options) + if err != nil { + errs = errors.Join(errs, err) + } + } + } + + return errs +} + +func validateAndMutateOptions(opts *ApiOpts) (errs error) { + if opts == nil { + return errors.New("opts cannot be nil") + } + + if opts.Timeout != "" { + duration, err := time.ParseDuration(opts.Timeout) + if err != nil { + errs = errors.Join(errs, fmt.Errorf("invalid wait timeout string: %s", opts.Timeout)) + } + opts.timeout = &duration + } + + if opts.timeout == nil { + opts.timeout = &defaultTimeout + } + + if opts.Proxy != "" { + proxyURL, err := url.Parse(opts.Proxy) + if err != nil { + // not logging the input URL in case it has embedded credentials + errs = errors.Join(errs, errors.New("invalid proxy string")) + } + opts.proxyURL = proxyURL + } + + return errs +} diff --git a/src/pkg/domains/api/spec_test.go b/src/pkg/domains/api/spec_test.go new file mode 100644 index 00000000..b05c6ac6 --- /dev/null +++ b/src/pkg/domains/api/spec_test.go @@ -0,0 +1,177 @@ +package api + +import ( + "net/url" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/stretchr/testify/require" +) + +func TestValidateAndMutateOptions(t *testing.T) { + var testTimeout = 10 * time.Second + var zeroTimeout = 0 * time.Second + + tests := map[string]struct { + input, want *ApiOpts + expectErrs int + }{ + "error: nil input": { + nil, + nil, + 1, + }, + "empty input, defaults are populated": { + &ApiOpts{}, + &ApiOpts{ + timeout: &defaultTimeout, + }, + 0, + }, + "valid input, internal fields populated": { + &ApiOpts{ + Timeout: "10s", + Proxy: "https://my.proxy", + Headers: map[string]string{"cache": "no-cache"}, + }, + &ApiOpts{ + Timeout: "10s", + Proxy: "https://my.proxy", + Headers: map[string]string{"cache": "no-cache"}, + timeout: &testTimeout, + proxyURL: &url.URL{ + Scheme: "https", + Host: "my.proxy", + }, + }, + 0, + }, + "several errors": { + &ApiOpts{ + Proxy: "close//butinvalid\n\r", + Timeout: "more nonsense", + }, + &ApiOpts{ + Proxy: "close//butinvalid\n\r", + Timeout: "more nonsense", + timeout: &zeroTimeout, // there was an error, so this is set to zero value + }, + 2, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + err := validateAndMutateOptions(test.input) + if err != nil { + if test.expectErrs == 0 { + t.Fatalf("expected success, got error(s) %s", err) + } else if uw, ok := err.(interface{ Unwrap() []error }); ok { + errs := uw.Unwrap() + require.Equal(t, test.expectErrs, len(errs)) + } else { + if test.expectErrs != 1 { + t.Fatalf("expected multiple errors, got one: %s", err) + } + } + } else { + if test.expectErrs != 0 { + t.Fatal("expected error(s), got success") + } + } + + if diff := cmp.Diff(test.want, test.input, cmp.AllowUnexported(ApiOpts{})); diff != "" { + t.Fatalf("wrong result(-got +want):\n%s\n", diff) + } + }) + } +} + +func TestValidateAndMutateSpec(t *testing.T) { + healthcheckUrl, err := url.Parse("http://example.com/health") + require.NoError(t, err) + testParams := url.Values{} + testParams.Add("key", "value") + + tests := map[string]struct { + input, want *ApiSpec + expectErrs int + }{ + "error: nil input": { + nil, nil, 1, + }, + "error: empty input, nil options": { + &ApiSpec{}, + &ApiSpec{ + Options: &ApiOpts{timeout: &defaultTimeout}, + }, + 1, + }, + "success": { + &ApiSpec{ + Requests: []Request{ + { + Name: "healthcheck", + URL: "http://example.com/health", + Params: map[string]string{ + "key": "value", + }, + Options: &ApiOpts{ + Headers: map[string]string{ + "cache-control": "no-hit", + }, + }, + }, + }, + }, + &ApiSpec{ + Requests: []Request{ + { + Name: "healthcheck", + URL: "http://example.com/health", + Params: map[string]string{ + "key": "value", + }, + reqURL: healthcheckUrl, + reqParameters: testParams, + Options: &ApiOpts{ + Headers: map[string]string{ + "cache-control": "no-hit", + }, + timeout: &defaultTimeout, + }, + }, + }, + Options: &ApiOpts{timeout: &defaultTimeout}, + }, + 0, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + err := validateAndMutateSpec(test.input) + if err != nil { + if test.expectErrs == 0 { + t.Fatalf("expected success, got error(s) %s", err) + } else if uw, ok := err.(interface{ Unwrap() []error }); ok { + errs := uw.Unwrap() + require.Equal(t, test.expectErrs, len(errs)) + } else { + if test.expectErrs != 1 { + t.Fatalf("expected multiple errors, got one: %s", err) + } + } + } else { + if test.expectErrs != 0 { + t.Fatal("expected error(s), got success") + } + } + + if diff := cmp.Diff(test.want, test.input, cmp.AllowUnexported(ApiSpec{}, ApiOpts{}, Request{})); diff != "" { + t.Fatalf("wrong result(-got +want):\n%s\n", diff) + } + }) + } +} diff --git a/src/pkg/domains/api/types.go b/src/pkg/domains/api/types.go index bc67c47d..0b86a285 100644 --- a/src/pkg/domains/api/types.go +++ b/src/pkg/domains/api/types.go @@ -2,7 +2,8 @@ package api import ( "context" - "fmt" + "net/url" + "time" "github.com/defenseunicorns/lula/src/types" ) @@ -15,20 +16,9 @@ type ApiDomain struct { func CreateApiDomain(spec *ApiSpec) (types.Domain, error) { // Check validity of spec - if spec == nil { - return nil, fmt.Errorf("spec is nil") - } - - if len(spec.Requests) == 0 { - return nil, fmt.Errorf("some requests must be specified") - } - for _, request := range spec.Requests { - if request.Name == "" { - return nil, fmt.Errorf("request name cannot be empty") - } - if request.URL == "" { - return nil, fmt.Errorf("request url cannot be empty") - } + err := validateAndMutateSpec(spec) + if err != nil { + return nil, err } return ApiDomain{ @@ -36,8 +26,8 @@ func CreateApiDomain(spec *ApiSpec) (types.Domain, error) { }, nil } -func (a ApiDomain) GetResources(_ context.Context) (types.DomainResources, error) { - return MakeRequests(a.Spec.Requests) +func (a ApiDomain) GetResources(ctx context.Context) (types.DomainResources, error) { + return a.makeRequests(ctx) } func (a ApiDomain) IsExecutable() bool { @@ -48,10 +38,32 @@ func (a ApiDomain) IsExecutable() bool { // ApiSpec contains a list of API requests type ApiSpec struct { Requests []Request `mapstructure:"requests" json:"requests" yaml:"requests"` + // Opts will be applied to all requests, except those which have their own + // specified ApiOpts + Options *ApiOpts `mapstructure:"options" json:"options,omitempty" yaml:"options,omitempty"` } // Request is a single API request type Request struct { - Name string `json:"name" yaml:"name"` - URL string `json:"url" yaml:"url"` + Name string `json:"name" yaml:"name"` + URL string `json:"url" yaml:"url"` + Params map[string]string `json:"parameters,omitempty" yaml:"parameters,omitempty"` + // ApiOpts specific to this request. If ApiOpts is present, values in the + // ApiSpec-level Options are ignored for this request. + Options *ApiOpts `json:"options,omitempty" yaml:"options,omitempty"` + + // internally-managed options + reqURL *url.URL + reqParameters url.Values +} + +type ApiOpts struct { + // Timeout in seconds + Timeout string `json:"timeout,omitempty" yaml:"timeout,omitempty"` + Proxy string `json:"proxy,omitempty" yaml:"proxy,omitempty"` + Headers map[string]string `json:"headers,omitempty" yaml:"headers,omitempty"` + + // internally-managed options + timeout *time.Duration + proxyURL *url.URL } diff --git a/src/pkg/domains/api/types_test.go b/src/pkg/domains/api/types_test.go index 64fa9562..03c1465a 100644 --- a/src/pkg/domains/api/types_test.go +++ b/src/pkg/domains/api/types_test.go @@ -1,35 +1,37 @@ -package api_test +package api import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" "testing" - api "github.com/defenseunicorns/lula/src/pkg/domains/api" + "github.com/stretchr/testify/require" + + "github.com/defenseunicorns/lula/src/types" ) func TestCreateApiDomain(t *testing.T) { t.Parallel() - tests := []struct { - name string - spec *api.ApiSpec + tests := map[string]struct { + spec *ApiSpec expectedErr bool }{ - { - name: "nil spec", + "nil spec": { spec: nil, expectedErr: true, }, - { - name: "empty requests", - spec: &api.ApiSpec{ - Requests: []api.Request{}, + "empty requests": { + spec: &ApiSpec{ + Requests: []Request{}, }, expectedErr: true, }, - { - name: "invalid request - no name", - spec: &api.ApiSpec{ - Requests: []api.Request{ + "invalid request - no name": { + spec: &ApiSpec{ + Requests: []Request{ { URL: "test", }, @@ -37,10 +39,9 @@ func TestCreateApiDomain(t *testing.T) { }, expectedErr: true, }, - { - name: "invalid request - no url", - spec: &api.ApiSpec{ - Requests: []api.Request{ + "invalid request - no url": { + spec: &ApiSpec{ + Requests: []Request{ { Name: "test", }, @@ -48,10 +49,9 @@ func TestCreateApiDomain(t *testing.T) { }, expectedErr: true, }, - { - name: "valid request", - spec: &api.ApiSpec{ - Requests: []api.Request{ + "valid request": { + spec: &ApiSpec{ + Requests: []Request{ { Name: "test", URL: "test", @@ -62,13 +62,70 @@ func TestCreateApiDomain(t *testing.T) { }, } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - _, err := api.CreateApiDomain(tt.spec) + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + _, err := CreateApiDomain(tt.spec) if (err != nil) != tt.expectedErr { - t.Errorf("CreateApiDomain() error = %v, wantErr %v", err, tt.expectedErr) - return + t.Fatalf("CreateApiDomain() error = %v, wantErr %v", err, tt.expectedErr) } }) } } + +func TestApiDomain(t *testing.T) { + respBytes := []byte(`{"status":"ok"}`) + // unmarshal the response + var resp map[string]interface{} + err := json.Unmarshal(respBytes, &resp) + require.NoError(t, err) + + name := "test" + svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Accept") != "application/json" { + w.WriteHeader(http.StatusBadRequest) + } else if _, ok := r.URL.Query()["label"]; !ok { + w.WriteHeader(http.StatusBadRequest) + } else { + w.WriteHeader(http.StatusOK) + _, err := w.Write(respBytes) + require.NoError(t, err) + } + })) + defer svr.Close() + + t.Run("pass", func(t *testing.T) { + api, err := CreateApiDomain(&ApiSpec{ + Requests: []Request{ + { + Name: name, + URL: svr.URL, + Params: map[string]string{"label": "test"}, + Options: &ApiOpts{ + Headers: map[string]string{"Accept": "application/json"}, + }, + }, + }, + }) + + require.NoError(t, err) + drs, err := api.GetResources(context.Background()) + require.NoError(t, err) + require.Equal(t, drs, types.DomainResources{name: resp}) + }) + + t.Run("fail", func(t *testing.T) { + api, err := CreateApiDomain(&ApiSpec{ + Requests: []Request{ + { + Name: name, + URL: svr.URL, + }, + }, + }) + + require.NoError(t, err) // the spec is correct + drs, err := api.GetResources(context.Background()) + require.Error(t, err) + require.Empty(t, drs) + }) +} diff --git a/src/test/e2e/api_validation_test.go b/src/test/e2e/api_validation_test.go index 84e3a383..0fc0d547 100644 --- a/src/test/e2e/api_validation_test.go +++ b/src/test/e2e/api_validation_test.go @@ -2,17 +2,25 @@ package test import ( "context" + "encoding/json" + "net/http" + "net/http/httptest" "testing" "time" - "github.com/defenseunicorns/lula/src/pkg/common/validation" - "github.com/defenseunicorns/lula/src/pkg/message" - "github.com/defenseunicorns/lula/src/test/util" + "github.com/stretchr/testify/require" corev1 "k8s.io/api/core/v1" "sigs.k8s.io/e2e-framework/klient/wait" "sigs.k8s.io/e2e-framework/klient/wait/conditions" "sigs.k8s.io/e2e-framework/pkg/envconf" "sigs.k8s.io/e2e-framework/pkg/features" + + "github.com/defenseunicorns/lula/src/cmd/dev" + "github.com/defenseunicorns/lula/src/internal/template" + "github.com/defenseunicorns/lula/src/pkg/common/composition" + "github.com/defenseunicorns/lula/src/pkg/common/validation" + "github.com/defenseunicorns/lula/src/pkg/message" + "github.com/defenseunicorns/lula/src/test/util" ) func TestApiValidation(t *testing.T) { @@ -203,3 +211,73 @@ func TestApiValidation(t *testing.T) { testEnv.Test(t, featureTrueValidation, featureFalseValidation) } + +// TestApiValidation_templated uses a URL parameter to control the return response from the API. +func TestApiValidation_templated(t *testing.T) { + message.NoProgress = true + dev.RunInteractively = false + svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + wantResp := r.URL.Query().Get("response") + require.NotEmpty(t, wantResp) + passRsp := false + if wantResp == "true" { + passRsp = true + } + resp := struct { + Pass bool `json:"pass"` + }{ + passRsp, + } + err := json.NewEncoder(w).Encode(resp) + require.NoError(t, err) + })) + defer svr.Close() + + tmpl := "scenarios/api-validations/component-definition.yaml.tmpl" + + // since it's just the two tests I'm using the name to check the assessment result. + tests := map[string]struct { + response string + }{ + "satisfied": {"true"}, + "not-satisfied": {"false"}, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + + composer, err := composition.New( + composition.WithModelFromLocalPath(tmpl), + composition.WithRenderSettings("all", true), + composition.WithTemplateRenderer("all", nil, []template.VariableConfig{ + { + Key: "reqUrl", + Default: svr.URL, + }, + { + Key: "response", + Default: test.response, + }, + }, []string{}), + ) + require.NoError(t, err) + + validator, err := validation.New(validation.WithComposition(composer, tmpl)) + require.NoError(t, err) + + assessment, err := validator.ValidateOnPath(context.Background(), tmpl, "") + require.NoError(t, err) + require.GreaterOrEqual(t, len(assessment.Results), 1) + + result := assessment.Results[0] + require.NotNil(t, result.Findings) + for _, finding := range *result.Findings { + state := finding.Target.Status.State + if state != name { + t.Fatalf("State should be %s, but got :%s", name, state) + } + } + }) + } +} diff --git a/src/test/e2e/scenarios/api-validations/component-definition.yaml.tmpl b/src/test/e2e/scenarios/api-validations/component-definition.yaml.tmpl new file mode 100644 index 00000000..208851cb --- /dev/null +++ b/src/test/e2e/scenarios/api-validations/component-definition.yaml.tmpl @@ -0,0 +1,67 @@ +component-definition: + uuid: E6A291A4-2BC8-43A0-B4B2-FD67CAAE1F8F + metadata: + title: OSCAL Demo Tool + last-modified: "2022-09-13T12:00:00Z" + version: "20220913" + oscal-version: 1.1.1 + parties: + - uuid: C18F4A9F-A402-415B-8D13-B51739D689FF + type: organization + name: Defense Unicorns + links: + - href: https://github.com/defenseunicorns/lula + rel: website + components: + - uuid: A9D5204C-7E5B-4C43-BD49-34DF759B9F04 + type: software + title: lula + description: | + Defense Unicorns lula + purpose: Validate compliance controls + responsible-roles: + - role-id: provider + party-uuids: + - C18F4A9F-A402-415B-8D13-B51739D689FF + control-implementations: + - uuid: A584FEDC-8CEA-4B0C-9F07-85C2C4AE751A + source: https://github.com/defenseunicorns/lula + description: Validate generic security requirements + implemented-requirements: + - uuid: 2851DD23-03D7-4245-B939-25F11F635359 + control-id: ID-1 + description: >- + NOT Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, + quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum + dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. + links: + - href: "#C30E849E-C262-42DF-8C84-EA1B62A6AD90" + rel: lula + back-matter: + resources: + - uuid: C30E849E-C262-42DF-8C84-EA1B62A6AD90 + description: >- + metadata: + name: test pass + uuid: 88AB3470-B96B-4D7C-BC36-02BF9563C46C + domain: + type: api + api-spec: + options: + timeout: 15s + headers: + x-special-header: "lula" + requests: + - name: healthcheck + url: {{ .var.reqUrl }} + parameters: + response: {{ .var.response }} + provider: + type: opa + opa-spec: + rego: | + package validate + + validate { + input.healthcheck.pass == true + }