776 lines
29 KiB
Go
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")
|
|
}
|
|
}
|