From 41c7d15f1247ee149cf0b5e2249862a6a578d3c0 Mon Sep 17 00:00:00 2001 From: aman Date: Tue, 29 Sep 2026 15:12:24 +0530 Subject: [PATCH 1/3] feat(store): domain delete keeps the row and the expiry cleanup skips deleted domains Delete sets deleted_at through softDelete instead of removing the row, and a domain that is already deleted reports not found. The daily cleanup of expired pending requests skips deleted rows, so it no longer removes them. --- internal/store/postgres/domain_repository.go | 6 +-- .../store/postgres/domain_repository_test.go | 52 +++++++++++++++++++ 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/internal/store/postgres/domain_repository.go b/internal/store/postgres/domain_repository.go index 924d96cc2..b2eaafb7e 100644 --- a/internal/store/postgres/domain_repository.go +++ b/internal/store/postgres/domain_repository.go @@ -123,9 +123,9 @@ func (s *DomainRepository) Get(ctx context.Context, id string) (domain.Domain, e } func (s *DomainRepository) Delete(ctx context.Context, id string) error { - query, params, err := dialect.Delete(TABLE_DOMAINS).Where(goqu.Ex{ + query, params, err := softDelete(TABLE_DOMAINS).Where(goqu.Ex{ "id": id, - }).Returning(&Domain{}).ToSQL() + }).ToSQL() if err != nil { return fmt.Errorf("%w: %s", errQuery, err) } @@ -183,7 +183,7 @@ func (s *DomainRepository) DeleteExpiredDomainRequests(ctx context.Context) erro query, params, err := dialect.Delete(TABLE_DOMAINS).Where(goqu.Ex{ "created_at": goqu.Op{"lte": s.Now().Add(-domain.DefaultTokenExpiry)}, "state": domain.Pending, - }).ToSQL() + }, live(TABLE_DOMAINS)).ToSQL() if err != nil { return fmt.Errorf("%w: %s", errQuery, err) } diff --git a/internal/store/postgres/domain_repository_test.go b/internal/store/postgres/domain_repository_test.go index a50b8fae5..6e2b01d2e 100644 --- a/internal/store/postgres/domain_repository_test.go +++ b/internal/store/postgres/domain_repository_test.go @@ -2,6 +2,7 @@ package postgres_test import ( "context" + "fmt" "io" "log/slog" "testing" @@ -46,6 +47,19 @@ func (s *DomainRepositoryTestSuite) TearDownSuite() { } } +func (s *DomainRepositoryTestSuite) TearDownTest() { + if err := s.cleanup(); err != nil { + s.T().Fatal(err) + } +} + +func (s *DomainRepositoryTestSuite) cleanup() error { + queries := []string{ + fmt.Sprintf("TRUNCATE TABLE %s RESTART IDENTITY CASCADE", postgres.TABLE_DOMAINS), + } + return execQueries(context.TODO(), s.client, queries) +} + func (s *DomainRepositoryTestSuite) TestSkipsSoftDeletedDomains() { live, err := s.repository.Create(s.ctx, domain.Domain{OrgID: s.orgID, Name: "live.example.com", Token: "t1"}) s.Require().NoError(err) @@ -70,6 +84,44 @@ func (s *DomainRepositoryTestSuite) TestSkipsSoftDeletedDomains() { s.Assert().ErrorIs(err, domain.ErrNotExist) } +func (s *DomainRepositoryTestSuite) TestDeleteKeepsTheRow() { + dmn, err := s.repository.Create(s.ctx, domain.Domain{OrgID: s.orgID, Name: "acme.com", Token: "t1"}) + s.Require().NoError(err) + + s.Require().NoError(s.repository.Delete(s.ctx, dmn.ID)) + s.Assert().Equal("1", scalarSQL(s.T(), s.ctx, s.client, + "SELECT count(*) FROM domains WHERE id = $1 AND deleted_at IS NOT NULL", dmn.ID)) + + _, err = s.repository.Get(s.ctx, dmn.ID) + s.Assert().ErrorIs(err, domain.ErrNotExist) + + s.Assert().ErrorIs(s.repository.Delete(s.ctx, dmn.ID), domain.ErrNotExist) + s.Assert().ErrorIs(s.repository.Delete(s.ctx, uuid.NewString()), domain.ErrNotExist) + + again, err := s.repository.Create(s.ctx, domain.Domain{OrgID: s.orgID, Name: "acme.com", Token: "t2"}) + s.Require().NoError(err) + s.Assert().NotEqual(dmn.ID, again.ID) +} + +func (s *DomainRepositoryTestSuite) TestDeleteExpiredDomainRequestsSkipsSoftDeletedDomains() { + stale, err := s.repository.Create(s.ctx, domain.Domain{OrgID: s.orgID, Name: "stale.example.com", Token: "t1"}) + s.Require().NoError(err) + deleted, err := s.repository.Create(s.ctx, domain.Domain{OrgID: s.orgID, Name: "deleted.example.com", Token: "t2"}) + s.Require().NoError(err) + fresh, err := s.repository.Create(s.ctx, domain.Domain{OrgID: s.orgID, Name: "fresh.example.com", Token: "t3"}) + s.Require().NoError(err) + + s.Require().NoError(s.repository.Delete(s.ctx, deleted.ID)) + execSQL(s.T(), s.ctx, s.client, + "UPDATE domains SET created_at = now() - interval '8 days' WHERE id IN ($1, $2)", stale.ID, deleted.ID) + + s.Require().NoError(s.repository.DeleteExpiredDomainRequests(s.ctx)) + + var remaining []string + s.Require().NoError(s.client.SelectContext(s.ctx, &remaining, "SELECT id FROM domains")) + s.Assert().ElementsMatch([]string{deleted.ID, fresh.ID}, remaining) +} + func TestDomainRepository(t *testing.T) { suite.Run(t, new(DomainRepositoryTestSuite)) } From 74e6851bf161566e7ef87656267634d1d1091a05 Mon Sep 17 00:00:00 2001 From: aman Date: Tue, 29 Sep 2026 15:13:42 +0530 Subject: [PATCH 2/3] feat(domain): write a domain.deleted audit record The service reads the domain and its organization, marks the domain deleted, then records the delete with the organization as the resource and the domain as the target. A failed record write is logged and does not fail the delete. --- cmd/serve.go | 2 +- core/domain/mocks/audit_record_repository.go | 94 ++++++++++++++++++++ core/domain/mocks/org_service.go | 60 ++++++++++++- core/domain/service.go | 74 +++++++++++---- core/domain/service_test.go | 81 ++++++++++++++++- pkg/auditrecord/consts.go | 4 + 6 files changed, 296 insertions(+), 19 deletions(-) create mode 100644 core/domain/mocks/audit_record_repository.go diff --git a/cmd/serve.go b/cmd/serve.go index ba950dfa3..1535dc212 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -503,7 +503,7 @@ func buildAPIDependencies( userProjectsService := userprojects.NewService(userProjectsRepository) domainRepository := postgres.NewDomainRepository(logger, dbc) - domainService := domain.NewService(logger, domainRepository, userService, organizationService, membershipService) + domainService := domain.NewService(logger, domainRepository, userService, organizationService, membershipService, auditRecordRepository) metaschemaRepository := postgres.NewMetaSchemaRepository(logger, dbc) metaschemaService := metaschema.NewService(metaschemaRepository, logger, cfg.App.Metaschema.RefreshInterval) diff --git a/core/domain/mocks/audit_record_repository.go b/core/domain/mocks/audit_record_repository.go new file mode 100644 index 000000000..87c61b8a3 --- /dev/null +++ b/core/domain/mocks/audit_record_repository.go @@ -0,0 +1,94 @@ +// Code generated by mockery v2.53.5. DO NOT EDIT. + +package mocks + +import ( + context "context" + + models "github.com/raystack/frontier/core/auditrecord/models" + mock "github.com/stretchr/testify/mock" +) + +// AuditRecordRepository is an autogenerated mock type for the AuditRecordRepository type +type AuditRecordRepository struct { + mock.Mock +} + +type AuditRecordRepository_Expecter struct { + mock *mock.Mock +} + +func (_m *AuditRecordRepository) EXPECT() *AuditRecordRepository_Expecter { + return &AuditRecordRepository_Expecter{mock: &_m.Mock} +} + +// Create provides a mock function with given fields: ctx, auditRecord +func (_m *AuditRecordRepository) Create(ctx context.Context, auditRecord models.AuditRecord) (models.AuditRecord, error) { + ret := _m.Called(ctx, auditRecord) + + if len(ret) == 0 { + panic("no return value specified for Create") + } + + var r0 models.AuditRecord + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, models.AuditRecord) (models.AuditRecord, error)); ok { + return rf(ctx, auditRecord) + } + if rf, ok := ret.Get(0).(func(context.Context, models.AuditRecord) models.AuditRecord); ok { + r0 = rf(ctx, auditRecord) + } else { + r0 = ret.Get(0).(models.AuditRecord) + } + + if rf, ok := ret.Get(1).(func(context.Context, models.AuditRecord) error); ok { + r1 = rf(ctx, auditRecord) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// AuditRecordRepository_Create_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Create' +type AuditRecordRepository_Create_Call struct { + *mock.Call +} + +// Create is a helper method to define mock.On call +// - ctx context.Context +// - auditRecord models.AuditRecord +func (_e *AuditRecordRepository_Expecter) Create(ctx interface{}, auditRecord interface{}) *AuditRecordRepository_Create_Call { + return &AuditRecordRepository_Create_Call{Call: _e.mock.On("Create", ctx, auditRecord)} +} + +func (_c *AuditRecordRepository_Create_Call) Run(run func(ctx context.Context, auditRecord models.AuditRecord)) *AuditRecordRepository_Create_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(models.AuditRecord)) + }) + return _c +} + +func (_c *AuditRecordRepository_Create_Call) Return(_a0 models.AuditRecord, _a1 error) *AuditRecordRepository_Create_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *AuditRecordRepository_Create_Call) RunAndReturn(run func(context.Context, models.AuditRecord) (models.AuditRecord, error)) *AuditRecordRepository_Create_Call { + _c.Call.Return(run) + return _c +} + +// NewAuditRecordRepository creates a new instance of AuditRecordRepository. 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 NewAuditRecordRepository(t interface { + mock.TestingT + Cleanup(func()) +}) *AuditRecordRepository { + mock := &AuditRecordRepository{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/core/domain/mocks/org_service.go b/core/domain/mocks/org_service.go index bd341d2b1..20899d342 100644 --- a/core/domain/mocks/org_service.go +++ b/core/domain/mocks/org_service.go @@ -5,9 +5,8 @@ package mocks import ( context "context" - mock "github.com/stretchr/testify/mock" - organization "github.com/raystack/frontier/core/organization" + mock "github.com/stretchr/testify/mock" ) // OrgService is an autogenerated mock type for the OrgService type @@ -80,6 +79,63 @@ func (_c *OrgService_Get_Call) RunAndReturn(run func(context.Context, string) (o return _c } +// GetRaw provides a mock function with given fields: ctx, id +func (_m *OrgService) GetRaw(ctx context.Context, id string) (organization.Organization, error) { + ret := _m.Called(ctx, id) + + if len(ret) == 0 { + panic("no return value specified for GetRaw") + } + + var r0 organization.Organization + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string) (organization.Organization, error)); ok { + return rf(ctx, id) + } + if rf, ok := ret.Get(0).(func(context.Context, string) organization.Organization); ok { + r0 = rf(ctx, id) + } else { + r0 = ret.Get(0).(organization.Organization) + } + + if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = rf(ctx, id) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// OrgService_GetRaw_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRaw' +type OrgService_GetRaw_Call struct { + *mock.Call +} + +// GetRaw is a helper method to define mock.On call +// - ctx context.Context +// - id string +func (_e *OrgService_Expecter) GetRaw(ctx interface{}, id interface{}) *OrgService_GetRaw_Call { + return &OrgService_GetRaw_Call{Call: _e.mock.On("GetRaw", ctx, id)} +} + +func (_c *OrgService_GetRaw_Call) Run(run func(ctx context.Context, id string)) *OrgService_GetRaw_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string)) + }) + return _c +} + +func (_c *OrgService_GetRaw_Call) Return(_a0 organization.Organization, _a1 error) *OrgService_GetRaw_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *OrgService_GetRaw_Call) RunAndReturn(run func(context.Context, string) (organization.Organization, error)) *OrgService_GetRaw_Call { + _c.Call.Return(run) + return _c +} + // NewOrgService creates a new instance of OrgService. 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 NewOrgService(t interface { diff --git a/core/domain/service.go b/core/domain/service.go index ff622f127..cf31025a0 100644 --- a/core/domain/service.go +++ b/core/domain/service.go @@ -11,8 +11,10 @@ import ( "strings" "time" + auditmodels "github.com/raystack/frontier/core/auditrecord/models" "github.com/raystack/frontier/core/membership" "github.com/raystack/frontier/core/organization" + pkgauditrecord "github.com/raystack/frontier/pkg/auditrecord" "github.com/raystack/frontier/pkg/utils" "log/slog" @@ -29,6 +31,7 @@ type UserService interface { type OrgService interface { Get(ctx context.Context, id string) (organization.Organization, error) + GetRaw(ctx context.Context, id string) (organization.Organization, error) } type MembershipService interface { @@ -36,13 +39,18 @@ type MembershipService interface { ListResourcesByPrincipal(ctx context.Context, principal authenticate.Principal, resourceType string, filter membership.ResourceFilter) ([]string, error) } +type AuditRecordRepository interface { + Create(ctx context.Context, auditRecord auditmodels.AuditRecord) (auditmodels.AuditRecord, error) +} + type Service struct { - repository Repository - userService UserService - orgService OrgService - membershipService MembershipService - cron *cron.Cron - log *slog.Logger + repository Repository + userService UserService + orgService OrgService + membershipService MembershipService + auditRecordRepository AuditRecordRepository + cron *cron.Cron + log *slog.Logger } const ( @@ -52,14 +60,15 @@ const ( refreshTime = "0 0 * * *" // Once a day at midnight (UTC) ) -func NewService(logger *slog.Logger, repository Repository, userService UserService, orgService OrgService, membershipService MembershipService) *Service { +func NewService(logger *slog.Logger, repository Repository, userService UserService, orgService OrgService, membershipService MembershipService, auditRecordRepository AuditRecordRepository) *Service { return &Service{ - repository: repository, - userService: userService, - orgService: orgService, - membershipService: membershipService, - cron: cron.New(), - log: logger, + repository: repository, + userService: userService, + orgService: orgService, + membershipService: membershipService, + auditRecordRepository: auditRecordRepository, + cron: cron.New(), + log: logger, } } @@ -73,9 +82,44 @@ func (s Service) List(ctx context.Context, flt Filter) ([]Domain, error) { return s.repository.List(ctx, flt) } -// Remove an organization's whitelisted domain from the database +// Delete marks an organization's whitelisted domain as deleted and writes an audit record func (s Service) Delete(ctx context.Context, id string) error { - return s.repository.Delete(ctx, id) + dmn, err := s.repository.Get(ctx, id) + if err != nil { + return err + } + org, err := s.orgService.GetRaw(ctx, dmn.OrgID) + if err != nil { + return err + } + if err = s.repository.Delete(ctx, id); err != nil { + return err + } + + s.createAuditRecord(ctx, pkgauditrecord.DomainDeletedEvent, dmn, org) + return nil +} + +func (s Service) createAuditRecord(ctx context.Context, event pkgauditrecord.Event, dmn Domain, org organization.Organization) { + if _, err := s.auditRecordRepository.Create(ctx, auditmodels.AuditRecord{ + Event: event, + Resource: auditmodels.Resource{ + ID: org.ID, + Type: pkgauditrecord.OrganizationType, + Name: org.Title, + }, + Target: &auditmodels.Target{ + ID: dmn.ID, + Type: pkgauditrecord.DomainType, + Name: dmn.Name, + }, + OrgID: org.ID, + OrgName: org.Title, + OccurredAt: time.Now(), + }); err != nil { + s.log.WarnContext(ctx, "failed to create domain audit record", + "event", event, "org_id", org.ID, "domain_id", dmn.ID, "domain_name", dmn.Name, "err", err) + } } // Creates a record for the domain in the database and returns the TXT record that needs to be added to the DNS for the domain verification diff --git a/core/domain/service_test.go b/core/domain/service_test.go index 01e73f8a8..7434e3902 100644 --- a/core/domain/service_test.go +++ b/core/domain/service_test.go @@ -2,14 +2,19 @@ package domain_test import ( "context" + "errors" "log/slog" "testing" + "time" + auditmodels "github.com/raystack/frontier/core/auditrecord/models" "github.com/raystack/frontier/core/authenticate" "github.com/raystack/frontier/core/domain" "github.com/raystack/frontier/core/domain/mocks" + "github.com/raystack/frontier/core/organization" "github.com/raystack/frontier/core/user" "github.com/raystack/frontier/internal/bootstrap/schema" + pkgauditrecord "github.com/raystack/frontier/pkg/auditrecord" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" ) @@ -25,7 +30,7 @@ func TestService_ListJoinableOrgsByDomain(t *testing.T) { userSvc := mocks.NewUserService(t) orgSvc := mocks.NewOrgService(t) memberSvc := mocks.NewMembershipService(t) - svc := domain.NewService(slog.Default(), repo, userSvc, orgSvc, memberSvc) + svc := domain.NewService(slog.Default(), repo, userSvc, orgSvc, memberSvc, mocks.NewAuditRecordRepository(t)) return svc, repo, userSvc, memberSvc } @@ -85,3 +90,77 @@ func TestService_ListJoinableOrgsByDomain(t *testing.T) { assert.Equal(t, []string{"org-1", "org-2"}, got) }) } + +func TestService_Delete(t *testing.T) { + ctx := context.Background() + dmn := domain.Domain{ID: "3f1c9a2e-8b4d-4e6f-9a1b-2c3d4e5f6a7b", Name: "acme.com", OrgID: "org-1"} + org := organization.Organization{ID: "org-1", Name: "acme", Title: "Acme Inc"} + errDB := errors.New("connection reset") + + newService := func(t *testing.T) (*domain.Service, *mocks.Repository, *mocks.OrgService, *mocks.AuditRecordRepository) { + t.Helper() + repo := mocks.NewRepository(t) + orgSvc := mocks.NewOrgService(t) + auditRepo := mocks.NewAuditRecordRepository(t) + svc := domain.NewService(slog.Default(), repo, mocks.NewUserService(t), orgSvc, mocks.NewMembershipService(t), auditRepo) + return svc, repo, orgSvc, auditRepo + } + + t.Run("marks the domain deleted and writes a domain.deleted audit record", func(t *testing.T) { + svc, repo, orgSvc, auditRepo := newService(t) + repo.EXPECT().Get(ctx, dmn.ID).Return(dmn, nil) + orgSvc.EXPECT().GetRaw(ctx, org.ID).Return(org, nil) + repo.EXPECT().Delete(ctx, dmn.ID).Return(nil) + + var got auditmodels.AuditRecord + auditRepo.EXPECT().Create(ctx, mock.Anything). + Run(func(_ context.Context, record auditmodels.AuditRecord) { got = record }). + Return(auditmodels.AuditRecord{}, nil) + + assert.NoError(t, svc.Delete(ctx, dmn.ID)) + + assert.False(t, got.OccurredAt.IsZero()) + got.OccurredAt = time.Time{} + assert.Equal(t, auditmodels.AuditRecord{ + Event: pkgauditrecord.DomainDeletedEvent, + Resource: auditmodels.Resource{ID: org.ID, Type: pkgauditrecord.OrganizationType, Name: org.Title}, + Target: &auditmodels.Target{ID: dmn.ID, Type: pkgauditrecord.DomainType, Name: dmn.Name}, + OrgID: org.ID, + OrgName: org.Title, + }, got) + }) + + t.Run("an unknown domain writes no audit record", func(t *testing.T) { + svc, repo, _, _ := newService(t) + repo.EXPECT().Get(ctx, dmn.ID).Return(domain.Domain{}, domain.ErrNotExist) + + assert.ErrorIs(t, svc.Delete(ctx, dmn.ID), domain.ErrNotExist) + }) + + t.Run("a failed org lookup leaves the domain alone", func(t *testing.T) { + svc, repo, orgSvc, _ := newService(t) + repo.EXPECT().Get(ctx, dmn.ID).Return(dmn, nil) + orgSvc.EXPECT().GetRaw(ctx, org.ID).Return(organization.Organization{}, errDB) + + assert.ErrorIs(t, svc.Delete(ctx, dmn.ID), errDB) + }) + + t.Run("a failed delete writes no audit record", func(t *testing.T) { + svc, repo, orgSvc, _ := newService(t) + repo.EXPECT().Get(ctx, dmn.ID).Return(dmn, nil) + orgSvc.EXPECT().GetRaw(ctx, org.ID).Return(org, nil) + repo.EXPECT().Delete(ctx, dmn.ID).Return(errDB) + + assert.ErrorIs(t, svc.Delete(ctx, dmn.ID), errDB) + }) + + t.Run("a failed audit write does not fail the delete", func(t *testing.T) { + svc, repo, orgSvc, auditRepo := newService(t) + repo.EXPECT().Get(ctx, dmn.ID).Return(dmn, nil) + orgSvc.EXPECT().GetRaw(ctx, org.ID).Return(org, nil) + repo.EXPECT().Delete(ctx, dmn.ID).Return(nil) + auditRepo.EXPECT().Create(ctx, mock.Anything).Return(auditmodels.AuditRecord{}, errDB) + + assert.NoError(t, svc.Delete(ctx, dmn.ID)) + }) +} diff --git a/pkg/auditrecord/consts.go b/pkg/auditrecord/consts.go index db630dabb..2bf3bf430 100644 --- a/pkg/auditrecord/consts.go +++ b/pkg/auditrecord/consts.go @@ -42,6 +42,9 @@ const ( OrganizationMemberRoleChangedEvent Event = "organization.role_changed" OrganizationInvitationAcceptedEvent Event = "organization.accepted" + // Domain Events + DomainDeletedEvent Event = "domain.deleted" + // Project Member Events ProjectMemberRoleChangedEvent Event = "project.member_role_changed" ProjectMemberRemovedEvent Event = "project.member_removed" @@ -107,6 +110,7 @@ const ( PATType EntityType = "pat" ConsentType EntityType = "consent" PlatformType EntityType = "platform" + DomainType EntityType = "domain" ) // String returns the string representation of the event From fe8b7d6730d2269fbd4519fcdf5463a6e0ef803f Mon Sep 17 00:00:00 2001 From: aman Date: Tue, 29 Sep 2026 15:13:42 +0530 Subject: [PATCH 3/3] test(e2e): cover domain delete, re-create, and its audit record --- test/e2e/regression/api_test.go | 78 +++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/test/e2e/regression/api_test.go b/test/e2e/regression/api_test.go index c8c3d5afd..41d9d7de3 100644 --- a/test/e2e/regression/api_test.go +++ b/test/e2e/regression/api_test.go @@ -2756,6 +2756,84 @@ func (s *APIRegressionTestSuite) TestOrganizationDomainsAPI() { s.Assert().NoError(err) s.Assert().NotNil(getDomainResp) }) + s.Run("2. deleting a domain hides it, frees its name and writes an audit record", func() { + createOrgResp, err := s.testBench.Client.CreateOrganization(ctxOrgAdminAuth, connect.NewRequest(&frontierv1beta1.CreateOrganizationRequest{ + Body: &frontierv1beta1.OrganizationRequestBody{ + Title: "org 2", + Name: "org-domains-2", + }, + })) + s.Require().NoError(err) + orgID := createOrgResp.Msg.GetOrganization().GetId() + + createDomainResp, err := s.testBench.Client.CreateOrganizationDomain(ctxOrgAdminAuth, connect.NewRequest(&frontierv1beta1.CreateOrganizationDomainRequest{ + OrgId: orgID, + Domain: "org-domains-2.raystack.io", + })) + s.Require().NoError(err) + domainID := createDomainResp.Msg.GetDomain().GetId() + + _, err = s.testBench.Client.DeleteOrganizationDomain(ctxOrgAdminAuth, connect.NewRequest(&frontierv1beta1.DeleteOrganizationDomainRequest{ + OrgId: orgID, + Id: domainID, + })) + s.Require().NoError(err) + + _, err = s.testBench.Client.GetOrganizationDomain(ctxOrgAdminAuth, connect.NewRequest(&frontierv1beta1.GetOrganizationDomainRequest{ + OrgId: orgID, + Id: domainID, + })) + s.Assert().Equal(connect.CodeNotFound, connect.CodeOf(err)) + + listDomainResp, err := s.testBench.Client.ListOrganizationDomains(ctxOrgAdminAuth, connect.NewRequest(&frontierv1beta1.ListOrganizationDomainsRequest{ + OrgId: orgID, + })) + s.Require().NoError(err) + s.Assert().Empty(listDomainResp.Msg.GetDomains()) + + _, err = s.testBench.Client.DeleteOrganizationDomain(ctxOrgAdminAuth, connect.NewRequest(&frontierv1beta1.DeleteOrganizationDomainRequest{ + OrgId: orgID, + Id: domainID, + })) + s.Assert().Equal(connect.CodeNotFound, connect.CodeOf(err)) + + recreateDomainResp, err := s.testBench.Client.CreateOrganizationDomain(ctxOrgAdminAuth, connect.NewRequest(&frontierv1beta1.CreateOrganizationDomainRequest{ + OrgId: orgID, + Domain: "org-domains-2.raystack.io", + })) + s.Require().NoError(err) + s.Assert().NotEqual(domainID, recreateDomainResp.Msg.GetDomain().GetId()) + + currentUserResp, err := s.testBench.Client.GetCurrentUser(ctxOrgAdminAuth, connect.NewRequest(&frontierv1beta1.GetCurrentUserRequest{})) + s.Require().NoError(err) + + recordsResp, err := s.testBench.AdminClient.ListAuditRecords(ctxOrgAdminAuth, connect.NewRequest(&frontierv1beta1.ListAuditRecordsRequest{ + Query: &frontierv1beta1.RQLRequest{ + Filters: []*frontierv1beta1.RQLFilter{{ + Name: "target_id", + Operator: "eq", + Value: &frontierv1beta1.RQLFilter_StringValue{StringValue: domainID}, + }}, + }, + })) + s.Require().NoError(err) + s.Require().Len(recordsResp.Msg.GetAuditRecords(), 1) + record := recordsResp.Msg.GetAuditRecords()[0] + s.Assert().Equal("domain.deleted", record.GetEvent()) + s.Assert().Equal(currentUserResp.Msg.GetUser().GetId(), record.GetActor().GetId()) + s.Assert().Equal(orgID, record.GetResource().GetId()) + s.Assert().Equal("organization", record.GetResource().GetType()) + s.Assert().Equal("domain", record.GetTarget().GetType()) + s.Assert().Equal("org-domains-2.raystack.io", record.GetTarget().GetName()) + s.Assert().Equal(orgID, record.GetOrgId()) + s.Assert().Equal("org 2", record.GetOrgName()) + + // the org now holds a live domain and a deleted one; both go with it + _, err = s.testBench.Client.DeleteOrganization(ctxOrgAdminAuth, connect.NewRequest(&frontierv1beta1.DeleteOrganizationRequest{ + Id: orgID, + })) + s.Require().NoError(err) + }) } func (s *APIRegressionTestSuite) TestWebhookAPI() {