package test import ( "RenewCMS/api/handlers/user" "RenewCMS/api/middleware" "RenewCMS/api/pkg/authtoken" validator "RenewCMS/api/pkg/validator" "RenewCMS/internal/domain/apikey" "RenewCMS/internal/domain/article" "RenewCMS/internal/domain/audit" "RenewCMS/internal/domain/author" "RenewCMS/internal/domain/entry" "RenewCMS/internal/domain/image" "RenewCMS/internal/domain/role" domain "RenewCMS/internal/domain/user" "RenewCMS/internal/infrastructure/useCases" "bytes" "errors" "log" "maps" "net/http" "net/http/httptest" "reflect" "slices" "strings" "testing" "time" "github.com/go-chi/chi/v5" ) const ( deleteAccountHost = "https://cms.example.com" deleteAccountEmail = "marta@renew.example" deleteAccountPassword = "limewash2026" deleteAccountBody = `{"current_password": "limewash2026", "confirmation": "DELETE"}` deleteAccountAuthorID = uint32(10) deleteAccountAvatarID = uint32(5) deleteAccountAvatar = "avatar.png" ) type deleteAccountRevoker struct { revoked []string failure error } func (r *deleteAccountRevoker) Revoke(id string, _ time.Time) error { if r.failure != nil { return r.failure } r.revoked = append(r.revoked, id) return nil } type deleteAccountEraserFake struct { users *mockUserRepo keys *mockAPIKeyRepo authors *mockAuthorRepo images *mockImageRepo events *exportAuditFake fail failSwitch beforeErase func() erasures []domain.Erasure } var _ domain.Eraser = &deleteAccountEraserFake{} func (f *deleteAccountEraserFake) Erase(erasure domain.Erasure) error { f.erasures = append(f.erasures, erasure) if f.beforeErase != nil { hook := f.beforeErase f.beforeErase = nil hook() } users, keys, authors, events, images := maps.Clone(f.users.users), maps.Clone(f.keys.keys), maps.Clone(f.authors.authors), slices.Clone(f.events.events), maps.Clone(f.images.images) err := f.run(erasure) if err != nil { f.users.users, f.keys.keys, f.authors.authors, f.events.events, f.images.images = users, keys, authors, events, images } return err } func (f *deleteAccountEraserFake) run(erasure domain.Erasure) error { if len(erasure.GuardRoleIDs) > 0 && f.verifiedHolders(erasure) == 0 { return domain.ErrAccountNotDeletable } if err := f.fail.trigger("DeleteAPIKeys"); err != nil { return err } for id, key := range f.keys.keys { if key.UserID == erasure.UserID { delete(f.keys.keys, id) } } if err := f.fail.trigger("ScrubAuthor"); err != nil { return err } f.releaseAuthor(erasure.UserID) if erasure.AvatarImageID != nil { delete(f.images.images, *erasure.AvatarImageID) } if err := f.fail.trigger("AnonymiseEvents"); err != nil { return err } _ = f.events.AnonymiseUser(erasure.UserID, nil, domain.DeletedUserName) if err := f.fail.trigger("DeleteUser"); err != nil { return err } return f.users.Delete(erasure.UserID) } func (f *deleteAccountEraserFake) verifiedHolders(erasure domain.Erasure) int { holders := 0 for id, candidate := range f.users.users { if id != erasure.UserID && candidate.IsVerified && slices.Contains(erasure.GuardRoleIDs, candidate.RoleID) { holders++ } } return holders } func (f *deleteAccountEraserFake) releaseAuthor(userID uint32) { for id, found := range f.authors.authors { if found.UserID == nil || *found.UserID != userID { continue } if f.authors.withCount(found).ArticleCount == 0 { delete(f.authors.authors, id) continue } f.authors.authors[id] = author.Author{ID: id, Name: author.DeletedName, Slug: author.DeletedSlug(id), SocialLinks: []author.SocialLink{}} } } type deleteAccountFixture struct { dispatcher *changeEmailDispatcher users *mockUserRepo roles *mockRoleRepo keys *mockAPIKeyRepo authors *mockAuthorRepo articles *mockArticleRepo entries *mockEntryRepo images *mockImageRepo events *exportAuditFake recorder *recordingRecorder mail *mockMailRepo revoker *deleteAccountRevoker eraser *deleteAccountEraserFake storage *mockImageStorage variants *mockVariantFileRepo sites *mockSiteRepo token *authtoken.Auth router *chi.Mux removeHits int } func newDeleteAccountFixture(t *testing.T) *deleteAccountFixture { t.Helper() validator.InitValidator() f := &deleteAccountFixture{ dispatcher: &changeEmailDispatcher{}, users: newMockUserRepo(), roles: newSeededMockRoleRepo(), keys: newMockAPIKeyRepo(), articles: newMockArticleRepo(), entries: &mockEntryRepo{entries: map[uint32]entry.Entry{}}, images: &mockImageRepo{images: map[uint32]image.Image{}}, events: &exportAuditFake{}, recorder: &recordingRecorder{}, mail: newMockMailRepo(), revoker: &deleteAccountRevoker{}, storage: newMockImageStorage(), variants: newMockVariantFileRepo(), sites: plainSites(), token: authtoken.New([]byte("test-secret")), } f.authors = newMockAuthorRepo(f.articles) f.seed() f.eraser = &deleteAccountEraserFake{users: f.users, keys: f.keys, authors: f.authors, images: f.images, events: f.events} f.mountRouter() return f } func (f *deleteAccountFixture) seed() { password := "hashed:" + deleteAccountPassword f.users.users[7] = domain.User{ID: 7, Username: "marta", Email: deleteAccountEmail, Password: password, IsVerified: true, RoleID: writerRoleID} f.users.users[2] = domain.User{ID: 2, Username: "jonas", Email: "jonas@renew.example", Password: password, IsVerified: true, RoleID: adminRoleID} f.users.users[3] = domain.User{ID: 3, Username: "ines", Email: "ines@renew.example", Password: password, IsVerified: true, RoleID: ownerRoleID} f.users.users[9] = domain.User{ID: 9, Username: "paul", Email: "paul@renew.example", Password: password, IsVerified: false, RoleID: writerRoleID} f.users.nextID = 9 f.keys.keys[1] = apikey.APIKey{ID: 1, UserID: 7, Name: "ci"} f.keys.keys[2] = apikey.APIKey{ID: 2, UserID: 7, Name: "deploy"} f.keys.keys[3] = apikey.APIKey{ID: 3, UserID: 2, Name: "jonas key"} f.authors.seedLinked(deleteAccountAuthorID, "Marta Alves", "marta-alves", 7) f.authors.update(deleteAccountAuthorID, func(a *author.Author) { a.Bio = "Writes about lime." website := "https://marta.example" a.Website = &website a.AvatarID = idPointer(deleteAccountAvatarID) a.SocialLinks = []author.SocialLink{{Type: author.SocialGitHub, URL: "https://github.com/marta"}} }) for id := uint32(1); id <= 3; id++ { f.articles.articles[id] = article.Article{ID: id, Title: "Article", CreatedBy: 7} f.articles.credits[id] = []uint32{deleteAccountAuthorID} } for id := uint32(1); id <= 2; id++ { f.entries.entries[id] = entry.Entry{ID: id, CreatedBy: 7, UpdatedBy: 7} } for id := uint32(1); id <= 4; id++ { f.images.images[id] = image.Image{ID: id, Filename: "a.png", UploadedByID: 7} } f.images.images[deleteAccountAvatarID] = image.Image{ID: deleteAccountAvatarID, Path: image.PublicPath(deleteAccountAvatar), Filename: deleteAccountAvatar, UploadedByID: 2} f.storage.blobs[deleteAccountAvatar] = []byte("avatar") for i := 0; i < 12; i++ { ip, agent, martaID := "203.0.113.7", "Firefox", uint32(7) f.events.events = append(f.events.events, audit.Event{ ID: uint64(i + 1), Action: "article.edit", Actor: audit.Actor{Type: audit.ActorUser, ID: &martaID, Name: "marta"}, Target: audit.Target{Type: audit.TargetArticle, ID: "1", Name: "Article"}, IP: &ip, UserAgent: &agent, }) } jonasID := uint32(2) f.events.events = append(f.events.events, audit.Event{ ID: 13, Action: "user.change_role", Actor: audit.Actor{Type: audit.ActorUser, ID: &jonasID, Name: "jonas"}, Target: audit.Target{Type: audit.TargetUser, ID: "7", Name: "marta"}, }) } func (f *deleteAccountFixture) mountRouter() { getUser := useCases.NewGetUserUseCase(f.users) usage := useCases.NewGetMediaUsageUseCase(f.images, f.articles, f.sites, f.authors, newMockContentTypeRepo(), f.entries) deleteUseCase := useCases.NewDeleteOwnAccountUseCase(f.users, f.roles, &stubHasher{}, f.eraser, f.revoker, f.mail, f.dispatcher, deleteAccountHost, f.recorder, f.authors, f.images, usage, f.storage, f.variants) handler := user.NewDeleteAccountHandler(deleteUseCase, false) f.router = chi.NewRouter() f.router.Route("/v1/user", func(r chi.Router) { r.Group(func(r chi.Router) { r.Use(middleware.RequireSession) r.Use(authtoken.Verifier(f.token)) r.Use(middleware.RequireSignedIn) r.Use(authtoken.Authenticator) r.Use(middleware.RequireCurrentToken(getUser)) r.Use(middleware.RequireVerifiedUser(getUser)) r.Use(middleware.NewUserRateLimiter(5, 15*time.Minute)) r.Delete("/me", handler.Delete) }) r.Group(func(r chi.Router) { r.Delete("/{id}", func(w http.ResponseWriter, _ *http.Request) { f.removeHits++ w.WriteHeader(http.StatusNoContent) }) }) }) f.router.Route("/v1/auth", func(r chi.Router) { r.Use(authtoken.Verifier(f.token)) r.Use(authtoken.Authenticator) r.Use(middleware.RequireCurrentToken(getUser)) r.Get("/me", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }) }) } func (f *deleteAccountFixture) request(t *testing.T, userID uint32, body string) *httptest.ResponseRecorder { t.Helper() req := httptest.NewRequest(http.MethodDelete, "/v1/user/me", bytes.NewBufferString(body)) req.AddCookie(displayNameCookie(t, f.token, userID, 0)) return f.serve(req) } func (f *deleteAccountFixture) serve(req *http.Request) *httptest.ResponseRecorder { rec := httptest.NewRecorder() f.router.ServeHTTP(rec, req) f.dispatcher.Drain() return rec } type deleteAccountState struct { users map[uint32]domain.User keys map[uint32]apikey.APIKey authors map[uint32]author.Author events []audit.Event articles map[uint32]article.Article entries map[uint32]entry.Entry images map[uint32]image.Image } func (f *deleteAccountFixture) state() deleteAccountState { return deleteAccountState{ users: maps.Clone(f.users.users), keys: maps.Clone(f.keys.keys), authors: f.authors.snapshot(), events: slices.Clone(f.events.events), articles: maps.Clone(f.articles.articles), entries: maps.Clone(f.entries.entries), images: maps.Clone(f.images.images), } } func (f *deleteAccountFixture) assertUntouchedSince(t *testing.T, before deleteAccountState) { t.Helper() if !reflect.DeepEqual(before, f.state()) { t.Error("expected the users, keys, authors, content and events to be unchanged") } if len(f.mail.SentEmails) != 0 { t.Errorf("expected no mail, got %d", len(f.mail.SentEmails)) } if len(f.revoker.revoked) != 0 { t.Errorf("expected no revoked session, got %v", f.revoker.revoked) } if len(f.storage.deleted) != 0 { t.Errorf("expected no deleted file, got %v", f.storage.deleted) } } func (f *deleteAccountFixture) lastEvent(t *testing.T) audit.Event { t.Helper() if len(f.recorder.events) != 1 { t.Fatalf("expected one audit event, got %d", len(f.recorder.events)) } return f.recorder.events[0] } func assertDeleteAccountResponse(t *testing.T, rec *httptest.ResponseRecorder, status int, body string) { t.Helper() if rec.Code != status || strings.TrimSpace(rec.Body.String()) != body { t.Fatalf("expected %d %q, got %d %q", status, body, rec.Code, rec.Body.String()) } } func TestAccountDeleteEndpointHTTP(t *testing.T) { t.Run("A Writer deletes their account", func(t *testing.T) { f := newDeleteAccountFixture(t) articles, entries, images := maps.Clone(f.articles.articles), maps.Clone(f.entries.entries), maps.Clone(f.images.images) delete(images, deleteAccountAvatarID) rec := f.request(t, 7, deleteAccountBody) assertDeleteAccountResponse(t, rec, http.StatusNoContent, "") expired := false for _, cookie := range rec.Result().Cookies() { expired = expired || cookie.Name == "jwt" && cookie.MaxAge < 0 && cookie.Value == "" && cookie.HttpOnly } if !expired { t.Errorf("expected an expired jwt cookie, got %v", rec.Header().Values("Set-Cookie")) } if _, exists := f.users.users[7]; exists { t.Error("user 7 must be gone") } if _, one := f.keys.keys[1]; one || len(f.keys.keys) != 1 { t.Errorf("keys 1 and 2 must be gone, got %v", f.keys.keys) } scrubbed := f.authors.authors[deleteAccountAuthorID] if scrubbed.Name != "Deleted user" || scrubbed.Slug != "deleted-user-10" || scrubbed.UserID != nil || scrubbed.Bio != "" || scrubbed.AvatarID != nil || scrubbed.Website != nil || len(scrubbed.SocialLinks) != 0 { t.Errorf("unexpected scrubbed author %+v", scrubbed) } if !reflect.DeepEqual(articles, f.articles.articles) || !reflect.DeepEqual(entries, f.entries.entries) || !reflect.DeepEqual(images, f.images.images) { t.Error("articles, entries and images must be unchanged") } for _, event := range f.events.events[:12] { if event.Actor.Name != "Deleted user" || event.IP != nil || event.UserAgent != nil { t.Errorf("event %d not anonymised: %+v", event.ID, event) } } if f.events.events[12].Actor.Name != "jonas" || f.events.events[12].Target.Name != "Deleted user" { t.Errorf("another user's event keeps their name but loses the target name: %+v", f.events.events[12]) } old := httptest.NewRequest(http.MethodGet, "/v1/auth/me", nil) old.AddCookie(displayNameCookie(t, f.token, 7, 0)) if got := f.serve(old); got.Code != http.StatusUnauthorized { t.Errorf("expected 401 for the old jwt, got %d", got.Code) } if len(f.revoker.revoked) != 1 || f.revoker.revoked[0] == "" { t.Errorf("expected the session id to be revoked, got %v", f.revoker.revoked) } if len(f.mail.SentEmails) != 1 { t.Fatalf("expected one mail, got %d", len(f.mail.SentEmails)) } sent := f.mail.SentEmails[0] if sent.Address != deleteAccountEmail || sent.Template != "account-deleted" || sent.Data != (useCases.MailAccountDeletedData{Host: deleteAccountHost}) { t.Errorf("unexpected mail %+v", sent) } event := f.lastEvent(t) martaID := uint32(7) if event.Action != "user.delete_account" || event.Actor.Name != "Deleted user" || *event.Actor.ID != martaID || event.Target.Name != "Deleted user" || event.Target.ID != "7" || !event.Security || event.Outcome != audit.OutcomeSuccess || event.Category != audit.CategoryUsers { t.Errorf("unexpected audit event %+v", event) } for key, value := range event.Params { if text, isText := value.(string); isText && strings.Contains(text, "@") { t.Errorf("params must hold no email, got %s=%v", key, value) } } }) t.Run("An author profile that credits no article is deleted", func(t *testing.T) { f := newDeleteAccountFixture(t) f.articles.credits = map[uint32][]uint32{} rec := f.request(t, 7, deleteAccountBody) assertDeleteAccountResponse(t, rec, http.StatusNoContent, "") if _, exists := f.authors.authors[deleteAccountAuthorID]; exists { t.Error("the author row must be gone") } }) t.Run("The avatar of the author profile is deleted with its stored file", func(t *testing.T) { f := newDeleteAccountFixture(t) rec := f.request(t, 7, deleteAccountBody) assertDeleteAccountResponse(t, rec, http.StatusNoContent, "") if _, exists := f.images.images[deleteAccountAvatarID]; exists { t.Error("the avatar image row must be gone") } if !slices.Contains(f.storage.deleted, deleteAccountAvatar) { t.Errorf("expected the stored file to be deleted, got %v", f.storage.deleted) } }) t.Run("An avatar that is used elsewhere is kept", func(t *testing.T) { cases := []struct { name string prepare func(f *deleteAccountFixture) }{ {"an article body", func(f *deleteAccountFixture) { stored := f.articles.articles[1] stored.Body = "" f.articles.articles[1] = stored }}, {"another author", func(f *deleteAccountFixture) { f.authors.seed(11, "Jonas", "jonas") f.authors.update(11, func(a *author.Author) { a.AvatarID = idPointer(deleteAccountAvatarID) }) }}, {"an article cover", func(f *deleteAccountFixture) { stored := f.articles.articles[2] stored.CoverImageID = idPointer(deleteAccountAvatarID) f.articles.articles[2] = stored }}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { f := newDeleteAccountFixture(t) c.prepare(f) rec := f.request(t, 7, deleteAccountBody) assertDeleteAccountResponse(t, rec, http.StatusNoContent, "") if _, exists := f.images.images[deleteAccountAvatarID]; !exists { t.Error("the shared avatar image must stay") } if len(f.storage.deleted) != 0 { t.Errorf("no file may be deleted, got %v", f.storage.deleted) } }) } }) t.Run("A storage failure after the commit is logged and the answer stays 204", func(t *testing.T) { f := newDeleteAccountFixture(t) f.storage.failDelete = errRepositoryFailure var logged bytes.Buffer previous := log.Writer() log.SetOutput(&logged) defer log.SetOutput(previous) rec := f.request(t, 7, deleteAccountBody) assertDeleteAccountResponse(t, rec, http.StatusNoContent, "") if _, exists := f.users.users[7]; exists { t.Error("the user must be gone") } if !strings.Contains(logged.String(), "stored avatar") { t.Errorf("expected the storage failure in the log, got %q", logged.String()) } }) t.Run("A user without an avatar deletes no file", func(t *testing.T) { f := newDeleteAccountFixture(t) f.authors.update(deleteAccountAuthorID, func(a *author.Author) { a.AvatarID = nil }) rec := f.request(t, 7, deleteAccountBody) assertDeleteAccountResponse(t, rec, http.StatusNoContent, "") if len(f.storage.deleted) != 0 { t.Errorf("no file may be deleted, got %v", f.storage.deleted) } }) t.Run("Refused requests change nothing", func(t *testing.T) { validator.InitValidator() confirmationMessage := validator.Validate.Struct(user.DeleteAccountRequest{CurrentPassword: "x", Confirmation: "delete"}).Error() emptyConfirmation := validator.Validate.Struct(user.DeleteAccountRequest{CurrentPassword: "x"}).Error() passwordMessage := validator.Validate.Struct(user.DeleteAccountRequest{Confirmation: "DELETE"}).Error() cases := []struct { name string body string status int text string }{ {"lowercase confirmation", `{"current_password": "limewash2026", "confirmation": "delete"}`, 400, confirmationMessage}, {"empty confirmation", `{"current_password": "limewash2026", "confirmation": ""}`, 400, emptyConfirmation}, {"trailing space confirmation", `{"current_password": "limewash2026", "confirmation": "DELETE "}`, 400, confirmationMessage}, {"empty password", `{"current_password": "", "confirmation": "DELETE"}`, 400, passwordMessage}, {"not json", `not-json`, 400, "The request cannot be processed due to a mismatch in the format of the body."}, {"wrong password", `{"current_password": "nope", "confirmation": "DELETE"}`, 403, "wrong_password"}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { f := newDeleteAccountFixture(t) before := f.state() rec := f.request(t, 7, c.body) assertDeleteAccountResponse(t, rec, c.status, c.text) f.assertUntouchedSince(t, before) }) } }) t.Run("The last Owner or administrator is blocked", func(t *testing.T) { cases := []struct { name string caller uint32 prepare func(f *deleteAccountFixture) status int header string }{ {"Owner, no other Owner, one Admin", 3, func(f *deleteAccountFixture) {}, 409, "Owner"}, {"Owner, no other Owner, no Admin", 3, func(f *deleteAccountFixture) { delete(f.users.users, 2) }, 409, "Owner"}, {"Owner, another verified Owner", 3, func(f *deleteAccountFixture) { f.users.users[4] = domain.User{ID: 4, Username: "ana", IsVerified: true, RoleID: ownerRoleID} }, 204, ""}, {"Owner, only an unverified Owner", 3, func(f *deleteAccountFixture) { f.users.users[4] = domain.User{ID: 4, Username: "ana", IsVerified: false, RoleID: ownerRoleID} }, 409, "Owner"}, {"Admin, no Owner and no other Admin", 2, func(f *deleteAccountFixture) { delete(f.users.users, 3) }, 409, "Admin"}, {"Admin, one Owner", 2, func(f *deleteAccountFixture) {}, 204, ""}, {"Admin, another verified Admin and no Owner", 2, func(f *deleteAccountFixture) { delete(f.users.users, 3) f.users.users[4] = domain.User{ID: 4, Username: "ana", IsVerified: true, RoleID: adminRoleID} }, 204, ""}, {"Admin, only an unverified Admin and no Owner", 2, func(f *deleteAccountFixture) { delete(f.users.users, 3) f.users.users[4] = domain.User{ID: 4, Username: "ana", IsVerified: false, RoleID: adminRoleID} }, 409, "Admin"}, {"Writer, no Owner and no Admin", 7, func(f *deleteAccountFixture) { delete(f.users.users, 3) delete(f.users.users, 2) }, 204, ""}, {"a custom role with every admin permission, no Owner and no Admin", 7, func(f *deleteAccountFixture) { delete(f.users.users, 3) delete(f.users.users, 2) custom, _ := f.roles.Create(role.Role{Name: "Everything but owner", Grants: f.roles.byName(role.AdminName).Grants}) caller := f.users.users[7] caller.RoleID = custom.ID f.users.users[7] = caller }, 204, ""}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { f := newDeleteAccountFixture(t) c.prepare(f) before := f.state() rec := f.request(t, c.caller, deleteAccountBody) if rec.Code != c.status { t.Fatalf("expected %d, got %d: %s", c.status, rec.Code, rec.Body.String()) } if c.status == 409 { assertDeleteAccountResponse(t, rec, 409, "last_admin") f.assertUntouchedSince(t, before) } if got := rec.Header().Get("X-Last-Role"); got != c.header { t.Errorf("expected X-Last-Role %q, got %q", c.header, got) } }) } }) t.Run("A blocked attempt leaves everything and records a failed event", func(t *testing.T) { f := newDeleteAccountFixture(t) before := f.state() rec := f.request(t, 3, deleteAccountBody) assertDeleteAccountResponse(t, rec, http.StatusConflict, "last_admin") f.assertUntouchedSince(t, before) event := f.lastEvent(t) if event.Action != "user.delete_account" || event.Outcome != audit.OutcomeFailed || !event.Security || event.FailureReason == nil || *event.FailureReason != "Last Owner." { t.Errorf("unexpected audit event %+v", event) } }) t.Run("A failed attempt records the reason on the user's own events", func(t *testing.T) { f := newDeleteAccountFixture(t) f.users.users[2] = domain.User{ID: 2, Username: "jonas", Password: "hashed:" + deleteAccountPassword, IsVerified: true, RoleID: adminRoleID} delete(f.users.users, 3) wrong := f.request(t, 2, `{"current_password": "nope", "confirmation": "DELETE"}`) blocked := f.request(t, 2, deleteAccountBody) if wrong.Code != 403 || blocked.Code != 409 || len(f.recorder.events) != 2 { t.Fatalf("unexpected outcome %d %d %d", wrong.Code, blocked.Code, len(f.recorder.events)) } if *f.recorder.events[0].FailureReason != "Wrong password." || *f.recorder.events[1].FailureReason != "Last administrator." { t.Errorf("unexpected reasons %q %q", *f.recorder.events[0].FailureReason, *f.recorder.events[1].FailureReason) } }) t.Run("A wrong password is reported before the last-role check", func(t *testing.T) { f := newDeleteAccountFixture(t) rec := f.request(t, 3, `{"current_password": "nope", "confirmation": "DELETE"}`) assertDeleteAccountResponse(t, rec, http.StatusForbidden, "wrong_password") if rec.Header().Get("X-Last-Role") != "" { t.Error("the role must not leak without the password") } }) t.Run("A concurrent role change stops the removal", func(t *testing.T) { f := newDeleteAccountFixture(t) f.users.users[4] = domain.User{ID: 4, Username: "ana", IsVerified: true, RoleID: ownerRoleID} f.eraser.beforeErase = func() { demoted := f.users.users[4] demoted.RoleID = writerRoleID f.users.users[4] = demoted } before := f.state() before.users[4] = domain.User{ID: 4, Username: "ana", IsVerified: true, RoleID: writerRoleID} rec := f.request(t, 3, deleteAccountBody) assertDeleteAccountResponse(t, rec, http.StatusConflict, "account_not_deletable") f.assertUntouchedSince(t, before) if event := f.lastEvent(t); event.Outcome != audit.OutcomeFailed || *event.FailureReason == useCases.GenericFailureReason { t.Errorf("expected a specific failure reason, got %+v", event) } }) t.Run("A mail failure does not undo the deletion", func(t *testing.T) { f := newDeleteAccountFixture(t) f.mail.fail.failNext("Send") var logged bytes.Buffer previous := log.Writer() log.SetOutput(&logged) defer log.SetOutput(previous) rec := f.request(t, 7, deleteAccountBody) assertDeleteAccountResponse(t, rec, http.StatusNoContent, "") if _, exists := f.users.users[7]; exists { t.Error("the user must be gone") } if !strings.Contains(logged.String(), "account deleted mail") { t.Errorf("expected the failed send in the log, got %q", logged.String()) } }) t.Run("A failure in the removal rolls everything back", func(t *testing.T) { for _, step := range []string{"DeleteAPIKeys", "ScrubAuthor", "AnonymiseEvents", "DeleteUser"} { t.Run(step, func(t *testing.T) { f := newDeleteAccountFixture(t) f.eraser.fail.failNext(step) before := f.state() rec := f.request(t, 7, deleteAccountBody) assertDeleteAccountResponse(t, rec, http.StatusInternalServerError, "Internal server error") f.assertUntouchedSince(t, before) if event := f.lastEvent(t); event.Outcome != audit.OutcomeFailed || *event.FailureReason != useCases.GenericFailureReason { t.Errorf("unexpected audit event %+v", event) } }) } }) t.Run("A failing session revocation does not undo the deletion", func(t *testing.T) { f := newDeleteAccountFixture(t) f.revoker.failure = errRepositoryFailure var logged bytes.Buffer previous := log.Writer() log.SetOutput(&logged) defer log.SetOutput(previous) rec := f.request(t, 7, deleteAccountBody) assertDeleteAccountResponse(t, rec, http.StatusNoContent, "") if !strings.Contains(logged.String(), "revoking the session") { t.Errorf("expected the failed revocation in the log, got %q", logged.String()) } }) t.Run("Rate limited after five attempts", func(t *testing.T) { f := newDeleteAccountFixture(t) for i := 0; i < 5; i++ { f.request(t, 7, `{"current_password": "nope", "confirmation": "DELETE"}`) } before := f.state() rec := f.request(t, 7, deleteAccountBody) assertDeleteAccountResponse(t, rec, http.StatusTooManyRequests, "Too many requests. Please try again later.") if rec.Header().Get("Retry-After") == "" { t.Error("expected a Retry-After header") } f.assertUntouchedSince(t, before) }) t.Run("Only a real verified session may delete", func(t *testing.T) { cases := []struct { name string status int text string build func(f *deleteAccountFixture) *http.Request }{ {"nobody", 401, "You must be logged in to access this resource.", func(f *deleteAccountFixture) *http.Request { return httptest.NewRequest(http.MethodDelete, "/v1/user/me", bytes.NewBufferString(deleteAccountBody)) }}, {"an API key bearer", 403, "This action needs a real session.", func(f *deleteAccountFixture) *http.Request { req := httptest.NewRequest(http.MethodDelete, "/v1/user/me", bytes.NewBufferString(deleteAccountBody)) req.Header.Set("Authorization", "Bearer rcms_anything") return req }}, {"an unverified user", 403, "You must verify your account to access this resource.", func(f *deleteAccountFixture) *http.Request { req := httptest.NewRequest(http.MethodDelete, "/v1/user/me", bytes.NewBufferString(deleteAccountBody)) req.AddCookie(displayNameCookie(t, f.token, 9, 0)) return req }}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { f := newDeleteAccountFixture(t) before := f.state() rec := f.serve(c.build(f)) assertDeleteAccountResponse(t, rec, c.status, c.text) f.assertUntouchedSince(t, before) }) } }) t.Run("DELETE /v1/user/{id} is unchanged", func(t *testing.T) { f := newDeleteAccountFixture(t) other := f.serve(httptest.NewRequest(http.MethodDelete, "/v1/user/7?expected_role_id=3", nil)) self := f.request(t, 7, deleteAccountBody) if other.Code != http.StatusNoContent || f.removeHits != 1 { t.Errorf("expected /{id} to reach its own handler once, got %d and %d hits", other.Code, f.removeHits) } if self.Code != http.StatusNoContent || f.removeHits != 1 { t.Errorf("expected /me to bypass the {id} handler, got %d and %d hits", self.Code, f.removeHits) } }) } func TestAccountDeleteLastAdminErrorContract(t *testing.T) { owner := domain.LastAdminError{Role: domain.LastRoleOwner} admin := domain.LastAdminError{Role: domain.LastRoleAdmin} if owner.Error() != domain.ErrLastOwner.Error() || admin.Error() != domain.ErrLastAdmin.Error() { t.Errorf("unexpected messages %q / %q", owner.Error(), admin.Error()) } if !errors.Is(owner, domain.ErrLastOwner) || !errors.Is(owner, domain.ErrLastAdmin) { t.Error("the last Owner must match both sentinels") } if !errors.Is(admin, domain.ErrLastAdmin) || errors.Is(admin, domain.ErrLastOwner) { t.Error("the last administrator must match only ErrLastAdmin") } }