Files
RenewCMS/test/account_delete_persistence_test.go
T

268 lines
13 KiB
Go

package test
import (
"RenewCMS/internal/domain/user"
entity "RenewCMS/internal/infrastructure/persistence/models"
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
"errors"
"fmt"
"sync"
"testing"
"gorm.io/gorm"
)
func seedDeleteAccountRows(t *testing.T, db *gorm.DB, creditedArticles bool) {
t.Helper()
agent, ip := "Firefox", "203.0.113.7"
martaID, inesID := uint32(7), uint32(8)
website := "https://marta.example"
rows := []any{
&entity.User{ID: 7, Username: "marta", Password: "x", Email: "marta@renew.example", IsVerified: true, RoleID: &martaID},
&entity.User{ID: 8, Username: "ines", Password: "x", Email: "ines@renew.example", IsVerified: true, RoleID: &inesID},
&entity.APIKey{ID: 1, UserID: 7, Name: "ci", Prefix: "a", HashedKey: "h1"},
&entity.APIKey{ID: 2, UserID: 7, Name: "deploy", Prefix: "b", HashedKey: "h2"},
&entity.APIKey{ID: 3, UserID: 8, Name: "ines", Prefix: "c", HashedKey: "h3"},
&entity.Author{ID: 5, Name: "Marta Alves", Slug: "marta-alves", Bio: "bio", Website: &website, SocialLinks: `[{"type":"github","url":"https://github.com/marta"}]`, UserID: &martaID, AvatarID: &martaID},
&entity.Author{ID: 6, Name: "Ines", Slug: "ines", SocialLinks: "[]", UserID: &inesID},
&entity.Article{ID: 1, Title: "Mine", Slug: "mine", CreatedBy: 7},
&entity.Entry{ID: 1, ContentTypeID: 1, Data: "{}", CreatedBy: 7, UpdatedBy: 7},
&entity.Image{ID: 1, Path: "/a.png", Filename: "a.png", UploadedByID: 7},
&entity.AuditEvent{ID: 1, Action: "article.edit", Category: "articles", ActorType: "user", ActorID: &martaID, ActorName: "marta", TargetType: "article", TargetID: "1", TargetName: "Mine", Outcome: "success", Source: "web", IP: &ip, UserAgent: &agent, Params: `{"title":"Mine"}`, Changes: `[{"field":"title","kind":"text","after":"Mine"}]`},
&entity.AuditEvent{ID: 2, Action: "user.change_role", Category: "users", ActorType: "user", ActorID: &inesID, ActorName: "ines", TargetType: "user", TargetID: "7", TargetName: "marta", Outcome: "success", Source: "web", IP: &ip, Params: `{"to_name":"marta"}`, Changes: `[{"field":"role","kind":"text","after":"Writer"}]`},
&entity.AuditEvent{ID: 4, Action: "author.edit", Category: "authors", ActorType: "user", ActorID: &martaID, ActorName: "marta", TargetType: "author", TargetID: "5", TargetName: "Marta Alves", Outcome: "success", Source: "web", Params: `{"bio":"Writes about lime"}`, Changes: `[{"field":"bio","kind":"text","after":"Writes about lime"}]`},
&entity.AuditEvent{ID: 5, Action: "author.edit", Category: "authors", ActorType: "user", ActorID: &martaID, ActorName: "marta", TargetType: "author", TargetID: "6", TargetName: "Ines", Outcome: "success", Source: "web", Params: "{}", Changes: "[]"},
&entity.AuditEvent{ID: 6, Action: "author.edit", Category: "authors", ActorType: "user", ActorID: &inesID, ActorName: "ines", TargetType: "author", TargetID: "5", TargetName: "Marta Alves", Outcome: "success", Source: "web", Params: `{"keep":"yes"}`, Changes: "[]"},
&entity.AuditEvent{ID: 3, Action: "article.edit", Category: "articles", ActorType: "user", ActorID: &inesID, ActorName: "ines", TargetType: "article", TargetID: "1", TargetName: "Mine", Outcome: "success", Source: "web", IP: &ip, Params: `{"title":"Mine"}`, Changes: `[{"field":"title","kind":"text","after":"Mine"}]`},
}
if creditedArticles {
rows = append(rows, &entity.ArticleAuthor{ArticleID: 1, AuthorID: 5, Position: 0})
}
for _, row := range rows {
if err := db.Create(row).Error; err != nil {
t.Fatalf("seed %T: %v", row, err)
}
}
}
func deleteAccountRowCount(t *testing.T, db *gorm.DB, model any, query string, args ...any) int64 {
t.Helper()
var total int64
if err := db.Model(model).Where(query, args...).Count(&total).Error; err != nil {
t.Fatal(err)
}
return total
}
func TestAccountDeleteErasureContract(t *testing.T) {
backends := []contractBackend{
{name: "sqlite", open: openContractSQLite},
{name: "mysql", open: openContractMySQL},
{name: "postgres", open: openContractPostgres},
}
for _, backend := range backends {
t.Run(backend.name, func(t *testing.T) {
t.Run("The user, keys, author credit and events are handled in one operation", func(t *testing.T) {
db := backend.open(t)
seedDeleteAccountRows(t, db, true)
err := persistence.NewAccountEraser(db).Erase(user.Erasure{UserID: 7})
if err != nil {
t.Fatal(err)
}
if deleteAccountRowCount(t, db, &entity.User{}, "id = ?", 7) != 0 || deleteAccountRowCount(t, db, &entity.User{}, "id = ?", 8) != 1 {
t.Error("only user 7 must be removed")
}
if deleteAccountRowCount(t, db, &entity.APIKey{}, "user_id = ?", 7) != 0 || deleteAccountRowCount(t, db, &entity.APIKey{}, "user_id = ?", 8) != 1 {
t.Error("only the keys of user 7 must be removed")
}
var scrubbed entity.Author
if err := db.First(&scrubbed, 5).Error; err != nil {
t.Fatal(err)
}
if scrubbed.Name != "Deleted user" || scrubbed.Slug != "deleted-user-5" || scrubbed.Bio != "" || scrubbed.UserID != nil || scrubbed.AvatarID != nil || scrubbed.Website != nil || scrubbed.SocialLinks != "[]" {
t.Errorf("unexpected scrubbed author %+v", scrubbed)
}
var other entity.Author
if err := db.First(&other, 6).Error; err != nil || other.Name != "Ines" || other.UserID == nil {
t.Errorf("the other author must be untouched: %+v %v", other, err)
}
var own, targeted, unrelated entity.AuditEvent
db.First(&own, 1)
db.First(&targeted, 2)
db.First(&unrelated, 3)
if own.ActorName != "Deleted user" || own.IP != nil || own.UserAgent != nil || own.Params != "{}" || own.Changes != "[]" {
t.Errorf("own event not anonymised: %+v", own)
}
if own.Action != "article.edit" || own.Outcome != "success" || own.CreatedAt.IsZero() {
t.Errorf("the event itself must stay: %+v", own)
}
if targeted.TargetName != "Deleted user" || targeted.ActorName != "ines" || targeted.IP == nil || targeted.Params != "{}" || targeted.Changes != "[]" {
t.Errorf("targeted event wrong: %+v", targeted)
}
if unrelated.ActorName != "ines" || unrelated.IP == nil || unrelated.Params != `{"title":"Mine"}` || unrelated.Changes == "[]" {
t.Errorf("unrelated event must be untouched: %+v", unrelated)
}
var ownProfile, otherProfile, byOther entity.AuditEvent
db.First(&ownProfile, 4)
db.First(&otherProfile, 5)
db.First(&byOther, 6)
if ownProfile.TargetName != "Deleted user" || ownProfile.Params != "{}" || ownProfile.Changes != "[]" {
t.Errorf("an author event on the own profile must lose the name and its content: %+v", ownProfile)
}
if otherProfile.TargetName != "Ines" || otherProfile.ActorName != "Deleted user" {
t.Errorf("an author event on another person's profile keeps its target: %+v", otherProfile)
}
if byOther.TargetName != "Marta Alves" || byOther.Params != `{"keep":"yes"}` {
t.Errorf("an event of another user on this author is not erased: %+v", byOther)
}
if deleteAccountRowCount(t, db, &entity.Article{}, "created_by = ?", 7) != 1 || deleteAccountRowCount(t, db, &entity.Entry{}, "created_by = ?", 7) != 1 || deleteAccountRowCount(t, db, &entity.Image{}, "uploaded_by_id = ?", 7) != 1 {
t.Error("articles, entries and images keep the old id")
}
})
t.Run("The avatar image row is deleted in the same operation", func(t *testing.T) {
db := backend.open(t)
seedDeleteAccountRows(t, db, true)
if err := db.Create(&entity.Image{ID: 9, Path: "/avatar.png", Filename: "avatar.png", UploadedByID: 8}).Error; err != nil {
t.Fatal(err)
}
avatar := uint32(9)
err := persistence.NewAccountEraser(db).Erase(user.Erasure{UserID: 7, AvatarImageID: &avatar})
if err != nil || deleteAccountRowCount(t, db, &entity.Image{}, "id = ?", 9) != 0 || deleteAccountRowCount(t, db, &entity.Image{}, "id = ?", 1) != 1 {
t.Errorf("expected only the avatar image to be gone, got %v", err)
}
})
t.Run("A scrubbed author whose deleted-user slug is taken gets a free suffix", func(t *testing.T) {
db := backend.open(t)
seedDeleteAccountRows(t, db, true)
if err := db.Create(&entity.Author{ID: 20, Name: "Legacy", Slug: "deleted-user-5", SocialLinks: "[]"}).Error; err != nil {
t.Fatal(err)
}
err := persistence.NewAccountEraser(db).Erase(user.Erasure{UserID: 7})
var scrubbed entity.Author
db.First(&scrubbed, 5)
if err != nil || scrubbed.Slug != "deleted-user-5-2" {
t.Errorf("expected deleted-user-5-2, got %q %v", scrubbed.Slug, err)
}
})
t.Run("A failure on the audit step restores the avatar image too", func(t *testing.T) {
db := backend.open(t)
seedDeleteAccountRows(t, db, true)
if err := db.Create(&entity.Image{ID: 9, Path: "/avatar.png", Filename: "avatar.png", UploadedByID: 8}).Error; err != nil {
t.Fatal(err)
}
if err := db.Migrator().DropTable(&entity.AuditEvent{}); err != nil {
t.Fatal(err)
}
avatar := uint32(9)
err := persistence.NewAccountEraser(db).Erase(user.Erasure{UserID: 7, AvatarImageID: &avatar})
if err == nil || deleteAccountRowCount(t, db, &entity.Image{}, "id = ?", 9) != 1 {
t.Errorf("expected a rollback, got %v", err)
}
})
t.Run("Concurrent self-deletions never leave the workspace without a verified Owner", func(t *testing.T) {
db := backend.open(t)
var owner entity.Role
if err := db.Where("name = ?", "Owner").First(&owner).Error; err != nil {
t.Fatal(err)
}
const owners = 4
for id := uint32(1); id <= owners; id++ {
name := fmt.Sprintf("owner%d", id)
row := &entity.User{ID: id, Username: name, Password: "x", Email: name + "@renew.example", IsVerified: true, RoleID: &owner.ID}
if err := db.Create(row).Error; err != nil {
t.Fatal(err)
}
}
eraser := persistence.NewAccountEraser(db)
results := make(chan error, owners)
var start sync.WaitGroup
start.Add(1)
for id := uint32(1); id <= owners; id++ {
go func() {
start.Wait()
results <- eraser.Erase(user.Erasure{UserID: id, GuardRoleIDs: []uint32{owner.ID}})
}()
}
start.Done()
deleted := 0
for i := 0; i < owners; i++ {
err := <-results
if err == nil {
deleted++
} else if !errors.Is(err, user.ErrAccountNotDeletable) {
t.Errorf("a loser must answer ErrAccountNotDeletable, got %v", err)
}
}
remaining := deleteAccountRowCount(t, db, &entity.User{}, "role_id = ? AND is_verified = ?", owner.ID, true)
if remaining < 1 || int(remaining)+deleted != owners {
t.Errorf("expected at least one Owner left and a consistent count, got %d remaining after %d deletions", remaining, deleted)
}
})
t.Run("An author that credits no article is deleted", func(t *testing.T) {
db := backend.open(t)
seedDeleteAccountRows(t, db, false)
err := persistence.NewAccountEraser(db).Erase(user.Erasure{UserID: 7})
if err != nil || deleteAccountRowCount(t, db, &entity.Author{}, "id = ?", 5) != 0 || deleteAccountRowCount(t, db, &entity.Author{}, "id = ?", 6) != 1 {
t.Errorf("expected only author 5 to be gone, got %v", err)
}
})
t.Run("The role holders are counted again inside the operation", func(t *testing.T) {
db := backend.open(t)
seedDeleteAccountRows(t, db, true)
eraser := persistence.NewAccountEraser(db)
refused := eraser.Erase(user.Erasure{UserID: 7, GuardRoleIDs: []uint32{7}})
allowed := eraser.Erase(user.Erasure{UserID: 7, GuardRoleIDs: []uint32{8}})
if !errors.Is(refused, user.ErrAccountNotDeletable) {
t.Errorf("expected ErrAccountNotDeletable, got %v", refused)
}
if allowed != nil || deleteAccountRowCount(t, db, &entity.User{}, "id = ?", 7) != 0 {
t.Errorf("expected the removal once another verified holder exists, got %v", allowed)
}
})
t.Run("A refused re-check removes nothing", func(t *testing.T) {
db := backend.open(t)
seedDeleteAccountRows(t, db, true)
err := persistence.NewAccountEraser(db).Erase(user.Erasure{UserID: 7, GuardRoleIDs: []uint32{7}})
if !errors.Is(err, user.ErrAccountNotDeletable) || deleteAccountRowCount(t, db, &entity.User{}, "id = ?", 7) != 1 || deleteAccountRowCount(t, db, &entity.APIKey{}, "user_id = ?", 7) != 2 {
t.Errorf("expected nothing removed, got %v", err)
}
})
t.Run("A failure half way rolls everything back", func(t *testing.T) {
db := backend.open(t)
seedDeleteAccountRows(t, db, true)
if err := db.Migrator().DropTable(&entity.AuditEvent{}); err != nil {
t.Fatal(err)
}
err := persistence.NewAccountEraser(db).Erase(user.Erasure{UserID: 7})
if err == nil {
t.Fatal("expected the missing events table to fail the operation")
}
var author entity.Author
if db.First(&author, 5).Error != nil || author.Name != "Marta Alves" || author.UserID == nil {
t.Errorf("the author must be restored: %+v", author)
}
if deleteAccountRowCount(t, db, &entity.User{}, "id = ?", 7) != 1 || deleteAccountRowCount(t, db, &entity.APIKey{}, "user_id = ?", 7) != 2 {
t.Error("the user and the keys must be restored")
}
})
})
}
}