package models_test import ( "testing" "time" "github.com/google/uuid" "github.com/icrowley/fake" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gorm.io/datatypes" "gorm.io/gorm" "rocketgit.ru/rsmon/worker/app/models" "rocketgit.ru/rsmon/worker/config/database" ) var gormErrRecordNotFound = gorm.ErrRecordNotFound func deletionStringPtr(s string) *string { return &s } func deletionBoolPtr(b bool) *bool { return &b } func init() { database.Init() } // TestUserDeletionPending tests the DeletionPending method func TestUserDeletionPending(t *testing.T) { t.Run("returns false when no deletion requested", func(t *testing.T) { user := models.User{} assert.False(t, user.DeletionPending()) }) t.Run("returns true when deletion requested", func(t *testing.T) { now := time.Now() user := models.User{DeletionRequestedAt: &now} assert.True(t, user.DeletionPending()) }) } // TestUserAsJSONIncludesDeletionField verifies that the AsJSON output // contains the deletion_requested_at field so the frontend can react to it. func TestUserAsJSONIncludesDeletionField(t *testing.T) { email := "test@example.com" now := time.Now() user := models.User{ ID: 42, Email: &email, Name: "Test User", DeletionRequestedAt: &now, } result := user.AsJSON() assert.NotNil(t, result) assert.Contains(t, result, "deletion_requested_at") assert.Equal(t, &now, result["deletion_requested_at"]) } // TestRequestUserDeletionNilUser ensures nil-safety func TestRequestUserDeletionNilUser(t *testing.T) { err := models.RequestUserDeletion(nil) assert.Error(t, err) } // TestCancelUserDeletionNilUser ensures nil-safety func TestCancelUserDeletionNilUser(t *testing.T) { err := models.CancelUserDeletion(nil) assert.Error(t, err) } // TestCacheInvalidationHook_CalledByRequestDeletion verifies that // RequestUserDeletion calls OnUserCacheInvalidate with the correct user ID. func TestCacheInvalidationHook_CalledByRequestDeletion(t *testing.T) { models.Drop() models.Migrate() email := fake.EmailAddress() user := models.User{Email: &email, Name: "CacheHookTest"} assert.NoError(t, models.DB().Create(&user).Error) var calledID int64 originalHook := models.OnUserCacheInvalidate models.OnUserCacheInvalidate = func(userID int64) { calledID = userID } defer func() { models.OnUserCacheInvalidate = originalHook }() assert.NoError(t, models.RequestUserDeletion(&user)) assert.Equal(t, user.ID, calledID) models.DB().Unscoped().Delete(&user) } // TestCacheInvalidationHook_CalledByCancelUserDeletion verifies that // CancelUserDeletion calls OnUserCacheInvalidate with the correct user ID. func TestCacheInvalidationHook_CalledByCancelUserDeletion(t *testing.T) { models.Drop() models.Migrate() email := fake.EmailAddress() now := time.Now() user := models.User{Email: &email, Name: "CacheHookCancel", DeletionRequestedAt: &now} assert.NoError(t, models.DB().Create(&user).Error) var calledID int64 originalHook := models.OnUserCacheInvalidate models.OnUserCacheInvalidate = func(userID int64) { calledID = userID } defer func() { models.OnUserCacheInvalidate = originalHook }() assert.NoError(t, models.CancelUserDeletion(&user)) assert.Equal(t, user.ID, calledID) models.DB().Unscoped().Delete(&user) } // TestHardDeleteAccount_FKCascadeRegression covers the FK regressions // that surfaced as a series of distinct SQL errors when an operator // purged an account that owned monitors with distributed-worker // activity or per-user contacts: // // 1. check_region_results.check_id → checks(id) had no ON DELETE // CASCADE, so deleting a check while it still had region-result // rows raised 23503. // 2. worker_nodes did not have an account_id column, so the // "Workers scoped to this account" delete raised 42703. // 3. contacts.account_id → accounts(id) had no ON DELETE CASCADE, // and the original delete filter required user_id IS NULL, so // per-user contacts created in CreateAccountForUser (both // account_id and user_id set) survived and blocked the account // delete with 23503. // // The test seeds an account with a monitor, a check with a region // result row, an account-scoped LLM, a per-user contact, and both a // private worker (with account_id) and an operated worker (NULL // account_id), then runs HardDeleteAccount and asserts that the // account, the private worker, the LLM, the monitor/check/region // result, and the per-user contact all disappear, while the operated // worker and an unrelated user-only contact survive. func TestHardDeleteAccount_FKCascadeRegression(t *testing.T) { models.Drop() models.Migrate() plan := models.Plan{Name: "hard-delete-regression"} require.NoError(t, models.DB().Create(&plan).Error) account := models.Account{Name: "victim", Timezone: "UTC", Language: "en", PlanID: &plan.ID} require.NoError(t, models.DB().Create(&account).Error) otherAccount := models.Account{Name: "survivor", Timezone: "UTC", Language: "en", PlanID: &plan.ID} require.NoError(t, models.DB().Create(&otherAccount).Error) owner := models.User{Name: "owner", Email: deletionStringPtr("owner@example.com"), Timezone: "UTC"} require.NoError(t, models.DB().Create(&owner).Error) stranger := models.User{Name: "stranger", Email: deletionStringPtr("stranger@example.com"), Timezone: "UTC"} require.NoError(t, models.DB().Create(&stranger).Error) region := models.Region{} if err := models.DB().Where("code = ?", "test").First(®ion).Error; err != nil { require.NoError(t, models.DB().Create(&models.Region{Code: "test", Name: "test", Enabled: true}).Error) } group := models.Group{Name: "g", AccountID: account.ID} require.NoError(t, models.DB().Create(&group).Error) monitor := models.Monitor{ Name: deletionStringPtr("m"), Host: "example.com", GroupID: group.ID, Enabled: true, } require.NoError(t, models.DB().Create(&monitor).Error) check := models.Check{ MonitorID: monitor.ID, Kind: "http", Interval: 60, Settings: datatypes.JSON([]byte(`{}`)), Enabled: deletionBoolPtr(true), State: "UNK", } require.NoError(t, models.DB().Create(&check).Error) // Per-user contact that references both the victim account and a // user. CreateAccountForUser writes a contact in this shape, and // the old "user_id IS NULL" filter would let it survive and block // the account delete with contacts_account_id_fkey 23503. ownerContact := models.Contact{ AccountID: &account.ID, UserID: &owner.ID, Name: "owner-email", Kind: "email", Value: "owner@example.com", } require.NoError(t, models.DB().Create(&ownerContact).Error) // Account-only contact (user_id IS NULL) — also tied to the account // via FK and must be removed. accountContact := models.Contact{ AccountID: &account.ID, Name: "ops", Kind: "email", Value: "ops@example.com", } require.NoError(t, models.DB().Create(&accountContact).Error) notification := models.Notification{AccountID: account.ID, Name: "alerts", Enabled: true} require.NoError(t, models.DB().Create(¬ification).Error) require.NoError(t, models.DB().Model(¬ification).Association("Contacts").Append(&accountContact)) message := models.Message{ NotificationID: notification.ID, ContactID: accountContact.ID, Kind: "test", State: "OK", CreatedAt: time.Now(), SentAt: time.Now(), } require.NoError(t, models.DB().Create(&message).Error) // User-only contact on a stranger — must survive account deletion. userOnlyContact := models.Contact{ UserID: &stranger.ID, Name: "stranger", Kind: "email", Value: "stranger@example.com", } require.NoError(t, models.DB().Create(&userOnlyContact).Error) // Region result row: this is the row that used to trigger the // fk_check_region_results_check FK violation when Check was // deleted. Without the fix the entire HardDeleteAccount would // fail here. privateWorker := &models.WorkerNode{ WorkerID: "private-" + uuid.New().String(), RegionCode: "test", Status: "active", AuthToken: "priv-tok-" + uuid.New().String(), AccountID: &account.ID, } require.NoError(t, models.DB().Create(privateWorker).Error) operatedWorker := &models.WorkerNode{ WorkerID: "operated-" + uuid.New().String(), RegionCode: "test", Status: "active", AuthToken: "op-tok-" + uuid.New().String(), AccountID: nil, } require.NoError(t, models.DB().Create(operatedWorker).Error) require.NoError(t, models.DB().Create(&models.CheckRegionResult{ CheckID: check.ID, RegionCode: "test", WorkerNodeID: &privateWorker.ID, ExecutedAt: time.Now(), State: "OK", }).Error) // LLM scoped to the victim account + linked to the private worker. // worker_llms.llm_id has a FK to llms(id) without ON DELETE // CASCADE, so the join row used to block LLM deletion too. llm := models.LLM{ AccountID: &account.ID, Name: "private-llm", URL: "https://llm.example.com", ModelName: "gpt-test", APIKey: "secret", Kind: "openai", } require.NoError(t, models.DB().Create(&llm).Error) require.NoError(t, models.DB().Exec( "INSERT INTO worker_llms (worker_node_id, llm_id) VALUES (?, ?)", privateWorker.ID, llm.ID, ).Error) // Inventory entities scoped to the victim account. Each has an // account_id FK to accounts(id) without ON DELETE CASCADE so they // must be removed before the account row goes away. The // shared-infra server belongs to another account and must // survive. victimServer := models.Server{Name: "victim-srv", AccountID: account.ID} require.NoError(t, models.DB().Create(&victimServer).Error) victimServerIP := models.ServerIp{ServerID: victimServer.ID, Address: "10.0.0.1"} require.NoError(t, models.DB().Create(&victimServerIP).Error) victimSite := models.Site{ AccountID: account.ID, ServerID: &victimServer.ID, Slug: "victim-site", Name: "victim-site", Kind: "production", IsActive: true, } require.NoError(t, models.DB().Create(&victimSite).Error) victimDeployment := models.Deployment{ AccountID: account.ID, ServerID: &victimServer.ID, SiteID: &victimSite.ID, Kind: "production", Mode: "compose", } require.NoError(t, models.DB().Create(&victimDeployment).Error) victimDomain := models.Domain{ AccountID: account.ID, ServerID: &victimServer.ID, SiteID: &victimSite.ID, Name: "victim.example.com", } require.NoError(t, models.DB().Create(&victimDomain).Error) otherServer := models.Server{Name: "shared-srv", AccountID: otherAccount.ID} require.NoError(t, models.DB().Create(&otherServer).Error) require.NoError(t, models.HardDeleteAccount(account.ID)) // Account and all account-scoped rows must be gone. assert.ErrorIs(t, models.DB().First(&models.Account{}, account.ID).Error, gormErrRecordNotFound) assert.ErrorIs(t, models.DB().First(&models.Check{}, check.ID).Error, gormErrRecordNotFound) assert.ErrorIs(t, models.DB().First(&models.Monitor{}, monitor.ID).Error, gormErrRecordNotFound) assert.ErrorIs(t, models.DB().First(&models.Group{}, group.ID).Error, gormErrRecordNotFound) assert.ErrorIs(t, models.DB().First(&models.Notification{}, notification.ID).Error, gormErrRecordNotFound) assert.ErrorIs(t, models.DB().First(&models.Message{}, message.ID).Error, gormErrRecordNotFound) assert.ErrorIs(t, models.DB().First(&models.LLM{}, llm.ID).Error, gormErrRecordNotFound) assert.ErrorIs(t, models.DB().First(&models.Contact{}, ownerContact.ID).Error, gormErrRecordNotFound) assert.ErrorIs(t, models.DB().First(&models.Contact{}, accountContact.ID).Error, gormErrRecordNotFound) var notificationContactCount int64 require.NoError(t, models.DB().Table("notification_contacts"). Where("notification_id = ? OR contact_id = ?", notification.ID, accountContact.ID). Count(¬ificationContactCount).Error) assert.Zero(t, notificationContactCount) // Private worker must be gone; operated worker must survive. assert.ErrorIs(t, models.DB().First(&models.WorkerNode{}, privateWorker.ID).Error, gormErrRecordNotFound) var stillOperated models.WorkerNode require.NoError(t, models.DB().First(&stillOperated, operatedWorker.ID).Error) assert.Nil(t, stillOperated.AccountID, "operated worker account_id must remain NULL") // User-only contact (no account_id) must survive. var stillUserOnly models.Contact require.NoError(t, models.DB().First(&stillUserOnly, userOnlyContact.ID).Error) // Inventory entities scoped to the account must be gone. assert.ErrorIs(t, models.DB().First(&models.Server{}, victimServer.ID).Error, gormErrRecordNotFound) assert.ErrorIs(t, models.DB().First(&models.Site{}, victimSite.ID).Error, gormErrRecordNotFound) assert.ErrorIs(t, models.DB().First(&models.Deployment{}, victimDeployment.ID).Error, gormErrRecordNotFound) assert.ErrorIs(t, models.DB().First(&models.Domain{}, victimDomain.ID).Error, gormErrRecordNotFound) var leftover int64 require.NoError(t, models.DB().Model(&models.ServerIp{}). Where("server_id = ?", victimServer.ID).Count(&leftover).Error) assert.Zero(t, leftover, "server_ips for the deleted server must be cleaned up") // Other-account inventory must survive. var stillOtherServer models.Server require.NoError(t, models.DB().First(&stillOtherServer, otherServer.ID).Error) // Region-result rows for the deleted check must be gone. require.NoError(t, models.DB().Model(&models.CheckRegionResult{}). Where("check_id = ?", check.ID).Count(&leftover).Error) assert.Zero(t, leftover, "check_region_results must be cleaned up before checks") require.NoError(t, models.DB().Model(&models.CheckRegionResult{}). Where("worker_node_id = ?", privateWorker.ID).Count(&leftover).Error) assert.Zero(t, leftover, "check_region_results referencing a private worker must be cleaned up") require.NoError(t, models.DB().Raw( "SELECT COUNT(*) FROM worker_llms WHERE llm_id = ? OR worker_node_id = ?", llm.ID, privateWorker.ID, ).Scan(&leftover).Error) assert.Zero(t, leftover, "worker_llms rows for the deleted LLM and private worker must be gone") // Sanity: the other account and its data are untouched. var stillOther models.Account require.NoError(t, models.DB().First(&stillOther, otherAccount.ID).Error) }