Files
worker/app/models/deletion_test.go
Gleb Tv 2c7a0236da feat: publish standalone worker
Separate worker packaging and service lifecycle from the control plane.
2026-07-13 17:55:14 +03:00

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(&region).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(&notification).Error)
require.NoError(t, models.DB().Model(&notification).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(&notificationContactCount).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)
}