From 91bdb0c6e8ba47325d46c78e1f117bd3ba040bc0 Mon Sep 17 00:00:00 2001 From: Vlastimil Zeman Date: Fri, 24 Apr 2026 16:40:36 +0100 Subject: [PATCH] feat: centralize operation inference, validate explicit vs inferred mismatch Operation inference (CREATE/UPDATE/DELETE) now works consistently regardless of whether test fixtures are in separate files or consolidated in .request.yaml. Changes: - parseObjectYAML and parseOldObjectYAML no longer hardcode the operation (CREATE/DELETE). They build a partial AdmissionRequest with resource metadata only. - New inferOrValidateOperation() runs after all files are merged: - Empty operation: inferred from object/oldObject presence - Explicit operation matching inference: accepted - Explicit operation conflicting with inference: error - Explicit operation with no objects (e.g. CONNECT): accepted - InferOperation renamed to inferOperation (unexported, only used internally by inferOrValidateOperation). - Added ErrOperationMismatch sentinel error. - README Operations section updated to reflect that inference works the same way for both split files and .request.yaml. - go fix applied (evaluator, reporter). --- README.md | 8 ++-- internal/evaluator/evaluator.go | 18 ++++----- internal/evaluator/evaluator_test.go | 16 ++++---- internal/loader/errors.go | 1 + internal/loader/request.go | 50 +++++++++++------------ internal/loader/request_test.go | 56 +++++++++++++------------- internal/loader/suite.go | 45 ++++++++++++++++----- internal/loader/suite_test.go | 60 +++++++++++++++++++++++++--- internal/reporter/reporter.go | 4 +- 9 files changed, 167 insertions(+), 91 deletions(-) diff --git a/README.md b/README.md index 9474045..285e60c 100644 --- a/README.md +++ b/README.md @@ -273,11 +273,13 @@ Any check not explicitly mocked returns "NoOpinion". ### Operations -- **CREATE** (default): Provide `.object.yaml` (or `object` in `.request.yaml`). -- **UPDATE**: Provide both `.object.yaml` (new) and `.oldObject.yaml` (old). Operation is inferred automatically. -- **DELETE**: Provide only `.oldObject.yaml`. Operation is inferred automatically. +- **CREATE** (default): Provide `object` (via `.object.yaml` or in `.request.yaml`). +- **UPDATE**: Provide both `object` and `oldObject`. Operation is inferred automatically. +- **DELETE**: Provide only `oldObject`. Operation is inferred automatically. - **CONNECT**: Set `operation: CONNECT` in `.request.yaml`. +Operation inference works the same way whether fields are in separate files or consolidated in `.request.yaml`. If you set `operation:` explicitly and it conflicts with what would be inferred from the fields present, `kat` reports an error. + ## Features - **Standard Kubernetes YAML** — no new DSL to learn diff --git a/internal/evaluator/evaluator.go b/internal/evaluator/evaluator.go index 9a98cb7..b36d9ea 100644 --- a/internal/evaluator/evaluator.go +++ b/internal/evaluator/evaluator.go @@ -353,12 +353,12 @@ func checkAuditAnnotations(expected *TestExpectation, actual *TestOutcome) *Test expectedYAML, err := yaml.Marshal(expected.AuditAnnotations) if err != nil { - expectedYAML = []byte(fmt.Sprintf("%+v", expected.AuditAnnotations)) + expectedYAML = fmt.Appendf(nil, "%+v", expected.AuditAnnotations) } actualYAML, err := yaml.Marshal(actualFiltered) if err != nil { - actualYAML = []byte(fmt.Sprintf("%+v", actualFiltered)) + actualYAML = fmt.Appendf(nil, "%+v", actualFiltered) } diff := getDiff(string(expectedYAML), string(actualYAML)) @@ -397,12 +397,12 @@ func checkMutatedObject(expected *TestExpectation, actual *TestOutcome) *TestRes // Convert to YAML for consistent diffing expectedYAML, err := yaml.Marshal(expected.Object.Object) if err != nil { - expectedYAML = []byte(fmt.Sprintf("%+v", expected.Object.Object)) + expectedYAML = fmt.Appendf(nil, "%+v", expected.Object.Object) } actualYAML, err := yaml.Marshal(actual.Object.Object) if err != nil { - actualYAML = []byte(fmt.Sprintf("%+v", actual.Object.Object)) + actualYAML = fmt.Appendf(nil, "%+v", actual.Object.Object) } // Generate a standard unified diff @@ -1089,7 +1089,7 @@ func appendPatchOperations(iter traits.Iterator, result *jsonpatch.Patch) error } func buildJSONPatchOperation(value ref.Val) (jsonpatch.Operation, error) { - patchObj, err := value.ConvertToNative(reflect.TypeOf(&mutation.JSONPatchVal{})) + patchObj, err := value.ConvertToNative(reflect.TypeFor[*mutation.JSONPatchVal]()) if err != nil { return nil, fmt.Errorf("convert patch element: %w", err) } @@ -1100,15 +1100,15 @@ func buildJSONPatchOperation(value ref.Val) (jsonpatch.Operation, error) { } resultOp := jsonpatch.Operation{} - resultOp["op"] = ptr.To(json.RawMessage(strconv.Quote(op.Op))) - resultOp["path"] = ptr.To(json.RawMessage(strconv.Quote(op.Path))) + resultOp["op"] = new(json.RawMessage(strconv.Quote(op.Op))) + resultOp["path"] = new(json.RawMessage(strconv.Quote(op.Path))) if len(op.From) > 0 { - resultOp["from"] = ptr.To(json.RawMessage(strconv.Quote(op.From))) + resultOp["from"] = new(json.RawMessage(strconv.Quote(op.From))) } if op.Val != nil { - converted, err := op.Val.ConvertToNative(reflect.TypeOf(&structpb.Value{})) + converted, err := op.Val.ConvertToNative(reflect.TypeFor[*structpb.Value]()) if err != nil { return nil, fmt.Errorf("convert patch value: %w", err) } diff --git a/internal/evaluator/evaluator_test.go b/internal/evaluator/evaluator_test.go index f26c074..7ddf20f 100644 --- a/internal/evaluator/evaluator_test.go +++ b/internal/evaluator/evaluator_test.go @@ -1544,10 +1544,10 @@ func TestEvaluator_EvaluateTest(t *testing.T) { } validPod := &unstructured.Unstructured{ - Object: map[string]interface{}{ + Object: map[string]any{ "apiVersion": "v1", "kind": "Pod", - "metadata": map[string]interface{}{ + "metadata": map[string]any{ "name": "test-pod", "namespace": "default", }, @@ -1646,13 +1646,13 @@ func TestEvaluator_EvaluateTest(t *testing.T) { Object: validPod, ExpectAllowed: true, ExpectedObject: &unstructured.Unstructured{ - Object: map[string]interface{}{ + Object: map[string]any{ "apiVersion": "v1", "kind": "Pod", - "metadata": map[string]interface{}{ + "metadata": map[string]any{ "name": "test-pod", "namespace": "default", - "labels": map[string]interface{}{ + "labels": map[string]any{ "foo": "bar", }, }, @@ -1679,13 +1679,13 @@ func TestEvaluator_EvaluateTest(t *testing.T) { Object: validPod, ExpectAllowed: true, ExpectedObject: &unstructured.Unstructured{ // Expect different label - Object: map[string]interface{}{ + Object: map[string]any{ "apiVersion": "v1", "kind": "Pod", - "metadata": map[string]interface{}{ + "metadata": map[string]any{ "name": "test-pod", "namespace": "default", - "labels": map[string]interface{}{ + "labels": map[string]any{ "foo": "baz", }, }, diff --git a/internal/loader/errors.go b/internal/loader/errors.go index 282959b..fc28498 100644 --- a/internal/loader/errors.go +++ b/internal/loader/errors.go @@ -18,4 +18,5 @@ var ( ErrConflictOldObject = errors.New("conflict: oldObject defined in multiple files") ErrConflictNamespaceObject = errors.New("conflict: namespaceObject defined in multiple files") ErrConflictParams = errors.New("conflict: params defined in multiple files") + ErrOperationMismatch = errors.New("explicit operation does not match inferred operation") ) diff --git a/internal/loader/request.go b/internal/loader/request.go index bd516f0..80e19e6 100644 --- a/internal/loader/request.go +++ b/internal/loader/request.go @@ -64,12 +64,12 @@ type simplifiedRequest struct { SubResource string `json:"subResource,omitempty"` Name string `json:"name,omitempty"` Namespace string `json:"namespace,omitempty"` - NamespaceObject map[string]interface{} `json:"namespaceObject,omitempty"` + NamespaceObject map[string]any `json:"namespaceObject,omitempty"` UserInfo *authenticationv1.UserInfo `json:"userInfo,omitempty"` - Object map[string]interface{} `json:"object,omitempty"` - OldObject map[string]interface{} `json:"oldObject,omitempty"` - Params map[string]interface{} `json:"params,omitempty"` - Options map[string]interface{} `json:"options,omitempty"` + Object map[string]any `json:"object,omitempty"` + OldObject map[string]any `json:"oldObject,omitempty"` + Params map[string]any `json:"params,omitempty"` + Options map[string]any `json:"options,omitempty"` } // parseRequestYAML parses a simplified request format. @@ -131,7 +131,7 @@ func validateSimplifiedRequest(req *simplifiedRequest) error { return nil } -func validateWithScheme(obj map[string]interface{}, field string, expectedGVK *schema.GroupVersionKind) error { +func validateWithScheme(obj map[string]any, field string, expectedGVK *schema.GroupVersionKind) error { if obj == nil { return nil } @@ -149,7 +149,7 @@ func validateWithScheme(obj map[string]interface{}, field string, expectedGVK *s return validateStructureStrict(obj, field, expectedGVK, u.GetKind()) } -func validateStructureStrict(obj map[string]interface{}, field string, expectedGVK *schema.GroupVersionKind, currentKind string) error { +func validateStructureStrict(obj map[string]any, field string, expectedGVK *schema.GroupVersionKind, currentKind string) error { // Decode using the default Kubernetes scheme to strictly validate known types data, err := json.Marshal(obj) if err != nil { @@ -235,7 +235,7 @@ func buildAdmissionRequestFromSimplified(req *simplifiedRequest, testReq *testRe // parseObjectYAML parses a raw Kubernetes object and creates an AdmissionRequest for it. func parseObjectYAML(testReq *testRequest, data []byte) error { - var obj map[string]interface{} + var obj map[string]any if err := yaml.Unmarshal(data, &obj); err != nil { return fmt.Errorf("failed to unmarshal object: %w", err) } @@ -246,7 +246,7 @@ func parseObjectYAML(testReq *testRequest, data []byte) error { unstruct := &unstructured.Unstructured{Object: obj} testReq.Object = unstruct - testReq.Request = buildCreateRequestFromObject(testReq.Name, unstruct) + testReq.Request = buildRequestFromObject(testReq.Name, unstruct) testReq.NamespaceName = unstruct.GetNamespace() if err := loadAuxiliaryFiles(testReq); err != nil { @@ -256,12 +256,13 @@ func parseObjectYAML(testReq *testRequest, data []byte) error { return nil } -func buildCreateRequestFromObject(testName string, obj *unstructured.Unstructured) *admissionv1.AdmissionRequest { +// buildRequestFromObject creates a partial AdmissionRequest with resource metadata. +// Operation is not set here — it is inferred centrally by inferOrValidateOperation. +func buildRequestFromObject(testName string, obj *unstructured.Unstructured) *admissionv1.AdmissionRequest { gvk := obj.GroupVersionKind() return &admissionv1.AdmissionRequest{ - UID: types.UID("test-" + testName), - Operation: admissionv1.Create, + UID: types.UID("test-" + testName), Kind: metav1.GroupVersionKind{ Group: gvk.Group, Version: gvk.Version, @@ -309,7 +310,7 @@ func loadGoldFile(testReq *testRequest) error { return fmt.Errorf("failed to read gold file: %w", err) } - var goldObj map[string]interface{} + var goldObj map[string]any if err := yaml.Unmarshal(goldData, &goldObj); err != nil { return fmt.Errorf("failed to unmarshal gold object: %w", err) } @@ -342,10 +343,11 @@ func loadMessageFile(testReq *testRequest) error { return nil } -// parseOldObjectYAML parses a raw Kubernetes object and creates an AdmissionRequest for DELETE operation. -// This is used for testing deletion policies where only oldObject is relevant. +// parseOldObjectYAML parses a raw Kubernetes object for the oldObject field. +// Operation is not set here — it is inferred centrally by inferOrValidateOperation +// (DELETE if only oldObject is present, UPDATE if both object and oldObject are present). func parseOldObjectYAML(testReq *testRequest, data []byte) error { - var obj map[string]interface{} + var obj map[string]any if err := yaml.Unmarshal(data, &obj); err != nil { return fmt.Errorf("failed to unmarshal oldObject: %w", err) } @@ -357,11 +359,10 @@ func parseOldObjectYAML(testReq *testRequest, data []byte) error { unstruct := &unstructured.Unstructured{Object: obj} testReq.OldObject = unstruct - // Build AdmissionRequest for DELETE operation + // Build a partial AdmissionRequest with resource metadata (operation set later by inference). gvk := unstruct.GroupVersionKind() - admReq := &admissionv1.AdmissionRequest{ - UID: types.UID("test-" + testReq.Name), - Operation: admissionv1.Delete, + testReq.Request = &admissionv1.AdmissionRequest{ + UID: types.UID("test-" + testReq.Name), Kind: metav1.GroupVersionKind{ Group: gvk.Group, Version: gvk.Version, @@ -376,7 +377,6 @@ func parseOldObjectYAML(testReq *testRequest, data []byte) error { Namespace: unstruct.GetNamespace(), } - testReq.Request = admReq testReq.NamespaceName = unstruct.GetNamespace() // Look for corresponding .message.txt file (expected error message). @@ -396,7 +396,7 @@ func parseOldObjectYAML(testReq *testRequest, data []byte) error { // parseNamespaceObjectYAML parses a Kubernetes Namespace object providing namespace context for the admission request. func parseNamespaceObjectYAML(testReq *testRequest, data []byte) error { - var obj map[string]interface{} + var obj map[string]any if err := yaml.Unmarshal(data, &obj); err != nil { return fmt.Errorf("failed to unmarshal namespaceObject: %w", err) } @@ -416,7 +416,7 @@ func parseNamespaceObjectYAML(testReq *testRequest, data []byte) error { // loadGoldFile loads the expected object from a .gold.yaml file. // parseParamsYAML parses a policy parameters file (ConfigMap or custom resource). func parseParamsYAML(testReq *testRequest, data []byte) error { - var obj map[string]interface{} + var obj map[string]any if err := yaml.Unmarshal(data, &obj); err != nil { return fmt.Errorf("failed to unmarshal params: %w", err) } @@ -475,13 +475,13 @@ func parseAuthorizerYAML(testReq *testRequest, data []byte) error { return nil } -// InferOperation determines the Kubernetes admission operation based on which YAML files are present. +// inferOperation determines the Kubernetes admission operation based on which YAML files are present. // If requestOpStr is non-empty, it's used directly (for explicit CONNECT operations). // Otherwise, operation is inferred from the presence of object/oldObject files: // - object only -> CREATE // - oldObject only -> DELETE // - both object and oldObject -> UPDATE -func InferOperation(hasObject, hasOldObject bool, requestOpStr string) (string, error) { +func inferOperation(hasObject, hasOldObject bool, requestOpStr string) (string, error) { // Explicit operation from request.yaml (e.g., CONNECT) if requestOpStr != "" { return requestOpStr, nil diff --git a/internal/loader/request_test.go b/internal/loader/request_test.go index b1cfa7b..ba788fb 100644 --- a/internal/loader/request_test.go +++ b/internal/loader/request_test.go @@ -14,22 +14,22 @@ func TestValidateWithScheme(t *testing.T) { tests := []struct { name string - obj map[string]interface{} + obj map[string]any field string expectedGVK *schema.GroupVersionKind wantErr bool }{ { name: "valid pod", - obj: map[string]interface{}{ + obj: map[string]any{ "apiVersion": "v1", "kind": "Pod", - "metadata": map[string]interface{}{ + "metadata": map[string]any{ "name": "test-pod", }, - "spec": map[string]interface{}{ - "containers": []interface{}{ - map[string]interface{}{ + "spec": map[string]any{ + "containers": []any{ + map[string]any{ "name": "nginx", "image": "nginx", }, @@ -42,15 +42,15 @@ func TestValidateWithScheme(t *testing.T) { }, { name: "invalid pod structure - typo in spec (strict)", - obj: map[string]interface{}{ + obj: map[string]any{ "apiVersion": "v1", "kind": "Pod", - "metadata": map[string]interface{}{ + "metadata": map[string]any{ "name": "test-pod", }, - "spec": map[string]interface{}{ - "containerss": []interface{}{ // Typo 'containerss' instead of 'containers' - map[string]interface{}{ + "spec": map[string]any{ + "containerss": []any{ // Typo 'containerss' instead of 'containers' + map[string]any{ "name": "nginx", "image": "nginx", }, @@ -62,13 +62,13 @@ func TestValidateWithScheme(t *testing.T) { }, { name: "invalid pod structure - wrong type for field", - obj: map[string]interface{}{ + obj: map[string]any{ "apiVersion": "v1", "kind": "Pod", - "metadata": map[string]interface{}{ + "metadata": map[string]any{ "name": "test-pod", }, - "spec": map[string]interface{}{ + "spec": map[string]any{ "restartPolicy": 123, // Should be string }, }, @@ -77,15 +77,15 @@ func TestValidateWithScheme(t *testing.T) { }, { name: "custom resource (unknown to scheme) - should pass leniently", - obj: map[string]interface{}{ + obj: map[string]any{ "apiVersion": "cilium.io/v2", "kind": "CiliumNetworkPolicy", - "metadata": map[string]interface{}{ + "metadata": map[string]any{ "name": "rule1", }, - "spec": map[string]interface{}{ - "endpointSelector": map[string]interface{}{ - "matchLabels": map[string]interface{}{ + "spec": map[string]any{ + "endpointSelector": map[string]any{ + "matchLabels": map[string]any{ "role": "backend", }, }, @@ -96,7 +96,7 @@ func TestValidateWithScheme(t *testing.T) { }, { name: "missing apiVersion", - obj: map[string]interface{}{ + obj: map[string]any{ "kind": "Pod", }, field: "object", @@ -104,7 +104,7 @@ func TestValidateWithScheme(t *testing.T) { }, { name: "missing kind", - obj: map[string]interface{}{ + obj: map[string]any{ "apiVersion": "v1", }, field: "object", @@ -112,10 +112,10 @@ func TestValidateWithScheme(t *testing.T) { }, { name: "wrong kind for namespaceObject", - obj: map[string]interface{}{ + obj: map[string]any{ "apiVersion": "v1", "kind": "Pod", // Not Namespace - "metadata": map[string]interface{}{ + "metadata": map[string]any{ "name": "foo", }, }, @@ -129,10 +129,10 @@ func TestValidateWithScheme(t *testing.T) { }, { name: "correct kind for namespaceObject", - obj: map[string]interface{}{ + obj: map[string]any{ "apiVersion": "v1", "kind": "Namespace", - "metadata": map[string]interface{}{ + "metadata": map[string]any{ "name": "foo", }, }, @@ -216,15 +216,15 @@ func TestInferOperation(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - got, err := InferOperation(tt.hasObject, tt.hasOldObject, tt.requestOpStr) + got, err := inferOperation(tt.hasObject, tt.hasOldObject, tt.requestOpStr) if (err != nil) != tt.wantErr { - t.Errorf("InferOperation() error = %v, wantErr %v", err, tt.wantErr) + t.Errorf("inferOperation() error = %v, wantErr %v", err, tt.wantErr) return } if got != tt.want { - t.Errorf("InferOperation() = %v, want %v", got, tt.want) + t.Errorf("inferOperation() = %v, want %v", got, tt.want) } }) } diff --git a/internal/loader/suite.go b/internal/loader/suite.go index dc6c9db..bade932 100644 --- a/internal/loader/suite.go +++ b/internal/loader/suite.go @@ -411,13 +411,7 @@ func buildTestRequest(baseName string, filePaths []string, policyNames []string) ExpectAllowed: expectAllowed, } - var hasExplicitRequest bool - for _, filePath := range filePaths { - if strings.HasSuffix(filePath, ".request.yaml") { - hasExplicitRequest = true - } - tempReq := newTempTestRequest(filePath, matchedPolicyName, expectAllowed) if err := parseTestRequestFile(tempReq); err != nil { @@ -433,16 +427,47 @@ func buildTestRequest(baseName string, filePaths []string, policyNames []string) } } - if !hasExplicitRequest && testReq.Request != nil { - op, err := InferOperation(testReq.Object != nil, testReq.OldObject != nil, "") - if err == nil && op != "" { - testReq.Request.Operation = admissionv1.Operation(op) + if testReq.Request != nil { + if err := inferOrValidateOperation(testReq); err != nil { + testReq.Error = err } } return testReq } +// inferOrValidateOperation infers the operation from object/oldObject presence, +// or validates that an explicit operation matches what would be inferred. +func inferOrValidateOperation(testReq *testRequest) error { + hasObject := testReq.Object != nil + hasOldObject := testReq.OldObject != nil + + inferred, err := inferOperation(hasObject, hasOldObject, "") + if err != nil { + // Cannot infer (e.g., neither object nor oldObject present). + // Only an error if there's no explicit operation either. + if testReq.Request.Operation == "" { + return err + } + + return nil + } + + explicit := string(testReq.Request.Operation) + if explicit == "" { + testReq.Request.Operation = admissionv1.Operation(inferred) + + return nil + } + + if explicit != inferred { + return fmt.Errorf("%w: explicit %q, inferred %q from object/oldObject presence", + ErrOperationMismatch, explicit, inferred) + } + + return nil +} + func matchPolicyName(baseName string, policyNames []string) string { for _, policyName := range policyNames { if strings.HasPrefix(baseName, policyName+".") { diff --git a/internal/loader/suite_test.go b/internal/loader/suite_test.go index b7dc38b..49991b9 100644 --- a/internal/loader/suite_test.go +++ b/internal/loader/suite_test.go @@ -143,13 +143,13 @@ func TestTestCase_Getters(t *testing.T) { t.Parallel() req := &admissionv1.AdmissionRequest{UID: "uid"} - obj := &unstructured.Unstructured{Object: map[string]interface{}{"kind": "Pod"}} - oldObj := &unstructured.Unstructured{Object: map[string]interface{}{"kind": "Pod"}} - params := &unstructured.Unstructured{Object: map[string]interface{}{"foo": "bar"}} - nsObj := &unstructured.Unstructured{Object: map[string]interface{}{"kind": "Namespace"}} + obj := &unstructured.Unstructured{Object: map[string]any{"kind": "Pod"}} + oldObj := &unstructured.Unstructured{Object: map[string]any{"kind": "Pod"}} + params := &unstructured.Unstructured{Object: map[string]any{"foo": "bar"}} + nsObj := &unstructured.Unstructured{Object: map[string]any{"kind": "Namespace"}} userInfo := &user.DefaultInfo{Name: "user"} auth := []evaluator.AuthorizationMockConfig{{Verb: "get"}} - expectedObj := &unstructured.Unstructured{Object: map[string]interface{}{"kind": "Mutation"}} + expectedObj := &unstructured.Unstructured{Object: map[string]any{"kind": "Mutation"}} err := errTest tc := &TestCase{ @@ -653,7 +653,7 @@ func TestMergeRequest_AllFields(t *testing.T) { func TestMergeTestRequests_Conflicts(t *testing.T) { t.Parallel() - obj := &unstructured.Unstructured{Object: map[string]interface{}{"kind": "Pod"}} + obj := &unstructured.Unstructured{Object: map[string]any{"kind": "Pod"}} tests := []struct { name string @@ -689,6 +689,54 @@ func TestMergeTestRequests_Conflicts(t *testing.T) { } } +func TestInferOrValidateOperation(t *testing.T) { + t.Parallel() + + obj := &unstructured.Unstructured{Object: map[string]any{"kind": "Pod"}} + + ar := func(op string) *admissionv1.AdmissionRequest { + return &admissionv1.AdmissionRequest{Operation: admissionv1.Operation(op)} + } + tests := []struct { + name string + req testRequest + wantOp string + wantErr error + }{ + {"infer CREATE", testRequest{Object: obj, Request: ar("")}, "CREATE", nil}, + {"infer DELETE", testRequest{OldObject: obj, Request: ar("")}, "DELETE", nil}, + {"infer UPDATE", testRequest{Object: obj, OldObject: obj, Request: ar("")}, "UPDATE", nil}, + {"explicit matches", testRequest{Object: obj, Request: ar("CREATE")}, "CREATE", nil}, + {"explicit conflicts", testRequest{Object: obj, OldObject: obj, Request: ar("DELETE")}, "", ErrOperationMismatch}, + {"CONNECT no objects", testRequest{Request: ar("CONNECT")}, "CONNECT", nil}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + req := tt.req + err := inferOrValidateOperation(&req) + + if tt.wantErr != nil { + if !errors.Is(err, tt.wantErr) { + t.Errorf("inferOrValidateOperation() error = %v, want %v", err, tt.wantErr) + } + + return + } + + if err != nil { + t.Fatalf("inferOrValidateOperation() unexpected error: %v", err) + } + + if string(req.Request.Operation) != tt.wantOp { + t.Errorf("operation = %q, want %q", req.Request.Operation, tt.wantOp) + } + }) + } +} + func testGroupVersionResource(version, resource string) metav1.GroupVersionResource { return metav1.GroupVersionResource{Version: version, Resource: resource} } diff --git a/internal/reporter/reporter.go b/internal/reporter/reporter.go index aeab358..b0fdd6e 100644 --- a/internal/reporter/reporter.go +++ b/internal/reporter/reporter.go @@ -197,8 +197,8 @@ func (s *SuiteReporter) ReportFail(testName, message string) { } func (s *SuiteReporter) printIndented(message string) { - lines := strings.Split(message, "\n") - for _, line := range lines { + lines := strings.SplitSeq(message, "\n") + for line := range lines { if line == "" { fmt.Fprintln(s.rep.out) } else {