From 9a28571ec61528bbf544eb4fad8740602d073f5f Mon Sep 17 00:00:00 2001 From: Dmitrii Andreev Date: Tue, 30 Jun 2026 15:51:22 -0500 Subject: [PATCH] HYPERFLEET-1155 - feat: add force-delete endpoint for generic resources Add POST /{plural}/{id}/force-delete to ResourceHandler for all generic entity types (channels, versions, wifconfigs). The endpoint bypasses OnParentDelete policies and recursively hard-deletes the entire ownership tree for resources in Finalizing state (deleted_time IS NOT NULL). Key design decisions per ADR-0013 (DB-only force-delete): - Requires Finalizing state and a reason field in the request body - Bottom-up hard-delete ordering per ADR-0012 - Fail-loud guard if RequiredAdapters non-empty (tripwire for HYPERFLEET-1154) - MarkForRollback on error prevents partial tree deletion from committing - Audit log with structured fields before each resource deletion Includes unit tests (handler + service + tripwire) and integration tests confirming force-delete succeeds where normal DELETE returns 409 due to Restrict policy. --- pkg/handlers/resource_handler.go | 44 +++ pkg/handlers/resource_handler_test.go | 172 +++++++++++ pkg/services/resource.go | 69 +++++ pkg/services/resource_test.go | 244 +++++++++++++++- plugins/entities/plugin.go | 2 + .../integration/resource_force_delete_test.go | 275 ++++++++++++++++++ test/integration/resource_helpers.go | 10 + 7 files changed, 815 insertions(+), 1 deletion(-) create mode 100644 test/integration/resource_force_delete_test.go diff --git a/pkg/handlers/resource_handler.go b/pkg/handlers/resource_handler.go index 151fdced..997cb553 100644 --- a/pkg/handlers/resource_handler.go +++ b/pkg/handlers/resource_handler.go @@ -275,3 +275,47 @@ func (h *ResourceHandler) DeleteByOwner(w http.ResponseWriter, r *http.Request) } handleSoftDelete(w, r, cfg) } + +func (h *ResourceHandler) ForceDelete(w http.ResponseWriter, r *http.Request) { + var req openapi.ForceDeleteRequest + cfg := &handlerConfig{ + MarshalInto: &req, + Validate: []validate{ + validateNotEmpty(&req, "Reason", "reason"), + validateMaxLength(&req, "Reason", "reason", maxReasonLength), + }, + Action: func() (interface{}, *errors.ServiceError) { + id := mux.Vars(r)["id"] + if err := h.service.ForceDelete(r.Context(), h.descriptor.Kind, id, req.Reason); err != nil { + return nil, err + } + return nil, nil + }, + } + handleForceDelete(w, r, cfg) +} + +func (h *ResourceHandler) ForceDeleteByOwner(w http.ResponseWriter, r *http.Request) { + var req openapi.ForceDeleteRequest + cfg := &handlerConfig{ + MarshalInto: &req, + Validate: []validate{ + validateNotEmpty(&req, "Reason", "reason"), + validateMaxLength(&req, "Reason", "reason", maxReasonLength), + }, + Action: func() (interface{}, *errors.ServiceError) { + vars := mux.Vars(r) + parentID, id := vars["parent_id"], vars["id"] + + if _, err := h.service.GetByOwner(r.Context(), h.descriptor.Kind, id, parentID); err != nil { + return nil, err + } + + if err := h.service.ForceDelete(r.Context(), h.descriptor.Kind, id, req.Reason); err != nil { + return nil, err + } + return nil, nil + }, + } + handleForceDelete(w, r, cfg) +} diff --git a/pkg/handlers/resource_handler_test.go b/pkg/handlers/resource_handler_test.go index 22ed9ec2..ca823b91 100644 --- a/pkg/handlers/resource_handler_test.go +++ b/pkg/handlers/resource_handler_test.go @@ -650,3 +650,175 @@ func TestResourceHandler_DeleteByOwner(t *testing.T) { }) } } + +func TestResourceHandler_ForceDelete(t *testing.T) { + RegisterTestingT(t) + + resourceID := "ch-123" + + tests := []struct { + setupMock func(mock *services.MockResourceService) + name string + body string + expectedStatusCode int + }{ + { + name: "Success 204 - resource force-deleted", + body: `{"reason": "Stuck in finalizing for 2 hours"}`, + setupMock: func(mock *services.MockResourceService) { + mock.EXPECT(). + ForceDelete(gomock.Any(), "Channel", resourceID, "Stuck in finalizing for 2 hours"). + Return(nil) + }, + expectedStatusCode: http.StatusNoContent, + }, + { + name: "Error 400 - malformed JSON", + body: `not json`, + setupMock: func(mock *services.MockResourceService) { + }, + expectedStatusCode: http.StatusBadRequest, + }, + { + name: "Error 400 - empty reason", + body: `{"reason": ""}`, + setupMock: func(mock *services.MockResourceService) { + }, + expectedStatusCode: http.StatusBadRequest, + }, + { + name: "Error 400 - reason exceeds max length", + body: `{"reason": "` + strings.Repeat("x", maxReasonLength+1) + `"}`, + setupMock: func(mock *services.MockResourceService) { + }, + expectedStatusCode: http.StatusBadRequest, + }, + { + name: "Error 404 - resource not found", + body: `{"reason": "some reason"}`, + setupMock: func(mock *services.MockResourceService) { + mock.EXPECT(). + ForceDelete(gomock.Any(), "Channel", resourceID, "some reason"). + Return(errors.NotFound("Channel with id='%s' not found", resourceID)) + }, + expectedStatusCode: http.StatusNotFound, + }, + { + name: "Error 409 - resource not in Finalizing state", + body: `{"reason": "some reason"}`, + setupMock: func(mock *services.MockResourceService) { + mock.EXPECT(). + ForceDelete(gomock.Any(), "Channel", resourceID, "some reason"). + Return(errors.ConflictState("Channel '%s' is not in Finalizing state", resourceID)) + }, + expectedStatusCode: http.StatusConflict, + }, + { + name: "Error 500 - service internal error", + body: `{"reason": "some reason"}`, + setupMock: func(mock *services.MockResourceService) { + mock.EXPECT(). + ForceDelete(gomock.Any(), "Channel", resourceID, "some reason"). + Return(errors.GeneralError("database connection lost")) + }, + expectedStatusCode: http.StatusInternalServerError, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + RegisterTestingT(t) + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + handler, mockSvc := newTestResourceHandler(ctrl) + tt.setupMock(mockSvc) + + reqURL := "/api/hyperfleet/v1/channels/" + resourceID + "/force-delete" + req := httptest.NewRequest(http.MethodPost, reqURL, strings.NewReader(tt.body)) + req.Header.Set("Content-Type", "application/json") + req = mux.SetURLVars(req, map[string]string{"id": resourceID}) + + rr := httptest.NewRecorder() + handler.ForceDelete(rr, req) + + Expect(rr.Code).To(Equal(tt.expectedStatusCode)) + + if tt.expectedStatusCode == http.StatusNoContent { + Expect(rr.Body.Len()).To(Equal(0)) + } + }) + } +} + +func TestResourceHandler_ForceDeleteByOwner(t *testing.T) { + RegisterTestingT(t) + + parentID := "ch-1" + versionID := "v-1" + + tests := []struct { + setupMock func(mock *services.MockResourceService) + name string + body string + expectedStatusCode int + }{ + { + name: "Success 204 - nested resource force-deleted", + body: `{"reason": "Stuck in finalizing"}`, + setupMock: func(mock *services.MockResourceService) { + mock.EXPECT(). + GetByOwner(gomock.Any(), "Version", versionID, parentID). + Return(&api.Resource{Meta: api.Meta{ID: versionID}, Kind: "Version"}, nil) + mock.EXPECT(). + ForceDelete(gomock.Any(), "Version", versionID, "Stuck in finalizing"). + Return(nil) + }, + expectedStatusCode: http.StatusNoContent, + }, + { + name: "Error 404 - ownership mismatch", + body: `{"reason": "some reason"}`, + setupMock: func(mock *services.MockResourceService) { + mock.EXPECT(). + GetByOwner(gomock.Any(), "Version", versionID, parentID). + Return(nil, errors.NotFound("Version with id='%s' not found for owner '%s'", versionID, parentID)) + }, + expectedStatusCode: http.StatusNotFound, + }, + { + name: "Error 400 - empty reason", + body: `{"reason": ""}`, + setupMock: func(mock *services.MockResourceService) { + }, + expectedStatusCode: http.StatusBadRequest, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + RegisterTestingT(t) + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + handler, mockSvc := newTestVersionHandler(ctrl) + tt.setupMock(mockSvc) + + reqURL := "/api/hyperfleet/v1/channels/" + parentID + "/versions/" + versionID + "/force-delete" + req := httptest.NewRequest(http.MethodPost, reqURL, strings.NewReader(tt.body)) + req.Header.Set("Content-Type", "application/json") + req = mux.SetURLVars(req, map[string]string{"parent_id": parentID, "id": versionID}) + + rr := httptest.NewRecorder() + handler.ForceDeleteByOwner(rr, req) + + Expect(rr.Code).To(Equal(tt.expectedStatusCode)) + + if tt.expectedStatusCode == http.StatusNoContent { + Expect(rr.Body.Len()).To(Equal(0)) + } + }) + } +} diff --git a/pkg/services/resource.go b/pkg/services/resource.go index 64f03cf7..678758fe 100644 --- a/pkg/services/resource.go +++ b/pkg/services/resource.go @@ -10,6 +10,7 @@ import ( "github.com/openshift-hyperfleet/hyperfleet-api/pkg/dao" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/db" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/errors" + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/logger" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/registry" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/util" ) @@ -24,6 +25,7 @@ type ResourceService interface { List(ctx context.Context, kind string, args *ListArguments) (api.ResourceList, *api.PagingMeta, *errors.ServiceError) GetByOwner(ctx context.Context, kind, id, ownerID string) (*api.Resource, *errors.ServiceError) ListByOwner(ctx context.Context, kind, ownerID string, args *ListArguments) (api.ResourceList, *api.PagingMeta, *errors.ServiceError) // nolint:lll + ForceDelete(ctx context.Context, kind, id, reason string) *errors.ServiceError } func NewResourceService(resourceDao dao.ResourceDao, generic GenericService) ResourceService { @@ -355,3 +357,70 @@ func applyResourcePatch(resource *api.Resource, patch *api.ResourcePatch) error // via dao.ReplaceReferences per generic-resource-registry-design.md §9.2 return nil } + +func (s *sqlResourceService) ForceDelete(ctx context.Context, kind, id, reason string) *errors.ServiceError { + if svcErr := validateKind(kind); svcErr != nil { + return svcErr + } + + resource, err := s.resourceDao.GetForUpdate(ctx, kind, id) + if err != nil { + return handleGetError(kind, "id", id, err) + } + + if resource.DeletedTime == nil { + return errors.ConflictState("%s '%s' is not in Finalizing state", kind, id) + } + + caller := actorFromContext(ctx) + if svcErr := s.forceDeleteResourceTree(ctx, resource, caller, reason); svcErr != nil { + db.MarkForRollback(ctx, svcErr) + return svcErr + } + return nil +} + +func (s *sqlResourceService) forceDeleteResourceTree( + ctx context.Context, resource *api.Resource, caller, reason string, +) *errors.ServiceError { + desc := registry.MustGet(resource.Kind) + if len(desc.RequiredAdapters) > 0 { + return errors.GeneralError( + "force-delete not implemented for resources with required adapters (kind=%s)"+ + " — adapter_status cleanup needed, see HYPERFLEET-1154", + resource.Kind, + ) + } + + children := registry.ChildrenOf(resource.Kind) + + childIDs := make([]string, 0) + for _, child := range children { + items, err := s.resourceDao.FindByKindAndOwnerForUpdate(ctx, child.Kind, resource.ID) + if err != nil { + logger.With(ctx, "resource_id", resource.ID, "child_kind", child.Kind). + WithError(err).Error("Failed to find children for force-delete") + return errors.GeneralError("Unable to find %s children for force-delete", child.Kind) + } + for _, item := range items { + childIDs = append(childIDs, item.ID) + if svcErr := s.forceDeleteResourceTree(ctx, item, caller, reason); svcErr != nil { + return svcErr + } + } + } + + logger.With(ctx, + "resource_kind", resource.Kind, + "resource_id", resource.ID, + "caller", caller, + "reason", reason, + "child_resource_ids", childIDs, + ).Info("Force-deleting resource") + + if err := s.resourceDao.Delete(ctx, resource.Kind, resource.ID); err != nil { + return handleDeleteError(resource.Kind, err) + } + + return nil +} diff --git a/pkg/services/resource_test.go b/pkg/services/resource_test.go index 42126fb7..105b9c3c 100644 --- a/pkg/services/resource_test.go +++ b/pkg/services/resource_test.go @@ -17,6 +17,11 @@ import ( "github.com/openshift-hyperfleet/hyperfleet-api/pkg/registry" ) +const ( + testDeletedBy = "someone" + testChannelID = "ch-1" +) + func setupTestDescriptors() { registry.Reset() registry.Register(registry.EntityDescriptor{ @@ -562,7 +567,7 @@ func TestResourceService_Delete_AlreadyDeleted_Idempotent(t *testing.T) { now := time.Now() existing := testResource("Channel", "ch-1", "stable") existing.DeletedTime = &now - deletedBy := "someone" + deletedBy := testDeletedBy existing.DeletedBy = &deletedBy existing.Generation = 3 mockDao.addResource(existing) @@ -1119,3 +1124,240 @@ func TestResourceService_ListByOwner_UnknownKind(t *testing.T) { Expect(svcErr).ToNot(BeNil()) Expect(svcErr.HTTPCode).To(Equal(400)) } + +// --- ForceDelete --- + +func TestResourceService_ForceDelete_HappyPath_NoChildren(t *testing.T) { + RegisterTestingT(t) + setupTestDescriptors() + + mockDao := newMockResourceDao() + svc, _, _ := newTestResourceService(mockDao) + + now := time.Now() + existing := testResource("Channel", testChannelID, "stable") + existing.DeletedTime = &now + deletedBy := testDeletedBy + existing.DeletedBy = &deletedBy + mockDao.addResource(existing) + + svcErr := svc.ForceDelete(context.Background(), "Channel", testChannelID, "Stuck in finalizing") + Expect(svcErr).To(BeNil()) + + _, exists := mockDao.resources[resourceKey("Channel", testChannelID)] + Expect(exists).To(BeFalse()) +} + +func TestResourceService_ForceDelete_CascadesAllChildren(t *testing.T) { + RegisterTestingT(t) + setupTestDescriptors() + + mockDao := newMockResourceDao() + svc, _, _ := newTestResourceService(mockDao) + + now := time.Now() + channel := testResource("Channel", testChannelID, "stable") + channel.DeletedTime = &now + deletedBy := testDeletedBy + channel.DeletedBy = &deletedBy + mockDao.addResource(channel) + + chID := testChannelID + v1 := testResource("Version", "v-1", "v1.0") + v1.OwnerID = &chID + mockDao.addResource(v1) + + v2 := testResource("Version", "v-2", "v2.0") + v2.OwnerID = &chID + mockDao.addResource(v2) + + svcErr := svc.ForceDelete(context.Background(), "Channel", testChannelID, "stuck") + Expect(svcErr).To(BeNil()) + + _, chExists := mockDao.resources[resourceKey("Channel", testChannelID)] + Expect(chExists).To(BeFalse()) + _, v1Exists := mockDao.resources[resourceKey("Version", "v-1")] + Expect(v1Exists).To(BeFalse()) + _, v2Exists := mockDao.resources[resourceKey("Version", "v-2")] + Expect(v2Exists).To(BeFalse()) +} + +func TestResourceService_ForceDelete_BypassesRestrict(t *testing.T) { + RegisterTestingT(t) + setupTestDescriptors() + + mockDao := newMockResourceDao() + svc, _, _ := newTestResourceService(mockDao) + + channel := testResource("Channel", testChannelID, "stable") + mockDao.addResource(channel) + + chID := testChannelID + version := testResource("Version", "v-1", "v1.0") + version.OwnerID = &chID + mockDao.addResource(version) + + // Normal delete blocked by Restrict policy (active children) + _, normalDeleteErr := svc.Delete(context.Background(), "Channel", testChannelID) + Expect(normalDeleteErr).ToNot(BeNil()) + Expect(normalDeleteErr.HTTPCode).To(Equal(409)) + + // Simulate reaching Finalizing state (e.g., via admin override) + now := time.Now() + channel.DeletedTime = &now + deletedBy := "admin" + channel.DeletedBy = &deletedBy + + // Force-delete bypasses Restrict and cascades everything + svcErr := svc.ForceDelete(context.Background(), "Channel", testChannelID, "bypass restrict") + Expect(svcErr).To(BeNil()) + + _, chExists := mockDao.resources[resourceKey("Channel", testChannelID)] + Expect(chExists).To(BeFalse()) + _, vExists := mockDao.resources[resourceKey("Version", "v-1")] + Expect(vExists).To(BeFalse()) +} + +func TestResourceService_ForceDelete_NotInFinalizingState(t *testing.T) { + RegisterTestingT(t) + setupTestDescriptors() + + mockDao := newMockResourceDao() + svc, _, _ := newTestResourceService(mockDao) + + existing := testResource("Channel", testChannelID, "stable") + mockDao.addResource(existing) + + svcErr := svc.ForceDelete(context.Background(), "Channel", testChannelID, "some reason") + Expect(svcErr).ToNot(BeNil()) + Expect(svcErr.HTTPCode).To(Equal(409)) +} + +func TestResourceService_ForceDelete_NotFound(t *testing.T) { + RegisterTestingT(t) + setupTestDescriptors() + + mockDao := newMockResourceDao() + svc, _, _ := newTestResourceService(mockDao) + + svcErr := svc.ForceDelete(context.Background(), "Channel", "nonexistent", "some reason") + Expect(svcErr).ToNot(BeNil()) + Expect(svcErr.HTTPCode).To(Equal(404)) +} + +func TestResourceService_ForceDelete_RecursiveGrandchildren(t *testing.T) { + RegisterTestingT(t) + registry.Reset() + registry.Register(registry.EntityDescriptor{Kind: "Root", Plural: "roots"}) + registry.Register(registry.EntityDescriptor{ + Kind: "Child", Plural: "children", ParentKind: "Root", + OnParentDelete: registry.OnParentDeleteCascade, + }) + registry.Register(registry.EntityDescriptor{ + Kind: "Grandchild", Plural: "grandchildren", ParentKind: "Child", + OnParentDelete: registry.OnParentDeleteRestrict, + }) + + mockDao := newMockResourceDao() + svc, _, _ := newTestResourceService(mockDao) + + now := time.Now() + root := testResource("Root", "r-1", "root") + root.DeletedTime = &now + deletedBy := testDeletedBy + root.DeletedBy = &deletedBy + mockDao.addResource(root) + + rootID := "r-1" + child := testResource("Child", "c-1", "child") + child.OwnerID = &rootID + mockDao.addResource(child) + + childID := "c-1" + grandchild := testResource("Grandchild", "gc-1", "grandchild") + grandchild.OwnerID = &childID + mockDao.addResource(grandchild) + + svcErr := svc.ForceDelete(context.Background(), "Root", "r-1", "force all") + Expect(svcErr).To(BeNil()) + + Expect(mockDao.resources).To(HaveLen(0)) +} + +func TestResourceService_ForceDelete_RequiredAdaptersBlocked(t *testing.T) { + RegisterTestingT(t) + setupManagedDescriptor() + + mockDao := newMockResourceDao() + svc, _, _ := newTestResourceService(mockDao) + + now := time.Now() + existing := testResource("Managed", "m-1", "managed-1") + existing.DeletedTime = &now + deletedBy := testDeletedBy + existing.DeletedBy = &deletedBy + mockDao.addResource(existing) + + svcErr := svc.ForceDelete(context.Background(), "Managed", "m-1", "some reason") + Expect(svcErr).ToNot(BeNil()) + Expect(svcErr.HTTPCode).To(Equal(500)) +} + +func TestResourceService_ForceDelete_ChildWithRequiredAdapters(t *testing.T) { + RegisterTestingT(t) + registry.Reset() + registry.Register(registry.EntityDescriptor{Kind: "Parent", Plural: "parents"}) + registry.Register(registry.EntityDescriptor{ + Kind: "ManagedChild", Plural: "managedchildren", ParentKind: "Parent", + OnParentDelete: registry.OnParentDeleteCascade, + RequiredAdapters: []string{"provisioner"}, + }) + + mockDao := newMockResourceDao() + svc, _, _ := newTestResourceService(mockDao) + + now := time.Now() + parent := testResource("Parent", "p-1", "parent") + parent.DeletedTime = &now + deletedBy := testDeletedBy + parent.DeletedBy = &deletedBy + mockDao.addResource(parent) + + childID := "p-1" + child := testResource("ManagedChild", "mc-1", "managed-child") + child.OwnerID = &childID + mockDao.addResource(child) + + svcErr := svc.ForceDelete(context.Background(), "Parent", "p-1", "force it") + Expect(svcErr).ToNot(BeNil()) + Expect(svcErr.HTTPCode).To(Equal(500)) + + _, parentExists := mockDao.resources[resourceKey("Parent", "p-1")] + Expect(parentExists).To(BeTrue(), "parent should NOT be deleted when child cascade fails") + _, childExists := mockDao.resources[resourceKey("ManagedChild", "mc-1")] + Expect(childExists).To(BeTrue(), "child should NOT be deleted when guard fires") +} + +func TestResourceService_ForceDelete_InvalidKind(t *testing.T) { + RegisterTestingT(t) + setupTestDescriptors() + + mockDao := newMockResourceDao() + svc, _, _ := newTestResourceService(mockDao) + + svcErr := svc.ForceDelete(context.Background(), "Bogus", testChannelID, "some reason") + Expect(svcErr).ToNot(BeNil()) + Expect(svcErr.HTTPCode).To(Equal(400)) +} + +func TestAllGenericDescriptors_HaveNoRequiredAdapters(t *testing.T) { + RegisterTestingT(t) + setupTestDescriptors() + + for _, d := range registry.All() { + Expect(d.RequiredAdapters).To(BeEmpty(), + "Descriptor %q has RequiredAdapters=%v. "+ + "ForceDelete does not yet handle adapter_status cleanup. "+ + "See HYPERFLEET-1154.", d.Kind, d.RequiredAdapters) + } +} diff --git a/plugins/entities/plugin.go b/plugins/entities/plugin.go index 575682a6..1f27cf84 100644 --- a/plugins/entities/plugin.go +++ b/plugins/entities/plugin.go @@ -45,6 +45,7 @@ func RegisterEntityRoutes(apiV1Router *mux.Router, resourceService services.Reso r.HandleFunc("/{id}", h.Get).Methods(http.MethodGet) r.HandleFunc("/{id}", h.Patch).Methods(http.MethodPatch) r.HandleFunc("/{id}", h.Delete).Methods(http.MethodDelete) + r.HandleFunc("/{id}/force-delete", h.ForceDelete).Methods(http.MethodPost) } else { parent := registry.MustGet(descriptor.ParentKind) pr := apiV1Router.PathPrefix("/" + parent.Plural + "/{parent_id}/" + descriptor.Plural).Subrouter() @@ -53,6 +54,7 @@ func RegisterEntityRoutes(apiV1Router *mux.Router, resourceService services.Reso pr.HandleFunc("/{id}", h.GetByOwner).Methods(http.MethodGet) pr.HandleFunc("/{id}", h.PatchByOwner).Methods(http.MethodPatch) pr.HandleFunc("/{id}", h.DeleteByOwner).Methods(http.MethodDelete) + pr.HandleFunc("/{id}/force-delete", h.ForceDeleteByOwner).Methods(http.MethodPost) } } } diff --git a/test/integration/resource_force_delete_test.go b/test/integration/resource_force_delete_test.go new file mode 100644 index 00000000..95b6f096 --- /dev/null +++ b/test/integration/resource_force_delete_test.go @@ -0,0 +1,275 @@ +package integration + +import ( + "fmt" + "net/http" + "testing" + + "github.com/google/uuid" + . "github.com/onsi/gomega" + "gopkg.in/resty.v1" + + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/registry" + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/services" + "github.com/openshift-hyperfleet/hyperfleet-api/test" +) + +func createVersionForChannel( + t *testing.T, svc services.ResourceService, channelID, name string, +) { + t.Helper() + version := newVersionResource(name, channelID) + _, err := svc.Create(t.Context(), "Version", version) + if err != nil { + t.Fatalf("Failed to create version: %v", err) + } +} + +func markFinalizing(t *testing.T, h *test.Helper, resourceID string) { + t.Helper() + dbSession := h.DBFactory.New(t.Context()) + err := dbSession.Exec( + "UPDATE resources SET deleted_time = NOW(), deleted_by = 'admin' WHERE id = ?", + resourceID, + ).Error + Expect(err).ToNot(HaveOccurred()) +} + +func TestResourceForceDelete(t *testing.T) { + t.Run("ChannelWithRestrictedChildren", func(t *testing.T) { + RegisterTestingT(t) + svc, h := setupResourceTest(t) + prefix := uuid.NewString()[:8] + + channel := createChannel(t, svc, fmt.Sprintf("fd-ch-%s", prefix)) + createVersionForChannel(t, svc, channel.ID, fmt.Sprintf("fd-v1-%s", prefix)) + createVersionForChannel(t, svc, channel.ID, fmt.Sprintf("fd-v2-%s", prefix)) + + args := &services.ListArguments{Page: 1, Size: 10} + versions, _, listErr := svc.ListByOwner( + t.Context(), "Version", channel.ID, args, + ) + Expect(listErr).To(BeNil()) + Expect(versions).To(HaveLen(2)) + versionIDs := []string{versions[0].ID, versions[1].ID} + + _, deleteErr := svc.Delete(t.Context(), "Channel", channel.ID) + Expect(deleteErr).ToNot(BeNil()) + Expect(deleteErr.HTTPCode).To(Equal(409)) + + markFinalizing(t, h, channel.ID) + + forceErr := svc.ForceDelete( + t.Context(), "Channel", channel.ID, "stuck in finalizing", + ) + Expect(forceErr).To(BeNil()) + + allIDs := append([]string{channel.ID}, versionIDs...) + err := checkResourceCount(t.Context(), h, allIDs, 0) + Expect(err).ToNot(HaveOccurred()) + }) + + t.Run("ChannelNoChildren", func(t *testing.T) { + RegisterTestingT(t) + svc, h := setupResourceTest(t) + prefix := uuid.NewString()[:8] + + channel := createChannel(t, svc, fmt.Sprintf("fd-solo-%s", prefix)) + markFinalizing(t, h, channel.ID) + + forceErr := svc.ForceDelete( + t.Context(), "Channel", channel.ID, "cleanup", + ) + Expect(forceErr).To(BeNil()) + + err := checkResourceCount(t.Context(), h, []string{channel.ID}, 0) + Expect(err).ToNot(HaveOccurred()) + }) + + t.Run("NotInFinalizingState_Returns409", func(t *testing.T) { + RegisterTestingT(t) + svc, _ := setupResourceTest(t) + prefix := uuid.NewString()[:8] + + channel := createChannel(t, svc, fmt.Sprintf("fd-active-%s", prefix)) + + forceErr := svc.ForceDelete( + t.Context(), "Channel", channel.ID, "should fail", + ) + Expect(forceErr).ToNot(BeNil()) + Expect(forceErr.HTTPCode).To(Equal(409)) + }) + + t.Run("NotFound_Returns404", func(t *testing.T) { + RegisterTestingT(t) + svc, _ := setupResourceTest(t) + + forceErr := svc.ForceDelete( + t.Context(), "Channel", "nonexistent-id", "should fail", + ) + Expect(forceErr).ToNot(BeNil()) + Expect(forceErr.HTTPCode).To(Equal(404)) + }) + + t.Run("NestedVersionForceDelete", func(t *testing.T) { + RegisterTestingT(t) + svc, h := setupResourceTest(t) + prefix := uuid.NewString()[:8] + + channel := createChannel(t, svc, fmt.Sprintf("fd-parent-%s", prefix)) + versionName := fmt.Sprintf("fd-ver-%s", prefix) + createVersionForChannel(t, svc, channel.ID, versionName) + + args := &services.ListArguments{Page: 1, Size: 10} + versions, _, listErr := svc.ListByOwner( + t.Context(), "Version", channel.ID, args, + ) + Expect(listErr).To(BeNil()) + Expect(versions).To(HaveLen(1)) + versionID := versions[0].ID + + markFinalizing(t, h, versionID) + + forceErr := svc.ForceDelete( + t.Context(), "Version", versionID, "cleanup version", + ) + Expect(forceErr).To(BeNil()) + + err := checkResourceCount(t.Context(), h, []string{versionID}, 0) + Expect(err).ToNot(HaveOccurred()) + + err = checkResourceCount(t.Context(), h, []string{channel.ID}, 1) + Expect(err).ToNot(HaveOccurred()) + }) +} + +func TestResourceForceDeleteHTTP(t *testing.T) { + t.Run("HappyPath_204", func(t *testing.T) { + RegisterTestingT(t) + h, _ := test.RegisterIntegration(t) + svc, _ := setupResourceTest(t) + prefix := uuid.NewString()[:8] + + account := h.NewRandAccount() + ctx := h.NewAuthenticatedContext(account) + token := test.GetAccessTokenFromContext(ctx) + + channel := createChannel(t, svc, fmt.Sprintf("fd-http-%s", prefix)) + markFinalizing(t, h, channel.ID) + + resp, err := resty.R(). + SetHeader("Content-Type", "application/json"). + SetHeader("Authorization", fmt.Sprintf("Bearer %s", token)). + SetBody(`{"reason": "admin cleanup"}`). + Post(h.RestURL(fmt.Sprintf("/channels/%s/force-delete", channel.ID))) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.StatusCode()).To(Equal(http.StatusNoContent)) + + err = checkResourceCount(t.Context(), h, []string{channel.ID}, 0) + Expect(err).ToNot(HaveOccurred()) + }) + + t.Run("NestedVersion_204", func(t *testing.T) { + RegisterTestingT(t) + h, _ := test.RegisterIntegration(t) + svc, _ := setupResourceTest(t) + prefix := uuid.NewString()[:8] + + account := h.NewRandAccount() + ctx := h.NewAuthenticatedContext(account) + token := test.GetAccessTokenFromContext(ctx) + + channel := createChannel(t, svc, fmt.Sprintf("fd-nested-%s", prefix)) + createVersionForChannel(t, svc, channel.ID, fmt.Sprintf("fd-nv-%s", prefix)) + + args := &services.ListArguments{Page: 1, Size: 10} + versions, _, listErr := svc.ListByOwner( + t.Context(), "Version", channel.ID, args, + ) + Expect(listErr).To(BeNil()) + Expect(versions).To(HaveLen(1)) + versionID := versions[0].ID + + markFinalizing(t, h, versionID) + + url := fmt.Sprintf( + "/channels/%s/versions/%s/force-delete", + channel.ID, versionID, + ) + resp, err := resty.R(). + SetHeader("Content-Type", "application/json"). + SetHeader("Authorization", fmt.Sprintf("Bearer %s", token)). + SetBody(`{"reason": "nested cleanup"}`). + Post(h.RestURL(url)) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.StatusCode()).To(Equal(http.StatusNoContent)) + + err = checkResourceCount(t.Context(), h, []string{versionID}, 0) + Expect(err).ToNot(HaveOccurred()) + + err = checkResourceCount(t.Context(), h, []string{channel.ID}, 1) + Expect(err).ToNot(HaveOccurred()) + }) + + t.Run("WifConfig_204", func(t *testing.T) { + RegisterTestingT(t) + h, _ := test.RegisterIntegration(t) + svc, _ := setupResourceTest(t) + prefix := uuid.NewString()[:8] + + account := h.NewRandAccount() + ctx := h.NewAuthenticatedContext(account) + token := test.GetAccessTokenFromContext(ctx) + + wifConfig := newWifConfigResource(fmt.Sprintf("fd-wif-%s", prefix)) + created, createErr := svc.Create(t.Context(), "WifConfig", wifConfig) + Expect(createErr).To(BeNil()) + + markFinalizing(t, h, created.ID) + + resp, err := resty.R(). + SetHeader("Content-Type", "application/json"). + SetHeader("Authorization", fmt.Sprintf("Bearer %s", token)). + SetBody(`{"reason": "wifconfig cleanup"}`). + Post(h.RestURL(fmt.Sprintf("/wifconfigs/%s/force-delete", created.ID))) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.StatusCode()).To(Equal(http.StatusNoContent)) + + err = checkResourceCount(t.Context(), h, []string{created.ID}, 0) + Expect(err).ToNot(HaveOccurred()) + }) + + t.Run("EmptyReason_400", func(t *testing.T) { + RegisterTestingT(t) + h, _ := test.RegisterIntegration(t) + svc, _ := setupResourceTest(t) + prefix := uuid.NewString()[:8] + + account := h.NewRandAccount() + ctx := h.NewAuthenticatedContext(account) + token := test.GetAccessTokenFromContext(ctx) + + channel := createChannel(t, svc, fmt.Sprintf("fd-noreason-%s", prefix)) + markFinalizing(t, h, channel.ID) + + resp, err := resty.R(). + SetHeader("Content-Type", "application/json"). + SetHeader("Authorization", fmt.Sprintf("Bearer %s", token)). + SetBody(`{"reason": ""}`). + Post(h.RestURL(fmt.Sprintf("/channels/%s/force-delete", channel.ID))) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.StatusCode()).To(Equal(http.StatusBadRequest)) + }) +} + +func TestGenericDescriptors_HaveNoRequiredAdapters(t *testing.T) { + RegisterTestingT(t) + _, _ = setupResourceTest(t) + + for _, d := range registry.All() { + Expect(d.RequiredAdapters).To(BeEmpty(), + "Descriptor %q has RequiredAdapters=%v. "+ + "ForceDelete does not yet handle adapter_status cleanup. "+ + "See HYPERFLEET-1154.", d.Kind, d.RequiredAdapters) + } +} diff --git a/test/integration/resource_helpers.go b/test/integration/resource_helpers.go index c8ed12ed..aa8cb1c9 100644 --- a/test/integration/resource_helpers.go +++ b/test/integration/resource_helpers.go @@ -58,3 +58,13 @@ func newVersionResource(name, channelID string) *api.Resource { UpdatedBy: "test@example.com", } } + +func newWifConfigResource(name string) *api.Resource { + return &api.Resource{ + Kind: "WifConfig", + Name: name, + Spec: []byte(`{"project_id": "test-project", "pool_id": "test-pool"}`), + CreatedBy: "test@example.com", + UpdatedBy: "test@example.com", + } +}