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..89ee9f82 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,68 @@ 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 { + return errors.GeneralError("Unable to find %s children for force-delete: %s", child.Kind, err) + } + 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..77e4822e 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,205 @@ 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_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/channels/plugin.go b/plugins/channels/plugin.go index 681f2e84..e1229e50 100644 --- a/plugins/channels/plugin.go +++ b/plugins/channels/plugin.go @@ -40,5 +40,6 @@ func init() { 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) }) } diff --git a/plugins/versions/plugin.go b/plugins/versions/plugin.go index c3852ee7..56659e3d 100644 --- a/plugins/versions/plugin.go +++ b/plugins/versions/plugin.go @@ -43,5 +43,6 @@ func init() { r.HandleFunc("/{id}", h.GetByOwner).Methods(http.MethodGet) r.HandleFunc("/{id}", h.PatchByOwner).Methods(http.MethodPatch) r.HandleFunc("/{id}", h.DeleteByOwner).Methods(http.MethodDelete) + r.HandleFunc("/{id}/force-delete", h.ForceDeleteByOwner).Methods(http.MethodPost) }) } diff --git a/plugins/wifconfigs/plugin.go b/plugins/wifconfigs/plugin.go index 8a46d740..34b8e4ec 100644 --- a/plugins/wifconfigs/plugin.go +++ b/plugins/wifconfigs/plugin.go @@ -40,5 +40,6 @@ func init() { 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) }) } diff --git a/test/integration/resource_force_delete_test.go b/test/integration/resource_force_delete_test.go new file mode 100644 index 00000000..ad24fe1b --- /dev/null +++ b/test/integration/resource_force_delete_test.go @@ -0,0 +1,158 @@ +package integration + +import ( + "fmt" + "testing" + + "github.com/google/uuid" + . "github.com/onsi/gomega" + + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/registry" + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/services" +) + +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 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)) + + // Capture version IDs to verify cascade + 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} + + // Normal delete blocked by Restrict policy (active children) + _, deleteErr := svc.Delete(t.Context(), "Channel", channel.ID) + Expect(deleteErr).ToNot(BeNil()) + Expect(deleteErr.HTTPCode).To(Equal(409)) + + // Simulate Finalizing state via direct DB update + dbSession := h.DBFactory.New(t.Context()) + err := dbSession.Exec( + "UPDATE resources SET deleted_time = NOW(), deleted_by = 'admin' WHERE id = ?", + channel.ID, + ).Error + Expect(err).ToNot(HaveOccurred()) + + // Force-delete succeeds — bypasses Restrict, cascades children + forceErr := svc.ForceDelete(t.Context(), "Channel", channel.ID, "stuck in finalizing") + Expect(forceErr).To(BeNil()) + + // Channel and all children gone from DB + 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)) + + // Simulate Finalizing state via direct DB update + // (channels have no RequiredAdapters, so normal Delete hard-deletes immediately) + dbSession := h.DBFactory.New(t.Context()) + err := dbSession.Exec( + "UPDATE resources SET deleted_time = NOW(), deleted_by = 'admin' WHERE id = ?", + channel.ID, + ).Error + Expect(err).ToNot(HaveOccurred()) + + // Force-delete succeeds + forceErr := svc.ForceDelete(t.Context(), "Channel", channel.ID, "cleanup") + Expect(forceErr).To(BeNil()) + + // Resource gone from DB + 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) + + // Find the version + 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 + + // Simulate Finalizing state via direct DB update + dbSession := h.DBFactory.New(t.Context()) + err := dbSession.Exec("UPDATE resources SET deleted_time = NOW(), deleted_by = 'admin' WHERE id = ?", versionID).Error + Expect(err).ToNot(HaveOccurred()) + + // Force-delete the version + forceErr := svc.ForceDelete(t.Context(), "Version", versionID, "cleanup version") + Expect(forceErr).To(BeNil()) + + // Version gone from DB + err = checkResourceCount(t.Context(), h, []string{versionID}, 0) + Expect(err).ToNot(HaveOccurred()) + + // Parent channel still exists + err = checkResourceCount(t.Context(), h, []string{channel.ID}, 1) + Expect(err).ToNot(HaveOccurred()) + }) +} + +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) + } +}