From 7777381968198d2183a4c40f1f2da8760b3d842d Mon Sep 17 00:00:00 2001 From: pnguyen44 Date: Tue, 23 Jun 2026 15:01:20 -0400 Subject: [PATCH 1/3] HYPERFLEET-1178 - test: add negative conformance tests for v1 adapter contract --- e2e/adapter/conformance_negative.go | 135 +++++++++++++++ pkg/client/cluster.go | 9 + .../testcases/adapter-conformance-negative.md | 156 ++++++++++++++++++ 3 files changed, 300 insertions(+) create mode 100644 e2e/adapter/conformance_negative.go create mode 100644 test-design/testcases/adapter-conformance-negative.md diff --git a/e2e/adapter/conformance_negative.go b/e2e/adapter/conformance_negative.go new file mode 100644 index 0000000..27bfc7d --- /dev/null +++ b/e2e/adapter/conformance_negative.go @@ -0,0 +1,135 @@ +package adapter + +import ( + "context" + "net/http" + "time" + + "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" //nolint:staticcheck // dot import for test readability + + "github.com/openshift-hyperfleet/hyperfleet-e2e/pkg/api/openapi" + "github.com/openshift-hyperfleet/hyperfleet-e2e/pkg/client" + "github.com/openshift-hyperfleet/hyperfleet-e2e/pkg/helper" + "github.com/openshift-hyperfleet/hyperfleet-e2e/pkg/labels" +) + +const ( + conformanceNegativeAdapter = "conformance-test" + conditionTypeReady = "Ready" +) + +// These tests verify deployment-level conformance: the deployed v1 stack +// correctly rejects pre-migration adapter behavior. While the assertions are +// HTTP status codes, the intent is end-to-end migration validation, not +// unit-level handler testing. +var _ = ginkgo.Describe("[Suite: adapter][negative] Legacy adapter behavior rejected by v1 API contract", + ginkgo.Label(labels.Tier0, labels.Negative), + func() { + ginkgo.It("should reject POST to the statuses endpoint with 405", + func(ctx context.Context) { + h := helper.New() + + ginkgo.By("creating a cluster") + cluster, err := h.Client.CreateClusterFromPayload(ctx, h.TestDataPath("payloads/clusters/cluster-request.json")) + Expect(err).NotTo(HaveOccurred()) + Expect(cluster.Id).NotTo(BeNil()) + clusterID := *cluster.Id + + ginkgo.DeferCleanup(func(ctx context.Context) { + _ = h.CleanupTestCluster(ctx, clusterID) + }) + + ginkgo.By("sending a POST status report (old v0.2.0 method)") + body := openapi.AdapterStatusCreateRequest{ + Adapter: conformanceNegativeAdapter, + ObservedGeneration: cluster.Generation, + ObservedTime: time.Now(), + Conditions: []openapi.ConditionRequest{ + {Type: client.ConditionTypeApplied, Status: openapi.AdapterConditionStatusTrue}, + {Type: client.ConditionTypeAvailable, Status: openapi.AdapterConditionStatusTrue}, + {Type: client.ConditionTypeHealth, Status: openapi.AdapterConditionStatusTrue}, + }, + } + resp, err := h.Client.PostClusterStatuses(ctx, clusterID, body) + defer func() { + if resp != nil { + _ = resp.Body.Close() + } + }() + Expect(err).NotTo(HaveOccurred()) + + Expect(resp.StatusCode).To(Equal(http.StatusMethodNotAllowed), + "POST to statuses should return 405; v1.0.0 only accepts PUT") + }) + + ginkgo.It("should reject PUT with missing mandatory conditions with 400", + func(ctx context.Context) { + h := helper.New() + + ginkgo.By("creating a cluster") + cluster, err := h.Client.CreateClusterFromPayload(ctx, h.TestDataPath("payloads/clusters/cluster-request.json")) + Expect(err).NotTo(HaveOccurred()) + Expect(cluster.Id).NotTo(BeNil()) + clusterID := *cluster.Id + + ginkgo.DeferCleanup(func(ctx context.Context) { + _ = h.CleanupTestCluster(ctx, clusterID) + }) + + ginkgo.By("sending a PUT with only a Ready condition (missing Applied, Available, Health)") + resp, err := h.Client.PutClusterStatuses(ctx, clusterID, openapi.AdapterStatusCreateRequest{ + Adapter: conformanceNegativeAdapter, + ObservedGeneration: cluster.Generation, + ObservedTime: time.Now(), + Conditions: []openapi.ConditionRequest{ + {Type: conditionTypeReady, Status: openapi.AdapterConditionStatusTrue}, + }, + }) + defer func() { + if resp != nil { + _ = resp.Body.Close() + } + }() + Expect(err).NotTo(HaveOccurred()) + + Expect(resp.StatusCode).To(Equal(http.StatusBadRequest), + "missing mandatory conditions (Available, Applied, Health) should return 400") + }) + + ginkgo.It("should not have a resource-level Ready condition on a reconciled cluster", + func(ctx context.Context) { + h := helper.New() + + ginkgo.By("creating a cluster") + cluster, err := h.Client.CreateClusterFromPayload(ctx, h.TestDataPath("payloads/clusters/cluster-request.json")) + Expect(err).NotTo(HaveOccurred()) + Expect(cluster.Id).NotTo(BeNil()) + clusterID := *cluster.Id + + ginkgo.DeferCleanup(func(ctx context.Context) { + _ = h.CleanupTestCluster(ctx, clusterID) + }) + + ginkgo.By("waiting for Reconciled=True") + Eventually(h.PollCluster(ctx, clusterID), h.Cfg.Timeouts.Cluster.Reconciled, h.Cfg.Polling.Interval). + Should(helper.HaveResourceCondition(client.ConditionTypeReconciled, openapi.ResourceConditionStatusTrue)) + + ginkgo.By("verifying resource-level Ready condition does not exist") + reconciledCluster, err := h.Client.GetCluster(ctx, clusterID) + Expect(err).NotTo(HaveOccurred()) + Expect(reconciledCluster.Status).NotTo(BeNil()) + + for _, c := range reconciledCluster.Status.Conditions { + Expect(c.Type).NotTo(Equal(conditionTypeReady), + "v1.0.0 removed the Ready condition; no Ready condition should exist regardless of status") + } + + ginkgo.By("confirming Reconciled and LastKnownReconciled are the correct v1.0.0 conditions") + Expect(h.HasResourceCondition(reconciledCluster.Status.Conditions, + client.ConditionTypeReconciled, openapi.ResourceConditionStatusTrue)).To(BeTrue()) + Expect(h.HasResourceCondition(reconciledCluster.Status.Conditions, + client.ConditionTypeLastKnownReconciled, openapi.ResourceConditionStatusTrue)).To(BeTrue()) + }) + }, +) diff --git a/pkg/client/cluster.go b/pkg/client/cluster.go index 59a7e08..7941400 100644 --- a/pkg/client/cluster.go +++ b/pkg/client/cluster.go @@ -60,6 +60,15 @@ func (c *HyperFleetClient) GetClusterStatuses(ctx context.Context, clusterID str return handleHTTPResponse[openapi.AdapterStatusList](resp, http.StatusOK, "get cluster statuses") } +// PostClusterStatuses sends a POST to the cluster statuses endpoint. +// POST was removed in v1.0.0 (replaced by PUT), so this is expected to return 405. +// Returns the raw HTTP response for status code assertions in negative conformance tests. +// The caller is responsible for closing the response body. +func (c *HyperFleetClient) PostClusterStatuses(ctx context.Context, clusterID string, body openapi.AdapterStatusCreateRequest) (*http.Response, error) { + path := fmt.Sprintf("clusters/%s/statuses", clusterID) + return c.doJSON(ctx, http.MethodPost, path, body) +} + // CreateClusterFromPayload creates a cluster from a JSON payload file. // The payload file should contain a ClusterCreateRequest in JSON format. func (c *HyperFleetClient) CreateClusterFromPayload(ctx context.Context, payloadPath string) (*openapi.Cluster, error) { diff --git a/test-design/testcases/adapter-conformance-negative.md b/test-design/testcases/adapter-conformance-negative.md new file mode 100644 index 0000000..ed394d9 --- /dev/null +++ b/test-design/testcases/adapter-conformance-negative.md @@ -0,0 +1,156 @@ +# Feature: Adapter Contract Conformance (Negative) + +## Table of Contents + +1. [POST to statuses endpoint returns 405](#test-title-post-to-statuses-endpoint-returns-405) +2. [PUT with missing mandatory conditions returns 400](#test-title-put-with-missing-mandatory-conditions-returns-400) +3. [Ready condition absent on reconciled cluster](#test-title-ready-condition-absent-on-reconciled-cluster) + +--- + +## Test Title: POST to statuses endpoint returns 405 + +### Description + +Validates that the v1.0.0 API rejects the old v0.2.0 POST method for adapter status reporting. Adapters that have not migrated to PUT will receive a 405 Method Not Allowed response instead of silently succeeding. + +--- + +| **Field** | **Value** | +|-----------|-----------| +| **Pos/Neg** | Negative | +| **Priority** | Tier0 | +| **Status** | Draft | +| **Automation** | Automated | +| **Version** | v1.0.0 | +| **Created** | 2026-06-23 | +| **Updated** | 2026-06-23 | + +--- + +### Preconditions + +1. HyperFleet API is deployed with v1.0.0 adapter contract (PUT-only statuses endpoint) +2. HyperFleet Sentinel is deployed and running + +--- + +### Test Steps + +#### Step 1: Create a cluster + +**Action:** +- Create a cluster via the API using a standard cluster payload + +**Expected Result:** +- API returns the created cluster with an ID and generation + +#### Step 2: Send a POST status report + +**Action:** +- Send a POST request to `/clusters/{id}/statuses` with a valid `AdapterStatusCreateRequest` body containing all three mandatory conditions (Applied, Available, Health) + +**Expected Result:** +- API returns HTTP 405 Method Not Allowed +- The POST method was removed in v1.0.0; only PUT is accepted + +--- + +## Test Title: PUT with missing mandatory conditions returns 400 + +### Description + +Validates that the v1.0.0 API rejects adapter status reports that omit the three mandatory conditions (Available, Applied, Health). Sends a PUT with only a `Ready` condition (which was removed in v1.0.0) and no mandatory conditions. + +--- + +| **Field** | **Value** | +|-----------|-----------| +| **Pos/Neg** | Negative | +| **Priority** | Tier0 | +| **Status** | Draft | +| **Automation** | Automated | +| **Version** | v1.0.0 | +| **Created** | 2026-06-23 | +| **Updated** | 2026-06-23 | + +--- + +### Preconditions + +1. HyperFleet API is deployed with v1.0.0 adapter contract +2. HyperFleet Sentinel is deployed and running + +--- + +### Test Steps + +#### Step 1: Create a cluster + +**Action:** +- Create a cluster via the API using a standard cluster payload + +**Expected Result:** +- API returns the created cluster with an ID and generation + +#### Step 2: Send a PUT with only a Ready condition + +**Action:** +- Send a PUT request to `/clusters/{id}/statuses` with an `AdapterStatusCreateRequest` containing only `{Type: "Ready", Status: "True"}`, omitting the mandatory Available, Applied, and Health conditions + +**Expected Result:** +- API returns HTTP 400 Bad Request +- The three mandatory conditions (Available, Applied, Health) must be present in every adapter status report + +--- + +## Test Title: Ready condition absent on reconciled cluster + +### Description + +Validates that the v1.0.0 API does not produce a resource-level `Ready` condition on a fully reconciled cluster. Adapters that poll for `Ready=True` to determine readiness will hang forever. The correct v1.0.0 conditions are `Reconciled` and `LastKnownReconciled`. + +--- + +| **Field** | **Value** | +|-----------|-----------| +| **Pos/Neg** | Negative | +| **Priority** | Tier0 | +| **Status** | Draft | +| **Automation** | Automated | +| **Version** | v1.0.0 | +| **Created** | 2026-06-23 | +| **Updated** | 2026-06-23 | + +--- + +### Preconditions + +1. HyperFleet API is deployed with v1.0.0 adapter contract +2. HyperFleet Sentinel is deployed and running +3. At least one adapter is configured and running to drive reconciliation + +--- + +### Test Steps + +#### Step 1: Create a cluster and wait for reconciliation + +**Action:** +- Create a cluster via the API +- Poll until `Reconciled=True` + +**Expected Result:** +- Cluster reaches `Reconciled` condition with `status: "True"` + +#### Step 2: Verify Ready condition is absent + +**Action:** +- Fetch the reconciled cluster and inspect `status.conditions` + +**Expected Result:** +- No condition with `type: "Ready"` exists in the resource-level conditions +- `Reconciled` condition is present with `status: "True"` +- `LastKnownReconciled` condition is present with `status: "True"` + +--- From 4717af717433473f205dd15acc68eb272b8423d4 Mon Sep 17 00:00:00 2001 From: pnguyen44 Date: Tue, 23 Jun 2026 15:17:02 -0400 Subject: [PATCH 2/3] HYPERFLEET-1178 - refactor: use configured adapter name and improve cleanup logging in negative tests --- e2e/adapter/conformance_negative.go | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/e2e/adapter/conformance_negative.go b/e2e/adapter/conformance_negative.go index 27bfc7d..f18bd10 100644 --- a/e2e/adapter/conformance_negative.go +++ b/e2e/adapter/conformance_negative.go @@ -14,10 +14,7 @@ import ( "github.com/openshift-hyperfleet/hyperfleet-e2e/pkg/labels" ) -const ( - conformanceNegativeAdapter = "conformance-test" - conditionTypeReady = "Ready" -) +const conditionTypeReady = "Ready" // These tests verify deployment-level conformance: the deployed v1 stack // correctly rejects pre-migration adapter behavior. While the assertions are @@ -29,6 +26,8 @@ var _ = ginkgo.Describe("[Suite: adapter][negative] Legacy adapter behavior reje ginkgo.It("should reject POST to the statuses endpoint with 405", func(ctx context.Context) { h := helper.New() + Expect(h.Cfg.Adapters.Cluster).NotTo(BeEmpty(), "at least one cluster adapter must be configured") + adapterName := h.Cfg.Adapters.Cluster[0] ginkgo.By("creating a cluster") cluster, err := h.Client.CreateClusterFromPayload(ctx, h.TestDataPath("payloads/clusters/cluster-request.json")) @@ -37,12 +36,14 @@ var _ = ginkgo.Describe("[Suite: adapter][negative] Legacy adapter behavior reje clusterID := *cluster.Id ginkgo.DeferCleanup(func(ctx context.Context) { - _ = h.CleanupTestCluster(ctx, clusterID) + if err := h.CleanupTestCluster(ctx, clusterID); err != nil { + ginkgo.GinkgoWriter.Printf("Warning: failed to cleanup cluster %s: %v\n", clusterID, err) + } }) ginkgo.By("sending a POST status report (old v0.2.0 method)") body := openapi.AdapterStatusCreateRequest{ - Adapter: conformanceNegativeAdapter, + Adapter: adapterName, ObservedGeneration: cluster.Generation, ObservedTime: time.Now(), Conditions: []openapi.ConditionRequest{ @@ -66,6 +67,8 @@ var _ = ginkgo.Describe("[Suite: adapter][negative] Legacy adapter behavior reje ginkgo.It("should reject PUT with missing mandatory conditions with 400", func(ctx context.Context) { h := helper.New() + Expect(h.Cfg.Adapters.Cluster).NotTo(BeEmpty(), "at least one cluster adapter must be configured") + adapterName := h.Cfg.Adapters.Cluster[0] ginkgo.By("creating a cluster") cluster, err := h.Client.CreateClusterFromPayload(ctx, h.TestDataPath("payloads/clusters/cluster-request.json")) @@ -74,12 +77,14 @@ var _ = ginkgo.Describe("[Suite: adapter][negative] Legacy adapter behavior reje clusterID := *cluster.Id ginkgo.DeferCleanup(func(ctx context.Context) { - _ = h.CleanupTestCluster(ctx, clusterID) + if err := h.CleanupTestCluster(ctx, clusterID); err != nil { + ginkgo.GinkgoWriter.Printf("Warning: failed to cleanup cluster %s: %v\n", clusterID, err) + } }) ginkgo.By("sending a PUT with only a Ready condition (missing Applied, Available, Health)") resp, err := h.Client.PutClusterStatuses(ctx, clusterID, openapi.AdapterStatusCreateRequest{ - Adapter: conformanceNegativeAdapter, + Adapter: adapterName, ObservedGeneration: cluster.Generation, ObservedTime: time.Now(), Conditions: []openapi.ConditionRequest{ @@ -108,7 +113,9 @@ var _ = ginkgo.Describe("[Suite: adapter][negative] Legacy adapter behavior reje clusterID := *cluster.Id ginkgo.DeferCleanup(func(ctx context.Context) { - _ = h.CleanupTestCluster(ctx, clusterID) + if err := h.CleanupTestCluster(ctx, clusterID); err != nil { + ginkgo.GinkgoWriter.Printf("Warning: failed to cleanup cluster %s: %v\n", clusterID, err) + } }) ginkgo.By("waiting for Reconciled=True") From 06b88c37a7ef329302158f57be66cea584bc7e00 Mon Sep 17 00:00:00 2001 From: pnguyen44 Date: Wed, 24 Jun 2026 11:53:34 -0400 Subject: [PATCH 3/3] HYPERFLEET-1178 - chore: drop single-component tests covered by API repo --- e2e/adapter/conformance_negative.go | 85 ----------------------------- pkg/client/cluster.go | 9 --- 2 files changed, 94 deletions(-) diff --git a/e2e/adapter/conformance_negative.go b/e2e/adapter/conformance_negative.go index f18bd10..9c54ab6 100644 --- a/e2e/adapter/conformance_negative.go +++ b/e2e/adapter/conformance_negative.go @@ -2,8 +2,6 @@ package adapter import ( "context" - "net/http" - "time" "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" //nolint:staticcheck // dot import for test readability @@ -16,92 +14,9 @@ import ( const conditionTypeReady = "Ready" -// These tests verify deployment-level conformance: the deployed v1 stack -// correctly rejects pre-migration adapter behavior. While the assertions are -// HTTP status codes, the intent is end-to-end migration validation, not -// unit-level handler testing. var _ = ginkgo.Describe("[Suite: adapter][negative] Legacy adapter behavior rejected by v1 API contract", ginkgo.Label(labels.Tier0, labels.Negative), func() { - ginkgo.It("should reject POST to the statuses endpoint with 405", - func(ctx context.Context) { - h := helper.New() - Expect(h.Cfg.Adapters.Cluster).NotTo(BeEmpty(), "at least one cluster adapter must be configured") - adapterName := h.Cfg.Adapters.Cluster[0] - - ginkgo.By("creating a cluster") - cluster, err := h.Client.CreateClusterFromPayload(ctx, h.TestDataPath("payloads/clusters/cluster-request.json")) - Expect(err).NotTo(HaveOccurred()) - Expect(cluster.Id).NotTo(BeNil()) - clusterID := *cluster.Id - - ginkgo.DeferCleanup(func(ctx context.Context) { - if err := h.CleanupTestCluster(ctx, clusterID); err != nil { - ginkgo.GinkgoWriter.Printf("Warning: failed to cleanup cluster %s: %v\n", clusterID, err) - } - }) - - ginkgo.By("sending a POST status report (old v0.2.0 method)") - body := openapi.AdapterStatusCreateRequest{ - Adapter: adapterName, - ObservedGeneration: cluster.Generation, - ObservedTime: time.Now(), - Conditions: []openapi.ConditionRequest{ - {Type: client.ConditionTypeApplied, Status: openapi.AdapterConditionStatusTrue}, - {Type: client.ConditionTypeAvailable, Status: openapi.AdapterConditionStatusTrue}, - {Type: client.ConditionTypeHealth, Status: openapi.AdapterConditionStatusTrue}, - }, - } - resp, err := h.Client.PostClusterStatuses(ctx, clusterID, body) - defer func() { - if resp != nil { - _ = resp.Body.Close() - } - }() - Expect(err).NotTo(HaveOccurred()) - - Expect(resp.StatusCode).To(Equal(http.StatusMethodNotAllowed), - "POST to statuses should return 405; v1.0.0 only accepts PUT") - }) - - ginkgo.It("should reject PUT with missing mandatory conditions with 400", - func(ctx context.Context) { - h := helper.New() - Expect(h.Cfg.Adapters.Cluster).NotTo(BeEmpty(), "at least one cluster adapter must be configured") - adapterName := h.Cfg.Adapters.Cluster[0] - - ginkgo.By("creating a cluster") - cluster, err := h.Client.CreateClusterFromPayload(ctx, h.TestDataPath("payloads/clusters/cluster-request.json")) - Expect(err).NotTo(HaveOccurred()) - Expect(cluster.Id).NotTo(BeNil()) - clusterID := *cluster.Id - - ginkgo.DeferCleanup(func(ctx context.Context) { - if err := h.CleanupTestCluster(ctx, clusterID); err != nil { - ginkgo.GinkgoWriter.Printf("Warning: failed to cleanup cluster %s: %v\n", clusterID, err) - } - }) - - ginkgo.By("sending a PUT with only a Ready condition (missing Applied, Available, Health)") - resp, err := h.Client.PutClusterStatuses(ctx, clusterID, openapi.AdapterStatusCreateRequest{ - Adapter: adapterName, - ObservedGeneration: cluster.Generation, - ObservedTime: time.Now(), - Conditions: []openapi.ConditionRequest{ - {Type: conditionTypeReady, Status: openapi.AdapterConditionStatusTrue}, - }, - }) - defer func() { - if resp != nil { - _ = resp.Body.Close() - } - }() - Expect(err).NotTo(HaveOccurred()) - - Expect(resp.StatusCode).To(Equal(http.StatusBadRequest), - "missing mandatory conditions (Available, Applied, Health) should return 400") - }) - ginkgo.It("should not have a resource-level Ready condition on a reconciled cluster", func(ctx context.Context) { h := helper.New() diff --git a/pkg/client/cluster.go b/pkg/client/cluster.go index 7941400..59a7e08 100644 --- a/pkg/client/cluster.go +++ b/pkg/client/cluster.go @@ -60,15 +60,6 @@ func (c *HyperFleetClient) GetClusterStatuses(ctx context.Context, clusterID str return handleHTTPResponse[openapi.AdapterStatusList](resp, http.StatusOK, "get cluster statuses") } -// PostClusterStatuses sends a POST to the cluster statuses endpoint. -// POST was removed in v1.0.0 (replaced by PUT), so this is expected to return 405. -// Returns the raw HTTP response for status code assertions in negative conformance tests. -// The caller is responsible for closing the response body. -func (c *HyperFleetClient) PostClusterStatuses(ctx context.Context, clusterID string, body openapi.AdapterStatusCreateRequest) (*http.Response, error) { - path := fmt.Sprintf("clusters/%s/statuses", clusterID) - return c.doJSON(ctx, http.MethodPost, path, body) -} - // CreateClusterFromPayload creates a cluster from a JSON payload file. // The payload file should contain a ClusterCreateRequest in JSON format. func (c *HyperFleetClient) CreateClusterFromPayload(ctx context.Context, payloadPath string) (*openapi.Cluster, error) {