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") } }) }) } }