diff --git a/core/deleter/mocks/resource_service.go b/core/deleter/mocks/resource_service.go index 98883140b..f9642c721 100644 --- a/core/deleter/mocks/resource_service.go +++ b/core/deleter/mocks/resource_service.go @@ -129,54 +129,6 @@ func (_c *ResourceService_List_Call) RunAndReturn(run func(context.Context, reso return _c } -// Purge provides a mock function with given fields: ctx, namespaceID, id -func (_m *ResourceService) Purge(ctx context.Context, namespaceID string, id string) error { - ret := _m.Called(ctx, namespaceID, id) - - if len(ret) == 0 { - panic("no return value specified for Purge") - } - - var r0 error - if rf, ok := ret.Get(0).(func(context.Context, string, string) error); ok { - r0 = rf(ctx, namespaceID, id) - } else { - r0 = ret.Error(0) - } - - return r0 -} - -// ResourceService_Purge_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Purge' -type ResourceService_Purge_Call struct { - *mock.Call -} - -// Purge is a helper method to define mock.On call -// - ctx context.Context -// - namespaceID string -// - id string -func (_e *ResourceService_Expecter) Purge(ctx interface{}, namespaceID interface{}, id interface{}) *ResourceService_Purge_Call { - return &ResourceService_Purge_Call{Call: _e.mock.On("Purge", ctx, namespaceID, id)} -} - -func (_c *ResourceService_Purge_Call) Run(run func(ctx context.Context, namespaceID string, id string)) *ResourceService_Purge_Call { - _c.Call.Run(func(args mock.Arguments) { - run(args[0].(context.Context), args[1].(string), args[2].(string)) - }) - return _c -} - -func (_c *ResourceService_Purge_Call) Return(_a0 error) *ResourceService_Purge_Call { - _c.Call.Return(_a0) - return _c -} - -func (_c *ResourceService_Purge_Call) RunAndReturn(run func(context.Context, string, string) error) *ResourceService_Purge_Call { - _c.Call.Return(run) - return _c -} - // NewResourceService creates a new instance of ResourceService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. // The first argument is typically a *testing.T value. func NewResourceService(t interface { diff --git a/core/deleter/service.go b/core/deleter/service.go index f1fad86b9..0f8ee733c 100644 --- a/core/deleter/service.go +++ b/core/deleter/service.go @@ -72,7 +72,6 @@ type PolicyService interface { type ResourceService interface { List(ctx context.Context, flt resource.Filter) ([]resource.Resource, error) Delete(ctx context.Context, namespaceID, id string) error - Purge(ctx context.Context, namespaceID, id string) error } type GroupService interface { @@ -254,16 +253,15 @@ func (d Service) deleteProject(ctx context.Context, prj project.Project) error { } } - // TODO(fix): soft-delete the live resources instead of purging every row - resources, err := d.resService.List(ctx, resource.Filter{ - ProjectID: prj.ID, - IncludeDeleted: true, - }) + resources, err := d.resService.List(ctx, resource.Filter{ProjectID: prj.ID}) if err != nil { return err } for _, r := range resources { - if err = d.resService.Purge(ctx, r.NamespaceID, r.ID); err != nil { + if err = d.resService.Delete(ctx, r.NamespaceID, r.ID); err != nil { + if errors.Is(err, resource.ErrNotExist) { + continue + } return fmt.Errorf("failed to delete project while deleting a resource[%s]: %w", r.Name, err) } } diff --git a/core/deleter/service_test.go b/core/deleter/service_test.go index 1ed04f442..268bacd89 100644 --- a/core/deleter/service_test.go +++ b/core/deleter/service_test.go @@ -142,9 +142,9 @@ func TestDeleteProject(t *testing.T) { m.polSvc.EXPECT().Delete(mock.Anything, "pol-2"). Run(func(_ context.Context, id string) { steps = append(steps, "policy:"+id) }).Return(nil) - m.resSvc.EXPECT().List(mock.Anything, resource.Filter{ProjectID: projectID, IncludeDeleted: true}). + m.resSvc.EXPECT().List(mock.Anything, resource.Filter{ProjectID: projectID}). Return([]resource.Resource{{ID: "res-1", NamespaceID: "ns-1", Name: "r1"}}, nil) - m.resSvc.EXPECT().Purge(mock.Anything, "ns-1", "res-1"). + m.resSvc.EXPECT().Delete(mock.Anything, "ns-1", "res-1"). Run(func(_ context.Context, _ string, id string) { steps = append(steps, "resource:"+id) }).Return(nil) m.projSvc.EXPECT().DeleteModel(mock.Anything, projectID). @@ -161,7 +161,7 @@ func TestDeleteProject(t *testing.T) { m.polSvc.EXPECT().List(mock.Anything, policy.Filter{ProjectID: projectID}). Return([]policy.Policy{}, nil) - m.resSvc.EXPECT().List(mock.Anything, resource.Filter{ProjectID: projectID, IncludeDeleted: true}). + m.resSvc.EXPECT().List(mock.Anything, resource.Filter{ProjectID: projectID}). Return([]resource.Resource{}, nil) m.projSvc.EXPECT().DeleteModel(mock.Anything, projectID).Return(project.ErrNotExist) @@ -169,6 +169,21 @@ func TestDeleteProject(t *testing.T) { assert.NoError(t, err) }) + t.Run("a resource that is already gone does not stop the delete", func(t *testing.T) { + m := newMocks(t) + expectProject(m) + + m.polSvc.EXPECT().List(mock.Anything, policy.Filter{ProjectID: projectID}). + Return([]policy.Policy{}, nil) + m.resSvc.EXPECT().List(mock.Anything, resource.Filter{ProjectID: projectID}). + Return([]resource.Resource{{ID: "res-1", NamespaceID: "ns-1", Name: "r1"}}, nil) + m.resSvc.EXPECT().Delete(mock.Anything, "ns-1", "res-1").Return(resource.ErrNotExist) + m.projSvc.EXPECT().DeleteModel(mock.Anything, projectID).Return(nil) + + err := m.build().DeleteProject(context.Background(), projectID) + assert.NoError(t, err) + }) + t.Run("returns error when policy list fails", func(t *testing.T) { m := newMocks(t) expectProject(m) @@ -199,7 +214,7 @@ func TestDeleteProject(t *testing.T) { m.polSvc.EXPECT().List(mock.Anything, policy.Filter{ProjectID: projectID}). Return([]policy.Policy{{ID: "pol-gone"}}, nil) m.polSvc.EXPECT().Delete(mock.Anything, "pol-gone").Return(policy.ErrNotExist) - m.resSvc.EXPECT().List(mock.Anything, resource.Filter{ProjectID: projectID, IncludeDeleted: true}). + m.resSvc.EXPECT().List(mock.Anything, resource.Filter{ProjectID: projectID}). Return([]resource.Resource{}, nil) m.projSvc.EXPECT().DeleteModel(mock.Anything, projectID).Return(nil) @@ -213,7 +228,7 @@ func TestDeleteProject(t *testing.T) { m.polSvc.EXPECT().List(mock.Anything, policy.Filter{ProjectID: projectID}). Return([]policy.Policy{}, nil) - m.resSvc.EXPECT().List(mock.Anything, resource.Filter{ProjectID: projectID, IncludeDeleted: true}). + m.resSvc.EXPECT().List(mock.Anything, resource.Filter{ProjectID: projectID}). Return([]resource.Resource{}, nil) m.projSvc.EXPECT().DeleteModel(mock.Anything, projectID).Return(nil) @@ -319,7 +334,7 @@ func TestDeleteOrganization(t *testing.T) { Return([]project.Project{{ID: "proj-1", Name: "p1"}}, nil) m.polSvc.EXPECT().List(mock.Anything, policy.Filter{ProjectID: "proj-1"}). Return([]policy.Policy{}, nil) - m.resSvc.EXPECT().List(mock.Anything, resource.Filter{ProjectID: "proj-1", IncludeDeleted: true}). + m.resSvc.EXPECT().List(mock.Anything, resource.Filter{ProjectID: "proj-1"}). Return([]resource.Resource{}, nil) m.projSvc.EXPECT().DeleteModel(mock.Anything, "proj-1").Return(nil) @@ -403,7 +418,7 @@ func TestDeleteOrganization(t *testing.T) { for _, id := range []string{"proj-on", "proj-off"} { m.polSvc.EXPECT().List(mock.Anything, policy.Filter{ProjectID: id}). Return([]policy.Policy{}, nil) - m.resSvc.EXPECT().List(mock.Anything, resource.Filter{ProjectID: id, IncludeDeleted: true}). + m.resSvc.EXPECT().List(mock.Anything, resource.Filter{ProjectID: id}). Return([]resource.Resource{}, nil) m.projSvc.EXPECT().DeleteModel(mock.Anything, id). Run(func(_ context.Context, id string) { steps = append(steps, "project:"+id) }).Return(nil) @@ -441,9 +456,9 @@ func TestDeleteOrganization(t *testing.T) { Return([]project.Project{{ID: "proj-1", Name: "p1"}}, nil) m.polSvc.EXPECT().List(mock.Anything, policy.Filter{ProjectID: "proj-1"}). Return([]policy.Policy{}, nil) - m.resSvc.EXPECT().List(mock.Anything, resource.Filter{ProjectID: "proj-1", IncludeDeleted: true}). + m.resSvc.EXPECT().List(mock.Anything, resource.Filter{ProjectID: "proj-1"}). Return([]resource.Resource{{ID: "m1", NamespaceID: "compute/machine", Name: "machine-1"}}, nil) - m.resSvc.EXPECT().Purge(mock.Anything, "compute/machine", "m1").Return(nil) + m.resSvc.EXPECT().Delete(mock.Anything, "compute/machine", "m1").Return(nil) m.projSvc.EXPECT().DeleteModel(mock.Anything, "proj-1"). Run(func(_ context.Context, id string) { steps = append(steps, "project:"+id) }).Return(nil) m.resSvc.EXPECT().List(mock.Anything, resource.Filter{OrgID: "org-1"}). @@ -470,7 +485,7 @@ func TestDeleteOrganization(t *testing.T) { Return([]project.Project{{ID: "proj-on", Name: "on"}}, nil) m.polSvc.EXPECT().List(mock.Anything, policy.Filter{ProjectID: "proj-on"}). Return([]policy.Policy{}, nil) - m.resSvc.EXPECT().List(mock.Anything, resource.Filter{ProjectID: "proj-on", IncludeDeleted: true}). + m.resSvc.EXPECT().List(mock.Anything, resource.Filter{ProjectID: "proj-on"}). Return([]resource.Resource{}, nil) m.projSvc.EXPECT().DeleteModel(mock.Anything, "proj-on"). Run(func(_ context.Context, id string) { steps = append(steps, "project:"+id) }).Return(nil) @@ -500,7 +515,7 @@ func TestDeleteOrganization(t *testing.T) { Return([]project.Project{{ID: "proj-off", Name: "off", State: project.Disabled}}, nil) m.polSvc.EXPECT().List(mock.Anything, policy.Filter{ProjectID: "proj-off"}). Return([]policy.Policy{}, nil) - m.resSvc.EXPECT().List(mock.Anything, resource.Filter{ProjectID: "proj-off", IncludeDeleted: true}). + m.resSvc.EXPECT().List(mock.Anything, resource.Filter{ProjectID: "proj-off"}). Return([]resource.Resource{}, nil) m.projSvc.EXPECT().DeleteModel(mock.Anything, "proj-off").Return(errors.New("boom")) m.roleSvc.EXPECT().Get(mock.Anything, schema.RoleOrganizationOwner). diff --git a/core/resource/filter.go b/core/resource/filter.go index 57d9b1d39..c769b0772 100644 --- a/core/resource/filter.go +++ b/core/resource/filter.go @@ -6,7 +6,4 @@ type Filter struct { ServiceUserID string NamespaceID string OrgID string - // IncludeDeleted also returns soft-deleted rows. - // TODO(fix): remove once the project delete cascade no longer purges - IncludeDeleted bool } diff --git a/core/resource/mocks/repository.go b/core/resource/mocks/repository.go index 533d6f8d0..32cd3b036 100644 --- a/core/resource/mocks/repository.go +++ b/core/resource/mocks/repository.go @@ -299,53 +299,6 @@ func (_c *Repository_List_Call) RunAndReturn(run func(context.Context, resource. return _c } -// Purge provides a mock function with given fields: ctx, id -func (_m *Repository) Purge(ctx context.Context, id string) error { - ret := _m.Called(ctx, id) - - if len(ret) == 0 { - panic("no return value specified for Purge") - } - - var r0 error - if rf, ok := ret.Get(0).(func(context.Context, string) error); ok { - r0 = rf(ctx, id) - } else { - r0 = ret.Error(0) - } - - return r0 -} - -// Repository_Purge_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Purge' -type Repository_Purge_Call struct { - *mock.Call -} - -// Purge is a helper method to define mock.On call -// - ctx context.Context -// - id string -func (_e *Repository_Expecter) Purge(ctx interface{}, id interface{}) *Repository_Purge_Call { - return &Repository_Purge_Call{Call: _e.mock.On("Purge", ctx, id)} -} - -func (_c *Repository_Purge_Call) Run(run func(ctx context.Context, id string)) *Repository_Purge_Call { - _c.Call.Run(func(args mock.Arguments) { - run(args[0].(context.Context), args[1].(string)) - }) - return _c -} - -func (_c *Repository_Purge_Call) Return(_a0 error) *Repository_Purge_Call { - _c.Call.Return(_a0) - return _c -} - -func (_c *Repository_Purge_Call) RunAndReturn(run func(context.Context, string) error) *Repository_Purge_Call { - _c.Call.Return(run) - return _c -} - // Update provides a mock function with given fields: ctx, _a1 func (_m *Repository) Update(ctx context.Context, _a1 resource.Resource) (resource.Resource, error) { ret := _m.Called(ctx, _a1) diff --git a/core/resource/resource.go b/core/resource/resource.go index ca33e7a26..1fa69825a 100644 --- a/core/resource/resource.go +++ b/core/resource/resource.go @@ -17,7 +17,6 @@ type Repository interface { List(ctx context.Context, flt Filter) ([]Resource, error) Update(ctx context.Context, resource Resource) (Resource, error) Delete(ctx context.Context, id string) error - Purge(ctx context.Context, id string) error } type Resource struct { diff --git a/core/resource/service.go b/core/resource/service.go index b2274e506..997c8ac4b 100644 --- a/core/resource/service.go +++ b/core/resource/service.go @@ -537,21 +537,6 @@ func (s Service) deletePolicies(ctx context.Context, res Resource) error { return nil } -// Purge removes the resource and its SpiceDB tuples for good. Only the project -// delete cascade uses it; the API uses Delete, which keeps the row. -// TODO(fix): remove once the project delete cascade soft-deletes resources -func (s Service) Purge(ctx context.Context, namespaceID, id string) error { - if err := s.relationService.Delete(ctx, relation.Relation{ - Object: relation.Object{ - ID: id, - Namespace: namespaceID, - }, - }); err != nil && !errors.Is(err, relation.ErrNotExist) { - return err - } - return s.repository.Purge(ctx, id) -} - func (s Service) RemovePrincipalAccess(ctx context.Context, principalID, principalType, orgID string, projectIDs []string) error { var scopes []Filter if orgID != "" { diff --git a/core/resource/service_test.go b/core/resource/service_test.go index 9746ccba1..97b599439 100644 --- a/core/resource/service_test.go +++ b/core/resource/service_test.go @@ -868,39 +868,6 @@ func TestDelete(t *testing.T) { }) } -func TestPurge(t *testing.T) { - t.Run("removes relations then the row", func(t *testing.T) { - repo, relationSvc, _, _, _, _, _, _, svc := newTestService(t) - - relationSvc.EXPECT().Delete(mock.Anything, relation.Relation{ - Object: relation.Object{ID: "r1", Namespace: "resource/item"}, - }).Return(nil) - repo.EXPECT().Purge(mock.Anything, "r1").Return(nil) - - err := svc.Purge(context.Background(), "resource/item", "r1") - assert.NoError(t, err) - }) - - t.Run("ignores relation not exist error", func(t *testing.T) { - repo, relationSvc, _, _, _, _, _, _, svc := newTestService(t) - - relationSvc.EXPECT().Delete(mock.Anything, mock.Anything).Return(relation.ErrNotExist) - repo.EXPECT().Purge(mock.Anything, "r1").Return(nil) - - err := svc.Purge(context.Background(), "resource/item", "r1") - assert.NoError(t, err) - }) - - t.Run("returns relation delete error", func(t *testing.T) { - _, relationSvc, _, _, _, _, _, _, svc := newTestService(t) - - relationSvc.EXPECT().Delete(mock.Anything, mock.Anything).Return(errors.New("spicedb down")) - - err := svc.Purge(context.Background(), "resource/item", "r1") - assert.ErrorContains(t, err, "spicedb down") - }) -} - func TestAddResourceOwner(t *testing.T) { t.Run("creates owner relation with user principal", func(t *testing.T) { _, relationSvc, _, _, _, _, _, _, svc := newTestService(t) diff --git a/internal/store/postgres/resource_repository.go b/internal/store/postgres/resource_repository.go index e0925ba6b..2ee3c5b61 100644 --- a/internal/store/postgres/resource_repository.go +++ b/internal/store/postgres/resource_repository.go @@ -113,11 +113,7 @@ func (r ResourceRepository) Create(ctx context.Context, res resource.Resource) ( func (r ResourceRepository) List(ctx context.Context, flt resource.Filter) ([]resource.Resource, error) { var fetchedResources []Resource - sqlStatement := fromLive(TABLE_RESOURCES) - if flt.IncludeDeleted { - sqlStatement = dialect.From(TABLE_RESOURCES) - } - sqlStatement = sqlStatement.Order(goqu.C("created_at").Asc()) + sqlStatement := fromLive(TABLE_RESOURCES).Order(goqu.C("created_at").Asc()) if flt.ProjectID != "" { sqlStatement = sqlStatement.Where(goqu.Ex{"project_id": flt.ProjectID}) } @@ -285,24 +281,3 @@ func (r ResourceRepository) Delete(ctx context.Context, id string) error { } return nil } - -// Purge removes the row for good. Only the project delete cascade uses it. -// TODO(fix): remove once the project delete cascade soft-deletes resources -func (r ResourceRepository) Purge(ctx context.Context, id string) error { - query, params, err := dialect.Delete(TABLE_RESOURCES).Where( - goqu.Ex{ - "id": id, - }, - ).ToSQL() - if err != nil { - return fmt.Errorf("%w: %s", errQuery, err) - } - - if err = r.dbc.WithTimeout(ctx, TABLE_RESOURCES, "Purge", func(ctx context.Context) error { - _, err := r.dbc.DB.ExecContext(ctx, query, params...) - return err - }); err != nil { - return checkPostgresError(err) - } - return nil -} diff --git a/internal/store/postgres/resource_repository_test.go b/internal/store/postgres/resource_repository_test.go index 01420c210..0a930c313 100644 --- a/internal/store/postgres/resource_repository_test.go +++ b/internal/store/postgres/resource_repository_test.go @@ -636,21 +636,6 @@ func (s *ResourceRepositoryTestSuite) TestDelete() { s.Assert().ErrorIs(err, resource.ErrNotExist) } -func (s *ResourceRepositoryTestSuite) TestPurge() { - target := s.resources[0] - - err := s.repository.Purge(s.ctx, target.ID) - s.Require().NoError(err) - - var rows int - err = s.client.QueryRowxContext(s.ctx, "SELECT count(*) FROM resources WHERE id = $1", target.ID).Scan(&rows) - s.Require().NoError(err) - s.Assert().Equal(0, rows) - - err = s.repository.Purge(s.ctx, utils.NewString()) - s.Assert().NoError(err) -} - func TestResourceRepository(t *testing.T) { suite.Run(t, new(ResourceRepositoryTestSuite)) } @@ -678,10 +663,6 @@ func (s *ResourceRepositoryTestSuite) TestSkipsSoftDeletedResources() { s.Assert().NotEqual(deleted.ID, r.ID) } - all, err := s.repository.List(s.ctx, resource.Filter{IncludeDeleted: true}) - s.Assert().NoError(err) - s.Assert().Len(all, len(before)) - updated := deleted updated.Title = "changed" _, err = s.repository.Update(s.ctx, updated) diff --git a/test/e2e/regression/api_test.go b/test/e2e/regression/api_test.go index dac39a7e3..157232edb 100644 --- a/test/e2e/regression/api_test.go +++ b/test/e2e/regression/api_test.go @@ -47,6 +47,7 @@ const ( fixturesDir = "testdata" computeOrderNamespace = "compute/order" computeDiskNamespace = "compute/disk" + computeVolumeNamespace = "compute/volume" computeViewerRoleName = "compute_order_viewer" computeManagerRoleName = "compute_order_manager" ) @@ -105,7 +106,8 @@ func (s *APIRegressionTestSuite) SetupSuite() { Deletion: deleter.Config{ Enabled: true, Resources: map[string]deleter.Rules{ - computeOrderNamespace: {deleter.ParentProject: deleter.RuleBlock, deleter.ParentOrganization: deleter.RuleAllow}, + computeOrderNamespace: {deleter.ParentProject: deleter.RuleBlock, deleter.ParentOrganization: deleter.RuleAllow}, + computeVolumeNamespace: {deleter.ParentProject: deleter.RuleAllow, deleter.ParentOrganization: deleter.RuleAllow}, }, }, }, @@ -128,6 +130,11 @@ func (s *APIRegressionTestSuite) SetupSuite() { s.Require().NoError(err) s.adminCookie = adminCookie + _, err = s.testBench.AdminClient.CreatePermission(testbench.ContextWithAuth(ctx, adminCookie), connect.NewRequest(&frontierv1beta1.CreatePermissionRequest{ + Bodies: []*frontierv1beta1.PermissionRequestBody{{Key: "compute.volume.get"}}, + })) + s.Require().NoError(err) + s.Require().NoError(testbench.BootstrapUsers(ctx, s.testBench.Client, adminCookie)) s.Require().NoError(testbench.BootstrapOrganizations(ctx, s.testBench.Client, adminCookie)) s.Require().NoError(testbench.BootstrapProject(ctx, s.testBench.Client, adminCookie)) @@ -3799,6 +3806,13 @@ func (s *APIRegressionTestSuite) TestOrganizationDeleteAlsoDeletesDisabledProjec enabledGroupID := createGroup("enabled-group") disabledGroupID := createGroup("disabled-group") + resourceResp, err := s.testBench.Client.CreateProjectResource(ctxOrgAdminAuth, connect.NewRequest(&frontierv1beta1.CreateProjectResourceRequest{ + ProjectId: disabledProjectID, + Body: &frontierv1beta1.ResourceRequestBody{Name: "disabled-project-order", Namespace: computeOrderNamespace}, + })) + s.Require().NoError(err) + disabledProjectResourceID := resourceResp.Msg.GetResource().GetId() + _, err = s.testBench.Client.DisableProject(ctxOrgAdminAuth, connect.NewRequest(&frontierv1beta1.DisableProjectRequest{Id: disabledProjectID})) s.Require().NoError(err) _, err = s.testBench.Client.DisableGroup(ctxOrgAdminAuth, connect.NewRequest(&frontierv1beta1.DisableGroupRequest{Id: disabledGroupID})) @@ -3883,6 +3897,16 @@ func (s *APIRegressionTestSuite) TestOrganizationDeleteAlsoDeletesDisabledProjec } }) + s.Run("the disabled project and its resource stay in the database marked deleted", func() { + var deletedProjects, deletedResources int + s.Require().NoError(s.dbc.GetContext(context.Background(), &deletedProjects, + "SELECT count(*) FROM projects WHERE id = $1 AND deleted_at IS NOT NULL", disabledProjectID)) + s.Require().NoError(s.dbc.GetContext(context.Background(), &deletedResources, + "SELECT count(*) FROM resources WHERE id = $1 AND deleted_at IS NOT NULL", disabledProjectResourceID)) + s.Assert().Equal(1, deletedProjects) + s.Assert().Equal(1, deletedResources) + }) + s.Run("the org is gone, a second delete is refused", func() { _, err := s.testBench.Client.DeleteOrganization(ctxOrgAdminAuth, connect.NewRequest(&frontierv1beta1.DeleteOrganizationRequest{Id: orgID})) s.Require().Error(err) @@ -3913,6 +3937,13 @@ func (s *APIRegressionTestSuite) TestProjectDeleteKeepsTheRowAndRemovesAccess() projectID := createProjectResp.Msg.GetProject().GetId() projectResource := schema.JoinNamespaceAndResourceID(schema.ProjectNamespace, projectID) + createResourceResp, err := s.testBench.Client.CreateProjectResource(ctxOrgAdminAuth, connect.NewRequest(&frontierv1beta1.CreateProjectResourceRequest{ + ProjectId: projectID, + Body: &frontierv1beta1.ResourceRequestBody{Name: "project-soft-delete-volume", Namespace: computeVolumeNamespace}, + })) + s.Require().NoError(err) + resourceID := createResourceResp.Msg.GetResource().GetId() + createUser := func(name string) string { resp, err := s.testBench.Client.CreateUser(ctxOrgAdminAuth, connect.NewRequest(&frontierv1beta1.CreateUserRequest{ Body: &frontierv1beta1.UserRequestBody{ @@ -4033,6 +4064,13 @@ func (s *APIRegressionTestSuite) TestProjectDeleteKeepsTheRowAndRemovesAccess() s.Assert().Equal(1, deletedRows) }) + s.Run("the project's resource stays in the database marked deleted", func() { + var deletedRows int + s.Require().NoError(s.dbc.GetContext(context.Background(), &deletedRows, + "SELECT count(*) FROM resources WHERE id = $1 AND deleted_at IS NOT NULL", resourceID)) + s.Assert().Equal(1, deletedRows) + }) + s.Run("a second delete is refused", func() { _, err := s.testBench.Client.DeleteProject(ctxOrgAdminAuth, connect.NewRequest(&frontierv1beta1.DeleteProjectRequest{Id: projectID})) s.Require().Error(err)