268 lines
13 KiB
Go
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")
|
|
}
|
|
})
|
|
})
|
|
}
|
|
}
|