diff --git a/cmd/serve.go b/cmd/serve.go index ba950dfa32..1535dc2120 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 0000000000..87c61b8a33 --- /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 bd341d2b1a..20899d342f 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 ff622f1279..cf31025a00 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 01e73f8a86..7434e39026 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/internal/store/postgres/domain_repository.go b/internal/store/postgres/domain_repository.go index 924d96cc22..b2eaafb7ed 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 a50b8fae5a..6e2b01d2ee 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)) } diff --git a/pkg/auditrecord/consts.go b/pkg/auditrecord/consts.go index db630dabbc..2bf3bf4300 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 diff --git a/test/e2e/regression/api_test.go b/test/e2e/regression/api_test.go index c8c3d5afdb..41d9d7de3c 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() {