Files
RenewCMS/test/account_delete_http_test.go
T

776 lines
29 KiB
Go

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 = "<img src=\"" + image.PublicPath(deleteAccountAvatar) + "\">"
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")
}
}