353 строки
14 KiB
Go
353 строки
14 KiB
Go
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"
|
|
|
|
"rsgit.ru/rsmon/rsmon/app/models"
|
|
"rsgit.ru/rsmon/rsmon/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)
|
|
}
|