feat: change email, export my data, delete account (with data anonymization)

This commit is contained in:
Florian Sylvain committed 2026-10-30 20:19:07 +01:00
1 parent bb191c389a
commit 10a66cd979
123 files changed
+10789 -76

No files matched your search

@@ -0,0 +1,84 @@
package test
import (
"RenewCMS/internal/infrastructure/mailer"
"bytes"
"log"
"strings"
"sync"
"testing"
"time"
)
type dispatcherLogBuffer struct {
mu sync.Mutex
buffer bytes.Buffer
}
func (b *dispatcherLogBuffer) Write(p []byte) (int, error) {
b.mu.Lock()
defer b.mu.Unlock()
return b.buffer.Write(p)
}
func (b *dispatcherLogBuffer) String() string {
b.mu.Lock()
defer b.mu.Unlock()
return b.buffer.String()
}
func TestAccountChangeEmailAsyncDispatcherRunsJobInBackground(t *testing.T) {
dispatcher := mailer.NewAsyncDispatcher()
release := make(chan struct{})
done := make(chan struct{})
dispatcher.Dispatch(func() {
<-release
close(done)
})
select {
case <-done:
t.Fatal("job completed before it was released, so Dispatch did not return independently")
default:
}
close(release)
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("dispatched job never ran")
}
}
func TestAccountChangeEmailAsyncDispatcherRecoversAndLogsPanic(t *testing.T) {
buffer := &dispatcherLogBuffer{}
previous := log.Writer()
log.SetOutput(buffer)
defer log.SetOutput(previous)
dispatcher := mailer.NewAsyncDispatcher()
finished := make(chan struct{})
dispatcher.Dispatch(func() {
defer close(finished)
panic("boom")
})
select {
case <-finished:
case <-time.After(2 * time.Second):
t.Fatal("panicking job never ran")
}
deadline := time.Now().Add(2 * time.Second)
for !strings.Contains(buffer.String(), "boom") && time.Now().Before(deadline) {
time.Sleep(5 * time.Millisecond)
}
if !strings.Contains(buffer.String(), "panic in a background mail job: boom") {
t.Fatalf("panic was not logged, got %q", buffer.String())
}
followUp := make(chan struct{})
dispatcher.Dispatch(func() { close(followUp) })
select {
case <-followUp:
case <-time.After(2 * time.Second):
t.Fatal("dispatcher unusable after a recovered panic")
}
}
+698
View File
@@ -0,0 +1,698 @@
package test
import (
"RenewCMS/api/handlers/auth"
"RenewCMS/api/middleware"
"RenewCMS/api/pkg/authtoken"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/mail"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"bytes"
"context"
"errors"
"log"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"github.com/go-chi/chi/v5"
)
const (
changeEmailHost = "https://cms.example.com"
changeEmailOld = "marta@renew.example"
changeEmailNew = "marta.alves@atelier-renew.example"
changeEmailPassword = "limewash2026"
)
type changeEmailDispatcher struct {
mu sync.Mutex
jobs []func()
}
func (d *changeEmailDispatcher) Dispatch(job func()) {
d.mu.Lock()
defer d.mu.Unlock()
d.jobs = append(d.jobs, job)
}
func (d *changeEmailDispatcher) Drain() {
d.mu.Lock()
jobs := d.jobs
d.jobs = nil
d.mu.Unlock()
for _, job := range jobs {
job()
}
}
func (d *changeEmailDispatcher) pending() int {
d.mu.Lock()
defer d.mu.Unlock()
return len(d.jobs)
}
var _ mail.Dispatcher = &changeEmailDispatcher{}
type changeEmailFixture struct {
dispatcher *changeEmailDispatcher
users *mockUserRepo
mail *mockMailRepo
recorder *recordingRecorder
token *authtoken.Auth
router *chi.Mux
}
func newChangeEmailFixture(t *testing.T) *changeEmailFixture {
t.Helper()
validator.InitValidator()
f := &changeEmailFixture{
dispatcher: &changeEmailDispatcher{},
users: newMockUserRepo(),
mail: newMockMailRepo(),
recorder: &recordingRecorder{},
token: authtoken.New([]byte("test-secret")),
}
f.users.users[7] = user.User{ID: 7, Username: "marta", Email: changeEmailOld, Password: "hashed:" + changeEmailPassword, IsVerified: true}
f.users.users[8] = user.User{ID: 8, Username: "ines", Email: "ines@renew.example", Password: "hashed:other", IsVerified: true}
f.users.nextID = 8
hasher := &stubHasher{}
getUser := useCases.NewGetUserUseCase(f.users)
handler := auth.NewEmailChangeHandler(
useCases.NewRequestEmailChangeUseCase(f.users, hasher, f.mail, f.dispatcher, changeEmailHost, time.Now, f.recorder),
useCases.NewResendEmailChangeUseCase(f.users, hasher, f.mail, f.dispatcher, changeEmailHost, time.Now, f.recorder),
useCases.NewCancelEmailChangeUseCase(f.users, f.recorder),
useCases.NewConfirmEmailChangeUseCase(f.users, hasher, time.Now, f.recorder),
)
f.router = chi.NewRouter()
f.router.Route("/v1/auth", 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.Delete("/email/change", handler.Cancel)
r.Post("/email/confirm", handler.Confirm)
r.Group(func(r chi.Router) {
r.Use(middleware.NewUserRateLimiter(5, 15*time.Minute))
r.Post("/email/change", handler.Request)
r.Post("/email/resend", handler.Resend)
})
})
return f
}
func (f *changeEmailFixture) as(t *testing.T, userId uint32, method, target, body string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(method, target, bytes.NewBufferString(body))
req.AddCookie(displayNameCookie(t, f.token, userId, 0))
rec := httptest.NewRecorder()
f.router.ServeHTTP(rec, req)
f.dispatcher.Drain()
return rec
}
func (f *changeEmailFixture) request(t *testing.T, body string) *httptest.ResponseRecorder {
t.Helper()
return f.as(t, 7, http.MethodPost, "/v1/auth/email/change", body)
}
func (f *changeEmailFixture) requestFor(t *testing.T, newEmail string) *httptest.ResponseRecorder {
t.Helper()
return f.request(t, `{"new_email": "`+newEmail+`", "current_password": "`+changeEmailPassword+`"}`)
}
func (f *changeEmailFixture) resend(t *testing.T) *httptest.ResponseRecorder {
t.Helper()
return f.as(t, 7, http.MethodPost, "/v1/auth/email/resend", "")
}
func (f *changeEmailFixture) cancel(t *testing.T) *httptest.ResponseRecorder {
t.Helper()
return f.as(t, 7, http.MethodDelete, "/v1/auth/email/change", "")
}
func (f *changeEmailFixture) confirm(t *testing.T, userId uint32, code string) *httptest.ResponseRecorder {
t.Helper()
return f.as(t, userId, http.MethodPost, "/v1/auth/email/confirm", `{"code": "`+code+`"}`)
}
func (f *changeEmailFixture) lastVerificationCode(t *testing.T) string {
t.Helper()
for i := len(f.mail.SentEmails) - 1; i >= 0; i-- {
if data, ok := f.mail.SentEmails[i].Data.(useCases.MailVerifyNewEmailData); ok {
return data.VerificationCode
}
}
t.Fatal("no verify-new-email mail was sent")
return ""
}
func (f *changeEmailFixture) mailsTo(address string) []recordedMail {
var sent []recordedMail
for _, m := range f.mail.SentEmails {
if m.Address == address {
sent = append(sent, m)
}
}
return sent
}
func (f *changeEmailFixture) marta() user.User {
return f.users.users[7]
}
func assertChangeEmailResponse(t *testing.T, rec *httptest.ResponseRecorder, status int, body string) {
t.Helper()
if rec.Code != status {
t.Fatalf("expected status %d, got %d: %s", status, rec.Code, rec.Body.String())
}
if strings.TrimSpace(rec.Body.String()) != body {
t.Fatalf("expected body %q, got %q", body, rec.Body.String())
}
}
func assertNothingPending(t *testing.T, f *changeEmailFixture) {
t.Helper()
if marta := f.marta(); marta.PendingEmail != "" || marta.EmailChangeCode != "" || !marta.EmailChangeExpiration.IsZero() {
t.Errorf("expected no pending change, got %+v", marta)
}
}
func TestAccountChangeEmailRequestHTTP(t *testing.T) {
t.Run("A valid request stores a pending change and sends two mails", func(t *testing.T) {
f := newChangeEmailFixture(t)
rec := f.requestFor(t, changeEmailNew)
assertChangeEmailResponse(t, rec, http.StatusOK, `{"pending_email":"`+changeEmailNew+`"}`)
marta := f.marta()
if marta.Email != changeEmailOld {
t.Errorf("users.email must be untouched, got %q", marta.Email)
}
rawCode := f.lastVerificationCode(t)
if marta.PendingEmail != changeEmailNew || marta.EmailChangeCode == "" || marta.EmailChangeCode == rawCode {
t.Errorf("expected the pending email and a hashed code, got %+v", marta)
}
if delta := time.Until(marta.EmailChangeExpiration.Add(-2 * time.Hour)); delta > 5*time.Second || delta < -5*time.Second {
t.Errorf("expected the expiration within 5 seconds of now plus 2 hours, got %v", marta.EmailChangeExpiration)
}
if len(f.mail.SentEmails) != 2 {
t.Fatalf("expected two mails, got %d", len(f.mail.SentEmails))
}
verify := f.mailsTo(changeEmailNew)
if len(verify) != 1 || verify[0].Template != "verify-new-email" {
t.Fatalf("expected one verify-new-email mail to the new address, got %+v", verify)
}
if data := verify[0].Data.(useCases.MailVerifyNewEmailData); data.Host != changeEmailHost || data.Username != "marta" || data.VerificationCode != rawCode {
t.Errorf("unexpected verify data %+v", data)
}
notice := f.mailsTo(changeEmailOld)
if len(notice) != 1 || notice[0].Template != "email-change-notice" {
t.Fatalf("expected one email-change-notice to the old address, got %+v", notice)
}
if data := notice[0].Data.(useCases.MailEmailChangeNoticeData); data.Host != changeEmailHost || data.Username != "marta" || data.NewEmail != changeEmailNew {
t.Errorf("unexpected notice data %+v", data)
}
if len(f.recorder.events) != 1 {
t.Fatalf("expected one audit event, got %d", len(f.recorder.events))
}
event := f.recorder.events[0]
if event.Action != "user.request_email_change" || event.Category != audit.CategoryUsers || event.Outcome != audit.OutcomeSuccess || !event.Security || event.Target.ID != "7" || len(event.Params) != 0 {
t.Errorf("unexpected audit event %+v", event)
}
})
refused := []struct {
name string
body string
status int
text string
}{
{"an invalid address", `{"new_email": "marta.alves@atelier", "current_password": "limewash2026"}`, http.StatusBadRequest, "NewEmail"},
{"an empty address", `{"new_email": "", "current_password": "limewash2026"}`, http.StatusBadRequest, "NewEmail"},
{"an empty password", `{"new_email": "marta.alves@atelier-renew.example", "current_password": ""}`, http.StatusBadRequest, "CurrentPassword"},
{"a body that is not json", `not-json`, http.StatusBadRequest, "The request cannot be processed due to a mismatch in the format of the body."},
{"the current address in another case", `{"new_email": "MARTA@renew.example", "current_password": "limewash2026"}`, http.StatusBadRequest, "same_email"},
{"a wrong password", `{"new_email": "marta.alves@atelier-renew.example", "current_password": "nope"}`, http.StatusForbidden, "wrong_password"},
}
for _, tc := range refused {
t.Run("A refused request changes nothing and sends nothing: "+tc.name, func(t *testing.T) {
f := newChangeEmailFixture(t)
rec := f.request(t, tc.body)
if rec.Code != tc.status || !strings.Contains(rec.Body.String(), tc.text) {
t.Errorf("expected %d containing %q, got %d %q", tc.status, tc.text, rec.Code, rec.Body.String())
}
assertNothingPending(t, f)
if len(f.mail.SentEmails) != 0 {
t.Errorf("expected no mail, got %d", len(f.mail.SentEmails))
}
})
}
t.Run("A wrong password records a failed security event", func(t *testing.T) {
f := newChangeEmailFixture(t)
f.request(t, `{"new_email": "`+changeEmailNew+`", "current_password": "nope"}`)
if len(f.recorder.events) != 1 {
t.Fatalf("expected one audit event, got %d", len(f.recorder.events))
}
event := f.recorder.events[0]
if event.Action != "user.request_email_change" || event.Outcome != audit.OutcomeFailed || !event.Security || event.FailureReason == nil || *event.FailureReason != "Wrong password." {
t.Errorf("unexpected audit event %+v", event)
}
})
t.Run("An address already in use answers like success and sends no verification mail", func(t *testing.T) {
f := newChangeEmailFixture(t)
fresh := f.requestFor(t, changeEmailNew)
freshBody := fresh.Body.String()
g := newChangeEmailFixture(t)
taken := g.requestFor(t, "Ines@Renew.example")
if taken.Code != fresh.Code || strings.ReplaceAll(taken.Body.String(), "Ines@Renew.example", changeEmailNew) != freshBody {
t.Errorf("expected the same response as a free address, got %d %q", taken.Code, taken.Body.String())
}
for name, values := range fresh.Header() {
if name != "Date" && strings.Join(taken.Header()[name], ",") != strings.Join(values, ",") {
t.Errorf("header %s differs", name)
}
}
if g.marta().PendingEmail != "Ines@Renew.example" {
t.Errorf("expected the pending change to be stored, got %q", g.marta().PendingEmail)
}
if len(g.mailsTo("Ines@Renew.example"))+len(g.mailsTo("ines@renew.example")) != 0 {
t.Error("no mail may reach the taken address")
}
if notice := g.mailsTo(changeEmailOld); len(notice) != 1 || notice[0].Template != "email-change-notice" {
t.Errorf("expected the notice to the old address, got %+v", notice)
}
})
t.Run("A new request replaces the previous link", func(t *testing.T) {
f := newChangeEmailFixture(t)
f.requestFor(t, "a@renew.example")
first := f.lastVerificationCode(t)
f.requestFor(t, "b@renew.example")
second := f.lastVerificationCode(t)
old := f.confirm(t, 7, first)
current := f.confirm(t, 7, second)
assertChangeEmailResponse(t, old, http.StatusForbidden, "invalid_email_change_code")
assertChangeEmailResponse(t, current, http.StatusOK, `{"email":"b@renew.example"}`)
})
t.Run("A failed send is logged and the response is unchanged", func(t *testing.T) {
f := newChangeEmailFixture(t)
f.mail.fail.failNextWith("Send", errors.New("smtp: connection refused"))
var logBuf bytes.Buffer
previous := log.Writer()
log.SetOutput(&logBuf)
defer log.SetOutput(previous)
rec := f.requestFor(t, changeEmailNew)
assertChangeEmailResponse(t, rec, http.StatusOK, `{"pending_email":"`+changeEmailNew+`"}`)
if f.marta().PendingEmail != changeEmailNew {
t.Error("the pending change must be stored")
}
logged := logBuf.String()
if !strings.Contains(logged, "smtp: connection refused") || !strings.Contains(logged, "verification") {
t.Errorf("expected the log to name the failed send and the error, got %q", logged)
}
if f.marta().EmailChangeCode != "" && strings.Contains(logged, strings.TrimPrefix(f.marta().EmailChangeCode, "hashed:")) {
t.Error("the log must not contain the raw code")
}
})
t.Run("A failed notice send is logged and the response is unchanged", func(t *testing.T) {
f := newChangeEmailFixture(t)
f.mail.fail.failNextWith("Send", errors.New("smtp: notice refused"))
var logBuf bytes.Buffer
previous := log.Writer()
log.SetOutput(&logBuf)
defer log.SetOutput(previous)
rec := f.requestFor(t, "ines@renew.example")
assertChangeEmailResponse(t, rec, http.StatusOK, `{"pending_email":"ines@renew.example"}`)
logged := logBuf.String()
if !strings.Contains(logged, "smtp: notice refused") || !strings.Contains(logged, "notice") {
t.Errorf("expected the log to name the failed notice send, got %q", logged)
}
})
t.Run("A successful send logs nothing", func(t *testing.T) {
f := newChangeEmailFixture(t)
var logBuf bytes.Buffer
previous := log.Writer()
log.SetOutput(&logBuf)
defer log.SetOutput(previous)
f.requestFor(t, changeEmailNew)
if logBuf.Len() != 0 {
t.Errorf("expected no log output, got %q", logBuf.String())
}
})
t.Run("The sixth request in 15 minutes is rate limited", func(t *testing.T) {
f := newChangeEmailFixture(t)
for i := 0; i < 3; i++ {
f.requestFor(t, changeEmailNew)
}
f.resend(t)
f.resend(t)
sentBefore := len(f.mail.SentEmails)
eventsBefore := len(f.recorder.events)
codeBefore := f.marta().EmailChangeCode
rec := f.requestFor(t, "other@renew.example")
assertChangeEmailResponse(t, rec, http.StatusTooManyRequests, "Too many requests. Please try again later.")
if rec.Header().Get("Retry-After") == "" {
t.Error("expected a Retry-After header")
}
if len(f.mail.SentEmails) != sentBefore || len(f.recorder.events) != eventsBefore || f.marta().EmailChangeCode != codeBefore || f.marta().PendingEmail != changeEmailNew {
t.Error("nothing may be sent or stored once limited")
}
})
t.Run("The endpoints refuse a missing session", func(t *testing.T) {
f := newChangeEmailFixture(t)
req := httptest.NewRequest(http.MethodPost, "/v1/auth/email/change", strings.NewReader("{}"))
rec := httptest.NewRecorder()
f.router.ServeHTTP(rec, req)
assertChangeEmailResponse(t, rec, http.StatusUnauthorized, "You must be logged in to access this resource.")
})
}
func TestAccountChangeEmailResendAndCancelHTTP(t *testing.T) {
t.Run("Resend issues a new code and invalidates the old one", func(t *testing.T) {
f := newChangeEmailFixture(t)
f.requestFor(t, changeEmailNew)
first := f.lastVerificationCode(t)
f.mail.SentEmails = nil
f.recorder.events = nil
rec := f.resend(t)
assertChangeEmailResponse(t, rec, http.StatusOK, `{"pending_email":"`+changeEmailNew+`"}`)
if len(f.mail.SentEmails) != 1 || f.mail.SentEmails[0].Template != "verify-new-email" || f.mail.SentEmails[0].Address != changeEmailNew {
t.Fatalf("expected one verify-new-email mail only, got %+v", f.mail.SentEmails)
}
second := f.lastVerificationCode(t)
if second == first {
t.Error("expected a new code")
}
if delta := time.Until(f.marta().EmailChangeExpiration.Add(-2 * time.Hour)); delta > 5*time.Second || delta < -5*time.Second {
t.Errorf("expected the expiration reset to now plus 2 hours, got %v", f.marta().EmailChangeExpiration)
}
resendEvents := f.recorder.events
assertChangeEmailResponse(t, f.confirm(t, 7, first), http.StatusForbidden, "invalid_email_change_code")
if len(resendEvents) != 1 || resendEvents[0].Action != "user.resend_email_change" {
t.Errorf("expected one user.resend_email_change event, got %+v", resendEvents)
}
})
t.Run("Resend for a taken address sends nothing", func(t *testing.T) {
f := newChangeEmailFixture(t)
f.users.users[7] = func() user.User {
marta := f.users.users[7]
marta.PendingEmail = "ines@renew.example"
marta.EmailChangeCode = "hashed:old"
return marta
}()
rec := f.resend(t)
assertChangeEmailResponse(t, rec, http.StatusOK, `{"pending_email":"ines@renew.example"}`)
if len(f.mail.SentEmails) != 0 {
t.Errorf("expected no mail, got %d", len(f.mail.SentEmails))
}
})
t.Run("Resend with nothing pending", func(t *testing.T) {
f := newChangeEmailFixture(t)
assertChangeEmailResponse(t, f.resend(t), http.StatusNotFound, "no_pending_email_change")
})
t.Run("Cancel clears the pending change", func(t *testing.T) {
f := newChangeEmailFixture(t)
f.requestFor(t, changeEmailNew)
code := f.lastVerificationCode(t)
f.recorder.events = nil
rec := f.cancel(t)
if rec.Code != http.StatusNoContent || rec.Body.Len() != 0 {
t.Errorf("expected an empty 204, got %d %q", rec.Code, rec.Body.String())
}
assertNothingPending(t, f)
cancelEvents := f.recorder.events
assertChangeEmailResponse(t, f.confirm(t, 7, code), http.StatusForbidden, "invalid_email_change_code")
if len(cancelEvents) != 1 || cancelEvents[0].Action != "user.cancel_email_change" {
t.Errorf("expected one user.cancel_email_change event, got %+v", cancelEvents)
}
})
t.Run("Cancel keeps the pending change and records nothing when the store fails", func(t *testing.T) {
f := newChangeEmailFixture(t)
f.requestFor(t, changeEmailNew)
f.recorder.events = nil
f.users.fail.failNextWith("ClearPendingEmailChange", errors.New("db down"))
rec := f.cancel(t)
if rec.Code != http.StatusInternalServerError {
t.Errorf("expected 500, got %d %q", rec.Code, rec.Body.String())
}
if f.marta().PendingEmail != changeEmailNew {
t.Error("the pending change must remain")
}
if len(f.recorder.events) != 1 || f.recorder.events[0].Action != "user.cancel_email_change" || f.recorder.events[0].Outcome != audit.OutcomeFailed {
t.Errorf("expected one failed user.cancel_email_change event, got %+v", f.recorder.events)
}
})
t.Run("Cancel with nothing pending is harmless", func(t *testing.T) {
f := newChangeEmailFixture(t)
rec := f.cancel(t)
if rec.Code != http.StatusNoContent || len(f.recorder.events) != 0 {
t.Errorf("expected 204 and no event, got %d and %d events", rec.Code, len(f.recorder.events))
}
})
}
func TestAccountChangeEmailConfirmHTTP(t *testing.T) {
t.Run("A valid code changes the email and is single use", func(t *testing.T) {
f := newChangeEmailFixture(t)
f.requestFor(t, changeEmailNew)
code := f.lastVerificationCode(t)
f.recorder.events = nil
rec := f.confirm(t, 7, code)
assertChangeEmailResponse(t, rec, http.StatusOK, `{"email":"`+changeEmailNew+`"}`)
if f.marta().Email != changeEmailNew || f.marta().TokenVersion != 0 {
t.Errorf("expected the new email and an unchanged token version, got %+v", f.marta())
}
assertNothingPending(t, f)
if len(f.recorder.events) != 1 || f.recorder.events[0].Action != "user.confirm_email_change" || !f.recorder.events[0].Security {
t.Errorf("expected one security user.confirm_email_change event, got %+v", f.recorder.events)
}
assertChangeEmailResponse(t, f.confirm(t, 7, code), http.StatusForbidden, "invalid_email_change_code")
})
t.Run("A code that does not work answers one 403: nothing is pending", func(t *testing.T) {
f := newChangeEmailFixture(t)
assertChangeEmailResponse(t, f.confirm(t, 7, "any"), http.StatusForbidden, "invalid_email_change_code")
if f.marta().Email != changeEmailOld {
t.Error("users.email must be unchanged")
}
})
t.Run("A code that does not work answers one 403: the code expired 1 second ago", func(t *testing.T) {
f := newChangeEmailFixture(t)
f.requestFor(t, changeEmailNew)
code := f.lastVerificationCode(t)
marta := f.marta()
marta.EmailChangeExpiration = time.Now().Add(-time.Second)
f.users.users[7] = marta
assertChangeEmailResponse(t, f.confirm(t, 7, code), http.StatusForbidden, "invalid_email_change_code")
if f.marta().Email != changeEmailOld {
t.Error("users.email must be unchanged")
}
})
t.Run("A code that does not work answers one 403: a wrong code", func(t *testing.T) {
f := newChangeEmailFixture(t)
f.requestFor(t, changeEmailNew)
assertChangeEmailResponse(t, f.confirm(t, 7, "wrong"), http.StatusForbidden, "invalid_email_change_code")
if f.marta().Email != changeEmailOld {
t.Error("users.email must be unchanged")
}
})
t.Run("A code that does not work answers one 403: the code was issued to another user", func(t *testing.T) {
f := newChangeEmailFixture(t)
f.requestFor(t, changeEmailNew)
code := f.lastVerificationCode(t)
assertChangeEmailResponse(t, f.confirm(t, 8, code), http.StatusForbidden, "invalid_email_change_code")
if f.marta().Email != changeEmailOld || f.users.users[8].Email != "ines@renew.example" {
t.Error("no email may change")
}
})
t.Run("A code that does not work answers one 403: the change was cancelled", func(t *testing.T) {
f := newChangeEmailFixture(t)
f.requestFor(t, changeEmailNew)
code := f.lastVerificationCode(t)
f.cancel(t)
assertChangeEmailResponse(t, f.confirm(t, 7, code), http.StatusForbidden, "invalid_email_change_code")
})
t.Run("The address was taken in the meantime", func(t *testing.T) {
f := newChangeEmailFixture(t)
f.requestFor(t, "later@renew.example")
code := f.lastVerificationCode(t)
ines := f.users.users[8]
ines.Email = "later@renew.example"
f.users.users[8] = ines
rec := f.confirm(t, 7, code)
assertChangeEmailResponse(t, rec, http.StatusConflict, "email_unavailable")
if f.marta().Email != changeEmailOld {
t.Error("users.email must be unchanged")
}
assertNothingPending(t, f)
})
t.Run("An empty code is refused before any lookup", func(t *testing.T) {
f := newChangeEmailFixture(t)
f.requestFor(t, changeEmailNew)
f.users.getCalls = 0
f.confirm(t, 7, "")
middlewareLookups := f.users.getCalls
f.users.getCalls = 0
rec := f.confirm(t, 7, "")
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "Code") {
t.Errorf("expected a 400 with the validator message, got %d %q", rec.Code, rec.Body.String())
}
if f.users.getCalls != middlewareLookups {
t.Error("the use case must not look the user up")
}
if f.marta().PendingEmail != changeEmailNew {
t.Error("nothing may change")
}
})
}
func TestAccountChangeEmailTimingAndFailuresHTTP(t *testing.T) {
t.Run("No mail is sent on the request path, free or taken address alike", func(t *testing.T) {
for _, address := range []string{changeEmailNew, "ines@renew.example"} {
f := newChangeEmailFixture(t)
req := httptest.NewRequest(http.MethodPost, "/v1/auth/email/change", strings.NewReader(`{"new_email": "`+address+`", "current_password": "`+changeEmailPassword+`"}`))
req.AddCookie(displayNameCookie(t, f.token, 7, 0))
rec := httptest.NewRecorder()
f.router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK || len(f.mail.SentEmails) != 0 || f.dispatcher.pending() != 2 {
t.Errorf("%s: status %d, sent %d, queued %d", address, rec.Code, len(f.mail.SentEmails), f.dispatcher.pending())
}
f.dispatcher.Drain()
}
})
t.Run("Every email-change refusal records a specific failure reason", func(t *testing.T) {
cases := []struct {
name string
run func(f *changeEmailFixture)
}{
{"same address", func(f *changeEmailFixture) { f.requestFor(t, changeEmailOld) }},
{"nothing pending on resend", func(f *changeEmailFixture) { f.resend(t) }},
{"invalid code", func(f *changeEmailFixture) { f.confirm(t, 7, "nope") }},
{"address unavailable", func(f *changeEmailFixture) {
f.requestFor(t, "later@renew.example")
code := f.lastVerificationCode(t)
ines := f.users.users[8]
ines.Email = "later@renew.example"
f.users.users[8] = ines
f.recorder.events = nil
f.confirm(t, 7, code)
}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
f := newChangeEmailFixture(t)
tc.run(f)
last := f.recorder.events[len(f.recorder.events)-1]
if last.Outcome != audit.OutcomeFailed || !last.Security || last.FailureReason == nil || *last.FailureReason == useCases.GenericFailureReason {
t.Errorf("expected a specific failed security event, got %+v", last)
}
})
}
})
t.Run("Concurrent confirms of one address let exactly one user win", func(t *testing.T) {
f := newChangeEmailFixture(t)
const contenders = 8
codes := map[uint32]string{}
for i := uint32(0); i < contenders; i++ {
id := 20 + i
f.users.users[id] = user.User{ID: id, Username: "u", Email: "u@renew.example", IsVerified: true, PendingEmail: "shared@renew.example", EmailChangeCode: "hashed:code", EmailChangeExpiration: time.Now().Add(time.Hour)}
codes[id] = "code"
}
confirm := useCases.NewConfirmEmailChangeUseCase(f.users, atomicPlainHasher{}, time.Now, discardRecorder)
var mu sync.Mutex
var group sync.WaitGroup
wins, unavailable := 0, 0
for id, code := range codes {
group.Add(1)
go func() {
defer group.Done()
_, err := confirm.ConfirmEmailChange(context.Background(), id, code)
mu.Lock()
defer mu.Unlock()
if err == nil {
wins++
} else if errors.Is(err, user.ErrEmailUnavailable) {
unavailable++
}
}()
}
group.Wait()
if wins != 1 || unavailable != contenders-1 {
t.Errorf("expected 1 win and %d unavailable, got %d and %d", contenders-1, wins, unavailable)
}
})
}
@@ -0,0 +1,228 @@
package test
import (
domainUser "RenewCMS/internal/domain/user"
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
"errors"
"fmt"
"sync"
"testing"
"time"
"gorm.io/gorm"
)
func TestUserRepositoryEmailChangeContract(t *testing.T) {
backends := []contractBackend{
{name: "sqlite", open: openContractSQLite},
{name: "mysql", open: openContractMySQL},
{name: "postgres", open: openContractPostgres},
}
expiration := time.Now().Add(2 * time.Hour).UTC().Truncate(time.Second)
cases := []struct {
name string
run func(t *testing.T, users *persistence.UserRepository, marta domainUser.User, ines domainUser.User)
}{
{"setting a pending change keeps the email and clearing empties the three fields", func(t *testing.T, users *persistence.UserRepository, marta domainUser.User, ines domainUser.User) {
set, err := users.SetPendingEmailChange(marta.ID, "new@renew.example", "hash", expiration)
if err != nil || set.PendingEmail != "new@renew.example" || set.EmailChangeCode != "hash" || !set.EmailChangeExpiration.Equal(expiration) || set.Email != marta.Email {
t.Fatalf("set %+v %v", set, err)
}
cleared, err := users.ClearPendingEmailChange(marta.ID)
if err != nil || cleared.PendingEmail != "" || cleared.EmailChangeCode != "" || !cleared.EmailChangeExpiration.IsZero() {
t.Errorf("cleared %+v %v", cleared, err)
}
}},
{"an address is in use whatever its case, except for the asking user", func(t *testing.T, users *persistence.UserRepository, marta domainUser.User, ines domainUser.User) {
taken, err := users.IsEmailInUse("INES@renew.example", marta.ID)
own, ownErr := users.IsEmailInUse(marta.Email, marta.ID)
free, freeErr := users.IsEmailInUse("free@renew.example", marta.ID)
if err != nil || !taken || ownErr != nil || own || freeErr != nil || free {
t.Errorf("taken %v %v own %v %v free %v %v", taken, err, own, ownErr, free, freeErr)
}
}},
{"confirming moves the pending address to the email once", func(t *testing.T, users *persistence.UserRepository, marta domainUser.User, ines domainUser.User) {
_, _ = users.SetPendingEmailChange(marta.ID, "new@renew.example", "hash", expiration)
confirmed, err := users.ConfirmEmailChange(marta.ID, "new@renew.example", "hash", time.Now())
_, again := users.ConfirmEmailChange(marta.ID, "new@renew.example", "hash", time.Now())
if err != nil || confirmed.Email != "new@renew.example" || confirmed.PendingEmail != "" || confirmed.EmailChangeCode != "" {
t.Errorf("confirmed %+v %v", confirmed, err)
}
if !errors.Is(again, domainUser.ErrInvalidEmailChangeCode) {
t.Errorf("second confirm %v", again)
}
}},
{"confirming after the expiry changes nothing", func(t *testing.T, users *persistence.UserRepository, marta domainUser.User, ines domainUser.User) {
_, _ = users.SetPendingEmailChange(marta.ID, "new@renew.example", "hash", expiration)
_, err := users.ConfirmEmailChange(marta.ID, "new@renew.example", "hash", expiration.Add(time.Second))
stored, _ := users.Get(marta.ID)
if !errors.Is(err, domainUser.ErrInvalidEmailChangeCode) || stored.Email != marta.Email {
t.Errorf("err %v stored %+v", err, stored)
}
}},
{"confirming with another code changes nothing", func(t *testing.T, users *persistence.UserRepository, marta domainUser.User, ines domainUser.User) {
_, _ = users.SetPendingEmailChange(marta.ID, "new@renew.example", "hash", expiration)
_, err := users.ConfirmEmailChange(marta.ID, "new@renew.example", "other", time.Now())
stored, _ := users.Get(marta.ID)
if !errors.Is(err, domainUser.ErrInvalidEmailChangeCode) || stored.Email != marta.Email || stored.PendingEmail != "new@renew.example" {
t.Errorf("err %v stored %+v", err, stored)
}
}},
{"confirming a taken address clears the pending change and keeps the email", func(t *testing.T, users *persistence.UserRepository, marta domainUser.User, ines domainUser.User) {
_, _ = users.SetPendingEmailChange(marta.ID, "Ines@Renew.example", "hash", expiration)
_, err := users.ConfirmEmailChange(marta.ID, "Ines@Renew.example", "hash", time.Now())
stored, _ := users.Get(marta.ID)
if !errors.Is(err, domainUser.ErrEmailUnavailable) || stored.Email != marta.Email || stored.PendingEmail != "" || stored.EmailChangeCode != "" {
t.Errorf("err %v stored %+v", err, stored)
}
}},
}
for _, backend := range backends {
t.Run(backend.name, func(t *testing.T) {
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
db := backend.open(t)
users := persistence.NewUserRepository(db)
marta := mustCreateEmailChangeUser(t, users, "marta", "marta@renew.example")
ines := mustCreateEmailChangeUser(t, users, "ines", "ines@renew.example")
c.run(t, users, marta, ines)
})
}
})
}
}
func mustCreateEmailChangeUser(t *testing.T, users *persistence.UserRepository, username string, email string) domainUser.User {
t.Helper()
created, err := users.Create(domainUser.User{Username: username, Email: email, Password: "hash", IsVerified: true})
if err != nil {
t.Fatalf("create %s: %v", username, err)
}
return created
}
func TestUserRepositoryConcurrentEmailChangeOnSQLite(t *testing.T) {
const contenders = 8
expiration := time.Now().Add(2 * time.Hour)
users := persistence.NewUserRepository(openContractSQLite(t))
ids := make([]uint32, 0, contenders)
for i := 0; i < contenders; i++ {
created := mustCreateEmailChangeUser(t, users, fmt.Sprintf("user%d", i), fmt.Sprintf("user%d@renew.example", i))
if _, err := users.SetPendingEmailChange(created.ID, "shared@renew.example", "hash", expiration); err != nil {
t.Fatal(err)
}
ids = append(ids, created.ID)
}
results := make([]error, contenders)
var group sync.WaitGroup
for i, id := range ids {
group.Add(1)
go func() {
defer group.Done()
_, results[i] = users.ConfirmEmailChange(id, "shared@renew.example", "hash", time.Now())
}()
}
group.Wait()
succeeded := 0
for _, err := range results {
switch {
case err == nil:
succeeded++
case !errors.Is(err, domainUser.ErrEmailUnavailable):
t.Errorf("a losing confirm must answer ErrEmailUnavailable, got %v", err)
}
}
if succeeded != 1 {
t.Errorf("expected exactly one success, got %d", succeeded)
}
for _, id := range ids {
stored, _ := users.Get(id)
if stored.PendingEmail != "" {
t.Errorf("user %d still has a pending change", id)
}
}
}
func failUserUpdates(t *testing.T, db *gorm.DB, failures ...error) {
t.Helper()
var mu sync.Mutex
remaining := failures
err := db.Callback().Update().Before("gorm:update").Register("test:fail_user_updates", func(tx *gorm.DB) {
mu.Lock()
defer mu.Unlock()
if len(remaining) == 0 || tx.Statement.Table != "users" {
return
}
failure := remaining[0]
remaining = remaining[1:]
if failure != nil {
_ = tx.AddError(failure)
}
})
if err != nil {
t.Fatal(err)
}
}
func TestUserRepositoryConfirmEmailChangeUniqueViolationRace(t *testing.T) {
expiration := time.Now().Add(2 * time.Hour)
setup := func(t *testing.T, failures ...error) (*persistence.UserRepository, domainUser.User) {
db := openContractSQLite(t)
users := persistence.NewUserRepository(db)
account := mustCreateEmailChangeUser(t, users, "racer", "racer@renew.example")
if _, err := users.SetPendingEmailChange(account.ID, "raced@renew.example", "hash", expiration); err != nil {
t.Fatal(err)
}
failUserUpdates(t, db, failures...)
return users, account
}
t.Run("a unique violation clears the pending change and answers ErrEmailUnavailable", func(t *testing.T) {
users, account := setup(t, gorm.ErrDuplicatedKey)
updated, err := users.ConfirmEmailChange(account.ID, "raced@renew.example", "hash", time.Now())
if !errors.Is(err, domainUser.ErrEmailUnavailable) {
t.Fatalf("expected ErrEmailUnavailable, got %v", err)
}
if updated.ID != 0 {
t.Errorf("no user must be returned, got %+v", updated)
}
stored, _ := users.Get(account.ID)
if stored.PendingEmail != "" || stored.Email != "racer@renew.example" {
t.Errorf("pending change must be cleared and email kept, got %+v", stored)
}
})
t.Run("a failing cleanup after a unique violation surfaces its own error", func(t *testing.T) {
cleanupFailure := errors.New("cleanup exploded")
users, account := setup(t, gorm.ErrDuplicatedKey, cleanupFailure)
_, err := users.ConfirmEmailChange(account.ID, "raced@renew.example", "hash", time.Now())
if !errors.Is(err, cleanupFailure) {
t.Fatalf("expected the cleanup error, got %v", err)
}
if errors.Is(err, domainUser.ErrEmailUnavailable) {
t.Error("a failed cleanup must not be reported as ErrEmailUnavailable")
}
})
t.Run("a non-unique database error is returned untouched", func(t *testing.T) {
failure := errors.New("disk on fire")
users, account := setup(t, failure)
_, err := users.ConfirmEmailChange(account.ID, "raced@renew.example", "hash", time.Now())
if !errors.Is(err, failure) {
t.Fatalf("expected the database error, got %v", err)
}
})
}
+81
View File
@@ -0,0 +1,81 @@
package test
import (
"RenewCMS/internal/domain/user"
entity "RenewCMS/internal/infrastructure/persistence/models"
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
"errors"
"testing"
"gorm.io/gorm"
)
func failAccountDeleteKeyRemoval(t *testing.T, db *gorm.DB, name string, failure error) {
t.Helper()
err := db.Callback().Delete().Before("gorm:delete").Register(name, func(tx *gorm.DB) {
if tx.Statement.Table == "api_keys" {
_ = tx.AddError(failure)
}
})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Callback().Delete().Remove(name) })
}
func TestAccountDeleteContentionMapping(t *testing.T) {
cases := []struct {
name string
failure string
guards []uint32
notDeletable bool
}{
{"locked database with a guard role", "database is locked", []uint32{8}, true},
{"sqlite busy with a guard role", "SQLITE_BUSY: x", []uint32{8}, true},
{"deadlock with a guard role", "Deadlock found when trying to get lock", []uint32{8}, true},
{"serialization failure with a guard role", "could not serialize access", []uint32{8}, true},
{"locked database without a guard role", "database is locked", nil, false},
{"unrelated failure with a guard role", "disk exploded", []uint32{8}, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
db := openContractSQLite(t)
seedDeleteAccountRows(t, db, true)
failure := errors.New(tc.failure)
failAccountDeleteKeyRemoval(t, db, "test:account_delete_contention", failure)
err := persistence.NewAccountEraser(db).Erase(user.Erasure{UserID: 7, GuardRoleIDs: tc.guards})
if errors.Is(err, user.ErrAccountNotDeletable) != tc.notDeletable {
t.Errorf("ErrAccountNotDeletable = %v, want %v (err %v)", !tc.notDeletable, tc.notDeletable, err)
}
if !tc.notDeletable && !errors.Is(err, failure) {
t.Errorf("expected the original failure, got %v", err)
}
if deleteAccountRowCount(t, db, &entity.User{}, "id = ?", 7) != 1 {
t.Error("the user must still exist after a failed erasure")
}
})
}
}
func TestAccountDeleteSQLiteTakesTheWriteLockBeforeCounting(t *testing.T) {
db := openContractSQLite(t)
seedDeleteAccountRows(t, db, true)
roleWrites := 0
err := db.Callback().Update().Before("gorm:update").Register("test:account_delete_role_writes", func(tx *gorm.DB) {
if tx.Statement.Table == "roles" {
roleWrites++
}
})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Callback().Update().Remove("test:account_delete_role_writes") })
err = persistence.NewAccountEraser(db).Erase(user.Erasure{UserID: 7, GuardRoleIDs: []uint32{8}})
if err != nil || roleWrites != 1 {
t.Errorf("expected one lock write on the guard roles and no error, got %d writes and %v", roleWrites, err)
}
}
+775
View File
@@ -0,0 +1,775 @@
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")
}
}
+64
View File
@@ -0,0 +1,64 @@
package test
import (
"RenewCMS/api/handlers/user"
"RenewCMS/internal/domain/author"
domain "RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/config"
"RenewCMS/internal/infrastructure/mailer"
"net/http"
"os"
"reflect"
"regexp"
"strings"
"testing"
)
func readWebSource(t *testing.T, path string) string {
t.Helper()
raw, err := os.ReadFile("../web/src/" + path)
if err != nil {
t.Fatal(err)
}
return string(raw)
}
func TestAccountDeleteLiteralParity(t *testing.T) {
t.Run("Deleted user is the same name in the domain, the audit trail and the SPA", func(t *testing.T) {
if domain.DeletedUserName != author.DeletedName {
t.Errorf("user %q and author %q disagree", domain.DeletedUserName, author.DeletedName)
}
if !strings.Contains(readWebSource(t, "lib/deletedUser.ts"), "DELETED_USER_NAME = '"+domain.DeletedUserName+"'") {
t.Errorf("web/src/lib/deletedUser.ts does not name the user %q", domain.DeletedUserName)
}
if !strings.Contains(readWebSource(t, "i18n/messages.ts"), "'"+domain.DeletedUserName+"':") {
t.Errorf("the i18n catalogue has no entry for %q", domain.DeletedUserName)
}
})
t.Run("The confirmation word is the same in the handler and in the SPA", func(t *testing.T) {
field, _ := reflect.TypeOf(user.DeleteAccountRequest{}).FieldByName("Confirmation")
matched := regexp.MustCompile(`eq=(\w+)`).FindStringSubmatch(field.Tag.Get("validate"))
if matched == nil {
t.Fatalf("the Confirmation tag has no eq= rule: %q", field.Tag)
}
if !strings.Contains(readWebSource(t, "lib/accountDeletion.ts"), "DELETE_WORD = '"+matched[1]+"'") {
t.Errorf("web/src/lib/accountDeletion.ts does not use the word %q", matched[1])
}
})
t.Run("The template the use case sends is the one the mailer renders", func(t *testing.T) {
f := newDeleteAccountFixture(t)
rec := f.request(t, 7, deleteAccountBody)
if rec.Code != http.StatusNoContent || len(f.mail.SentEmails) != 1 {
t.Fatalf("unexpected outcome %d with %d mails", rec.Code, len(f.mail.SentEmails))
}
sent := f.mail.SentEmails[0]
subject, body, err := mailer.NewMailRepository(config.SmtpSettings{}).Render(sent.Template, sent.Data)
if err != nil || body == "" || subject == "RenewCMS | Action required" {
t.Errorf("the mailer does not know %q: subject %q err %v", sent.Template, subject, err)
}
})
}
+267
View File
@@ -0,0 +1,267 @@
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")
}
})
})
}
}
+71
View File
@@ -0,0 +1,71 @@
package test
import (
"RenewCMS/internal/domain/author"
"RenewCMS/internal/infrastructure/useCases"
"context"
"errors"
"testing"
)
func TestAccountDeleteReservedAuthorSlug(t *testing.T) {
t.Run("The deleted-user prefix is not a valid author slug", func(t *testing.T) {
for slug, valid := range map[string]bool{"deleted-user-5": false, "deleted-user-": false, "deleted-user": true, "deleted-users-5": true, "marta": true} {
if author.IsValidSlug(slug) != valid {
t.Errorf("%q: expected valid=%v", slug, valid)
}
}
})
t.Run("The reserved prefix is detected exactly", func(t *testing.T) {
for slug, reserved := range map[string]bool{"deleted-user-": true, "deleted-user-7": true, "deleted-user": false, "": false, "deleted-users": false} {
if author.IsReservedSlug(slug) != reserved {
t.Errorf("%q: expected reserved=%v", slug, reserved)
}
}
})
t.Run("A generated slug never lands on the reserved prefix", func(t *testing.T) {
if got := author.GenerateSlug("Deleted User 5"); author.IsReservedSlug(got) || got != "author-deleted-user-5" {
t.Errorf("unexpected slug %q", got)
}
if got := author.SlugWithSuffix("deleted-user", 2); author.IsReservedSlug(got) {
t.Errorf("suffixed slug %q is reserved", got)
}
})
t.Run("Creating an author with a reserved slug is refused", func(t *testing.T) {
authors := plainAuthors()
uc := useCases.NewCreateAuthorUseCase(authors, newMockUserRepo(), newMockImageRepo(), discardRecorder)
_, err := uc.Create(context.Background(), useCases.CreateAuthorCommand{Name: "Marta", Slug: "deleted-user-9"})
if !errors.Is(err, author.ErrInvalidSlug) || len(authors.authors) != 0 {
t.Errorf("err %v created %d", err, len(authors.authors))
}
})
t.Run("Creating an author named like the reserved prefix gets a safe slug", func(t *testing.T) {
authors := plainAuthors()
uc := useCases.NewCreateAuthorUseCase(authors, newMockUserRepo(), newMockImageRepo(), discardRecorder)
created, err := uc.Create(context.Background(), useCases.CreateAuthorCommand{Name: "Deleted User 9"})
if err != nil || created.Slug != "author-deleted-user-9" {
t.Errorf("slug %q err %v", created.Slug, err)
}
})
t.Run("Changing an author slug to a reserved one is refused", func(t *testing.T) {
authors := plainAuthors()
authors.seed(2, "B", "b")
uc := useCases.NewUpdateAuthorUseCase(authors, newMockUserRepo(), newMockImageRepo(), discardRecorder)
reserved := "deleted-user-2"
_, err := uc.Update(context.Background(), 2, useCases.AuthorCaller{UserID: 9, Grants: authorsEditAllGrants}, author.Patch{Slug: &reserved})
if !errors.Is(err, author.ErrInvalidSlug) {
t.Errorf("err = %v", err)
}
})
}
+100
View File
@@ -0,0 +1,100 @@
package test
import (
"RenewCMS/api/middleware"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestAccountExportDataEmptyCollectionsAreArrays(t *testing.T) {
f := newExportFixture(t)
for index := range f.events.events {
f.events.events[index].Params = nil
}
rec := f.export(t, exportMartaID)
files := openExportArchive(t, rec)
for _, item := range exportList(t, exportJSON(t, files, "authored-content.json"), "articles") {
authors, ok := item.(map[string]any)["authors"].([]any)
if !ok || len(authors) != 0 {
t.Errorf("uncredited article authors = %v, want empty list", item.(map[string]any)["authors"])
}
}
events := exportList(t, exportJSON(t, files, "account-activity.json"), "events")
if len(events) == 0 {
t.Fatal("expected activity events")
}
for _, item := range events {
params, ok := item.(map[string]any)["params"].(map[string]any)
if !ok || len(params) != 0 {
t.Errorf("event params = %v, want empty object", item.(map[string]any)["params"])
}
}
}
func successLimiterStatus(t *testing.T, handler http.Handler) *httptest.ResponseRecorder {
t.Helper()
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
return rec
}
func newSuccessLimiterHandler(clock *exportClock, status *int) http.Handler {
limited := middleware.NewUserSuccessLimiterAt(time.Minute, clock.now)
return limited(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(*status)
}))
}
func TestUserSuccessLimiterStatusBoundary(t *testing.T) {
cases := map[string]struct {
status int
blocked bool
}{
"399 keeps the slot": {399, true},
"400 releases the slot": {400, false},
"500 releases the slot": {500, false},
"200 keeps the slot": {200, true},
"302 keeps the slot": {302, true},
"429 releases the slot": {429, false},
"401 releases the slot": {401, false},
"204 keeps the slot too": {204, true},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
clock := &exportClock{current: time.Date(2026, time.October, 29, 9, 0, 0, 0, time.UTC)}
status := tc.status
handler := newSuccessLimiterHandler(clock, &status)
successLimiterStatus(t, handler)
second := successLimiterStatus(t, handler)
if tc.blocked && second.Code != http.StatusTooManyRequests {
t.Errorf("second call = %d, want 429", second.Code)
}
if !tc.blocked && second.Code != tc.status {
t.Errorf("second call = %d, want handler status %d", second.Code, tc.status)
}
})
}
}
func TestUserSuccessLimiterWindowEdge(t *testing.T) {
clock := &exportClock{current: time.Date(2026, time.October, 29, 9, 0, 0, 0, time.UTC)}
status := http.StatusOK
handler := newSuccessLimiterHandler(clock, &status)
successLimiterStatus(t, handler)
clock.advance(time.Minute - time.Second)
blocked := successLimiterStatus(t, handler)
if blocked.Code != http.StatusTooManyRequests || blocked.Header().Get("Retry-After") != "1" {
t.Errorf("just inside the window = %d retry-after %q, want 429 and 1", blocked.Code, blocked.Header().Get("Retry-After"))
}
clock.advance(time.Second)
if edge := successLimiterStatus(t, handler); edge.Code != http.StatusOK {
t.Errorf("exactly at the window = %d, want 200", edge.Code)
}
}
+528
View File
@@ -0,0 +1,528 @@
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/contenttype"
"RenewCMS/internal/domain/entry"
"RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/role"
domainUser "RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"archive/zip"
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"slices"
"strconv"
"strings"
"testing"
"time"
"github.com/go-chi/chi/v5"
)
const (
exportMartaID uint32 = 7
exportInesID uint32 = 8
exportUnverifID uint32 = 9
exportTokenVer uint32 = 3
exportSecret = "hashed:secret-hash"
exportRoute = "/v1/user/me/export"
)
var exportFiles = []string{"account-activity.json", "authored-content.json", "profile.json", "uploaded-media.json"}
type exportClock struct{ current time.Time }
func (c *exportClock) now() time.Time { return c.current }
func (c *exportClock) advance(d time.Duration) { c.current = c.current.Add(d) }
type exportAuditFake struct {
events []audit.Event
}
var _ audit.Repository = &exportAuditFake{}
func (f *exportAuditFake) Append(event audit.Event) (audit.Event, error) {
f.events = append(f.events, event)
return event, nil
}
func (f *exportAuditFake) List(filter audit.Filter) (audit.Page, error) {
items := []audit.Event{}
for _, event := range f.events {
if filter.Actor != nil {
if event.Actor.Type != filter.Actor.Type || filter.Actor.ID != nil && (event.Actor.ID == nil || *event.Actor.ID != *filter.Actor.ID) {
continue
}
}
items = append(items, event)
}
return audit.Page{Items: items, Total: len(items)}, nil
}
func (f *exportAuditFake) Summary(audit.SummaryQuery) (audit.Summary, error) {
return audit.Summary{}, nil
}
func (f *exportAuditFake) DeleteOlderThan(time.Time) (int, error) { return 0, nil }
func (f *exportAuditFake) DeleteOldest(int, time.Time) (int, error) { return 0, nil }
func (f *exportAuditFake) SizeBytes() (int64, error) { return 0, nil }
type exportFixture struct {
clock *exportClock
users *mockUserRepo
roles *mockRoleRepo
authors *mockAuthorRepo
articles *mockArticleRepo
entries *mockEntryRepo
images *mockImageRepo
keys *mockAPIKeyRepo
types *mockContentTypeRepo
events *exportAuditFake
recorder *recordingRecorder
token *authtoken.Auth
router *chi.Mux
}
const exportDefaultCap = 2
func newExportFixture(t *testing.T) *exportFixture {
t.Helper()
return newExportFixtureWithCap(t, exportDefaultCap)
}
func newExportFixtureWithCap(t *testing.T, maxConcurrent int) *exportFixture {
t.Helper()
validator.InitValidator()
articles := newMockArticleRepo()
f := &exportFixture{
clock: &exportClock{current: time.Date(2026, time.October, 29, 9, 41, 0, 0, time.UTC)},
users: newMockUserRepo(),
roles: newSeededMockRoleRepo(),
authors: newMockAuthorRepo(articles),
articles: articles,
entries: newMockEntryRepo(),
images: newMockImageRepo(),
keys: newMockAPIKeyRepo(),
types: newMockContentTypeRepo(),
events: &exportAuditFake{},
recorder: &recordingRecorder{},
token: authtoken.New([]byte("test-secret")),
}
editor := f.roles.byName(role.WriterName)
created := time.Date(2026, time.March, 2, 10, 0, 0, 0, time.UTC)
f.users.users[exportMartaID] = domainUser.User{
ID: exportMartaID, Username: "marta", DisplayName: "Marta Alves", Email: "marta@renew.example",
Password: exportSecret, PasswordResetCode: "reset-code-xyz", VerificationCode: "verification-code-xyz",
EmailChangeCode: "email-change-code-xyz", PendingEmail: "marta.new@renew.example", TokenVersion: exportTokenVer,
RoleID: editor.ID, IsVerified: true, CreatedAt: created, UpdatedAt: created,
}
f.users.users[exportInesID] = domainUser.User{
ID: exportInesID, Username: "ines", DisplayName: "Ines Duarte", Email: "ines@renew.example",
Password: "hashed:ines", RoleID: editor.ID, IsVerified: true, TokenVersion: exportTokenVer,
}
f.users.users[exportUnverifID] = domainUser.User{ID: exportUnverifID, Username: "newbie", Email: "newbie@renew.example", Password: "hashed:n", RoleID: editor.ID}
f.users.nextID = exportUnverifID
f.types.types[1] = contenttype.ContentType{ID: 1, Key: "testimonial", Name: "Testimonial"}
f.seedMartaContent()
f.seedInesContent()
getUser := useCases.NewGetUserUseCase(f.users)
handler := user.NewExportHandler(useCases.NewExportUserDataUseCase(
f.users, f.roles, f.authors, f.keys, f.articles, f.entries, f.types, f.images, f.events, f.clock.now, f.recorder, maxConcurrent,
))
f.router = chi.NewRouter()
f.router.Route("/v1/user", 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.With(middleware.NewUserSuccessLimiterAt(15*time.Minute, f.clock.now)).Get("/me/export", handler.Export)
})
return f
}
func (f *exportFixture) seedMartaContent() {
stamp := time.Date(2026, time.April, 3, 8, 0, 0, 0, time.UTC)
f.articles.articles[1] = article.Article{ID: 1, Title: "Limewash basics", Body: "Mix lime putty.", Slug: "limewash-basics", SiteID: 1, IsOnline: true, CreatedBy: exportMartaID, CreatedAt: stamp, UpdatedAt: stamp}
f.articles.articles[2] = article.Article{ID: 2, Title: "Draft on tadelakt", Body: "Soap and polish.", Slug: "draft-tadelakt", SiteID: 1, CreatedBy: exportMartaID, CreatedAt: stamp, UpdatedAt: stamp}
f.entries.entries[1] = entry.Entry{ID: 1, ContentTypeID: 1, Data: map[string]any{"quote": "Loved it"}, CreatedBy: exportMartaID, UpdatedBy: exportMartaID, CreatedAt: stamp, UpdatedAt: stamp}
for i, name := range []string{"wall.png", "floor.jpg", "ceiling.webp"} {
f.images.images[uint32(i+1)] = image.Image{ID: uint32(i + 1), Path: "/static/uploadedImages/stored-" + name, Filename: name, Size: 2048, ContentType: "image/png", SHA256: strings.Repeat("a", 63) + string(rune('1'+i)), UploadedByID: exportMartaID, CreatedAt: stamp}
}
f.keys.keys[1] = apikey.APIKey{ID: 1, UserID: exportMartaID, Name: "CI deploy", Prefix: "rcms_ab12", HashedKey: "sha-of-the-key", CreatedAt: stamp}
for i := range 5 {
event := audit.NewEvent(audit.ActionLogin, audit.CategoryAuth)
event.ID = uint64(i + 1)
event.CreatedAt = stamp.Add(time.Duration(i) * time.Hour)
event.Actor = audit.Actor{Type: audit.ActorUser, ID: idPointer(exportMartaID), Name: "Marta Alves"}
event.Target = audit.Target{Type: audit.TargetUser, ID: "7", Name: "Marta Alves"}
event.Source = audit.SourceWeb
f.events.events = append(f.events.events, event)
}
}
func (f *exportFixture) seedInesContent() {
stamp := time.Date(2026, time.May, 3, 8, 0, 0, 0, time.UTC)
f.articles.articles[3] = article.Article{ID: 3, Title: "Ines private plan", Body: "Secret ines text", Slug: "ines-plan", SiteID: 1, CreatedBy: exportInesID, CreatedAt: stamp, UpdatedAt: stamp}
f.entries.entries[2] = entry.Entry{ID: 2, ContentTypeID: 1, Data: map[string]any{"quote": "Ines quote"}, CreatedBy: exportInesID, UpdatedBy: exportInesID, CreatedAt: stamp, UpdatedAt: stamp}
f.images.images[10] = image.Image{ID: 10, Path: "/static/uploadedImages/ines.png", Filename: "ines.png", Size: 10, ContentType: "image/png", UploadedByID: exportInesID, CreatedAt: stamp}
f.keys.keys[2] = apikey.APIKey{ID: 2, UserID: exportInesID, Name: "ines key", Prefix: "rcms_zz99", HashedKey: "ines-hash", CreatedAt: stamp}
targeted := audit.NewEvent(audit.ActionUserChangeRole, audit.CategoryUsers)
targeted.ID = 100
targeted.CreatedAt = stamp
targeted.Actor = audit.Actor{Type: audit.ActorUser, ID: idPointer(exportMartaID), Name: "Marta Alves"}
targeted.Target = audit.Target{Type: audit.TargetUser, ID: "8", Name: "Ines Duarte"}
targeted.Source = audit.SourceWeb
inesOwn := audit.NewEvent(audit.ActionLogin, audit.CategoryAuth)
inesOwn.ID = 101
inesOwn.Actor = audit.Actor{Type: audit.ActorUser, ID: idPointer(exportInesID), Name: "Ines Duarte"}
inesOwn.Source = audit.SourceWeb
anonymous := audit.NewEvent(audit.ActionLoginFailed, audit.CategoryAuth)
anonymous.ID = 102
anonymous.Actor = audit.AnonymousActor("marta")
anonymous.Outcome = audit.OutcomeFailed
anonymous.Source = audit.SourceWeb
f.events.events = append(f.events.events, targeted, inesOwn, anonymous)
}
func (f *exportFixture) export(t *testing.T, userID uint32) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodGet, exportRoute, nil)
req.AddCookie(displayNameCookie(t, f.token, userID, exportTokenVer))
rec := httptest.NewRecorder()
f.router.ServeHTTP(rec, req)
return rec
}
func openExportArchive(t *testing.T, rec *httptest.ResponseRecorder) map[string][]byte {
t.Helper()
body := rec.Body.Bytes()
reader, err := zip.NewReader(bytes.NewReader(body), int64(len(body)))
if err != nil {
t.Fatalf("response is not a zip: %v", err)
}
files := map[string][]byte{}
for _, file := range reader.File {
opened, err := file.Open()
if err != nil {
t.Fatalf("open %s: %v", file.Name, err)
}
content, err := io.ReadAll(opened)
_ = opened.Close()
if err != nil {
t.Fatalf("read %s: %v", file.Name, err)
}
files[file.Name] = content
}
return files
}
func exportJSON(t *testing.T, files map[string][]byte, name string) map[string]any {
t.Helper()
var decoded map[string]any
if err := json.Unmarshal(files[name], &decoded); err != nil {
t.Fatalf("%s is not a JSON object: %v", name, err)
}
return decoded
}
func exportList(t *testing.T, decoded map[string]any, key string) []any {
t.Helper()
list, ok := decoded[key].([]any)
if !ok {
t.Fatalf("expected a list under %q, got %T", key, decoded[key])
}
return list
}
func collectKeys(value any, into *[]string) {
switch typed := value.(type) {
case map[string]any:
for key, nested := range typed {
*into = append(*into, key)
collectKeys(nested, into)
}
case []any:
for _, nested := range typed {
collectKeys(nested, into)
}
}
}
func assertExportStatusAndBody(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 TestAccountExportDataEndpointHTTP(t *testing.T) {
t.Run("A first export returns the archive", func(t *testing.T) {
f := newExportFixture(t)
rec := f.export(t, exportMartaID)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
if got := rec.Header().Get("Content-Disposition"); got != `attachment; filename="renewcms-export-marta-2026-10-29.zip"` {
t.Errorf("Content-Disposition %q", got)
}
if rec.Header().Get("Content-Type") != "application/zip" || rec.Header().Get("Cache-Control") != "no-store" {
t.Errorf("headers %v", rec.Header())
}
if rec.Header().Get("Content-Length") != strconv.Itoa(rec.Body.Len()) {
t.Errorf("Content-Length %q, body %d", rec.Header().Get("Content-Length"), rec.Body.Len())
}
files := openExportArchive(t, rec)
names := []string{}
for name := range files {
names = append(names, name)
}
slices.Sort(names)
if !slices.Equal(names, exportFiles) {
t.Fatalf("files %v", names)
}
profile := exportJSON(t, files, "profile.json")
if profile["username"] != "marta" || profile["email"] != "marta@renew.example" || profile["pending_email"] != "marta.new@renew.example" {
t.Errorf("profile %v", profile)
}
if roleBlock, _ := profile["role"].(map[string]any); roleBlock["name"] != role.WriterName {
t.Errorf("role %v", profile["role"])
}
if profile["author"] != nil {
t.Errorf("author should be null, got %v", profile["author"])
}
keys := exportList(t, profile, "api_keys")
if len(keys) != 1 || keys[0].(map[string]any)["prefix"] != "rcms_ab12" || keys[0].(map[string]any)["name"] != "CI deploy" {
t.Errorf("api keys %v", keys)
}
authored := exportJSON(t, files, "authored-content.json")
if len(exportList(t, authored, "articles")) != 2 || len(exportList(t, authored, "entries")) != 1 {
t.Errorf("authored %v", authored)
}
entryBlock := exportList(t, authored, "entries")[0].(map[string]any)
if entryBlock["content_type"] != "testimonial" {
t.Errorf("entry %v", entryBlock)
}
media := exportJSON(t, files, "uploaded-media.json")
references := exportList(t, media, "images")
if len(references) != 3 || references[0].(map[string]any)["mime"] != "image/png" {
t.Errorf("media %v", media)
}
activity := exportJSON(t, files, "account-activity.json")
if got := len(exportList(t, activity, "events")); got != 5 {
t.Errorf("expected 5 events, got %d", got)
}
if len(f.recorder.events) != 1 {
t.Fatalf("expected one audit event, got %+v", f.recorder.events)
}
recorded := f.recorder.events[0]
if recorded.Action != "user.export_data" || recorded.Category != audit.CategoryUsers || recorded.Target.ID != "7" {
t.Errorf("event %+v", recorded)
}
})
t.Run("The archive never contains secrets and is pretty-printed", func(t *testing.T) {
f := newExportFixture(t)
files := openExportArchive(t, f.export(t, exportMartaID))
forbiddenKeys := []string{"password", "token_version", "hashed_key", "verification_code", "password_reset_code", "email_change_code"}
forbiddenText := []string{exportSecret, "reset-code-xyz", "verification-code-xyz", "email-change-code-xyz", "sha-of-the-key"}
for name, content := range files {
var decoded any
if err := json.Unmarshal(content, &decoded); err != nil {
t.Fatalf("%s: %v", name, err)
}
var keys []string
collectKeys(decoded, &keys)
for _, key := range keys {
if slices.Contains(forbiddenKeys, key) || strings.Contains(key, "code") || strings.Contains(key, "token") {
t.Errorf("%s exposes key %q", name, key)
}
}
for _, text := range forbiddenText {
if bytes.Contains(content, []byte(text)) {
t.Errorf("%s contains %q", name, text)
}
}
if !bytes.Contains(content, []byte("\n ")) {
t.Errorf("%s is not pretty-printed", name)
}
}
})
t.Run("A second export within 15 minutes is refused", func(t *testing.T) {
f := newExportFixture(t)
f.export(t, exportMartaID)
f.clock.advance(3 * time.Minute)
listsBefore := f.images.uploaderLists
rec := f.export(t, exportMartaID)
assertExportStatusAndBody(t, rec, http.StatusTooManyRequests, "Too many requests. Please try again later.")
if rec.Header().Get("Retry-After") != "720" {
t.Errorf("Retry-After %q", rec.Header().Get("Retry-After"))
}
if f.images.uploaderLists != listsBefore || len(f.recorder.events) != 1 {
t.Errorf("an archive was built or an event recorded: lists %d events %d", f.images.uploaderLists, len(f.recorder.events))
}
})
t.Run("The slot is per user and expires", func(t *testing.T) {
f := newExportFixture(t)
f.export(t, exportMartaID)
f.clock.advance(15 * time.Minute)
f.export(t, exportInesID)
f.clock.advance(time.Minute)
marta := f.export(t, exportMartaID)
ines := f.export(t, exportInesID)
if marta.Code != http.StatusOK {
t.Errorf("marta expected 200, got %d", marta.Code)
}
if ines.Code != http.StatusTooManyRequests {
t.Errorf("ines expected 429, got %d", ines.Code)
}
})
t.Run("A failed export does not consume the slot", func(t *testing.T) {
f := newExportFixture(t)
f.images.fail.failNext("ListByUploader")
failed := f.export(t, exportMartaID)
if failed.Code != http.StatusInternalServerError || strings.HasPrefix(failed.Header().Get("Content-Type"), "application/zip") || failed.Header().Get("Content-Disposition") != "" {
t.Errorf("expected a plain 500, got %d %v", failed.Code, failed.Header())
}
if strings.Contains(failed.Body.String(), "PK") || len(f.recorder.events) != 0 {
t.Errorf("body %q events %d", failed.Body.String(), len(f.recorder.events))
}
retried := f.export(t, exportMartaID)
if retried.Code != http.StatusOK {
t.Errorf("expected 200 after the failure is gone, got %d", retried.Code)
}
})
t.Run("Only a real verified session may export", func(t *testing.T) {
cases := []struct {
name string
status int
text string
build func(f *exportFixture) *http.Request
}{
{"nobody (no cookie)", http.StatusUnauthorized, "You must be logged in to access this resource.", func(f *exportFixture) *http.Request {
return httptest.NewRequest(http.MethodGet, exportRoute, nil)
}},
{"an API key bearer", http.StatusForbidden, "This action needs a real session.", func(f *exportFixture) *http.Request {
req := httptest.NewRequest(http.MethodGet, exportRoute, nil)
req.Header.Set("Authorization", "Bearer rcms_anything")
return req
}},
{"an unverified user", http.StatusForbidden, "You must verify your account to access this resource.", func(f *exportFixture) *http.Request {
req := httptest.NewRequest(http.MethodGet, exportRoute, nil)
req.AddCookie(displayNameCookie(t, f.token, exportUnverifID, 0))
return req
}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
f := newExportFixture(t)
rec := httptest.NewRecorder()
f.router.ServeHTTP(rec, c.build(f))
assertExportStatusAndBody(t, rec, c.status, c.text)
})
}
})
t.Run("Someone else's data is never included", func(t *testing.T) {
f := newExportFixture(t)
files := openExportArchive(t, f.export(t, exportMartaID))
for name, content := range files {
lowered := strings.ToLower(string(content))
for _, leak := range []string{"ines", "duarte", "secret ines"} {
if strings.Contains(lowered, leak) {
t.Errorf("%s mentions %q", name, leak)
}
}
}
activity := exportJSON(t, files, "account-activity.json")
for _, raw := range exportList(t, activity, "events") {
event := raw.(map[string]any)
if event["action"] == "auth.login_failed" || event["action"] == "user.change_role" {
t.Errorf("unexpected event %v", event)
}
}
})
t.Run("A user with a linked author profile", func(t *testing.T) {
f := newExportFixture(t)
f.authors.seedLinked(1, "Marta Alves", "marta-alves", exportMartaID)
f.articles.articles[4] = article.Article{ID: 4, Title: "Credited piece", Body: "Co-written", Slug: "credited", SiteID: 1, CreatedBy: exportInesID, IsOnline: true}
f.articles.credit(4, 1)
f.articles.credit(3)
files := openExportArchive(t, f.export(t, exportMartaID))
profile := exportJSON(t, files, "profile.json")
authorBlock, _ := profile["author"].(map[string]any)
if authorBlock["slug"] != "marta-alves" {
t.Errorf("author %v", profile["author"])
}
articles := exportList(t, exportJSON(t, files, "authored-content.json"), "articles")
ids := []float64{}
for _, raw := range articles {
ids = append(ids, raw.(map[string]any)["id"].(float64))
}
if !slices.Contains(ids, 4) || len(ids) != 3 {
t.Errorf("article ids %v", ids)
}
})
}
func (f *exportAuditFake) AnonymiseUser(userID uint32, _ *uint32, name string) error {
for i, event := range f.events {
if event.Actor.Type == audit.ActorUser && event.Actor.ID != nil && *event.Actor.ID == userID {
f.events[i].Actor.Name = name
f.events[i].IP = nil
f.events[i].UserAgent = nil
}
if event.Target.Type == audit.TargetUser && event.Target.ID == strconv.FormatUint(uint64(userID), 10) {
f.events[i].Target.Name = name
}
}
return nil
}
@@ -0,0 +1,129 @@
package test
import (
"RenewCMS/internal/domain/article"
entity "RenewCMS/internal/infrastructure/persistence/models"
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
"net/http"
"slices"
"testing"
"gorm.io/gorm"
)
func TestAccountExportDataReadsContract(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("articles are those created by the user, drafts included, or published ones crediting the user's author, without soft-deleted ones", func(t *testing.T) {
db := backend.open(t)
seedExportRows(t, db)
articles := persistence.NewArticleRepository(db)
authorID := uint32(5)
mine, err := articles.ListForAccount(7, nil)
credited, creditedErr := articles.ListForAccount(7, &authorID)
if err != nil || creditedErr != nil {
t.Fatalf("errors %v %v", err, creditedErr)
}
if got := articleIDs(mine); !slices.Equal(got, []uint32{1, 8}) {
t.Errorf("created by marta %v", got)
}
if got := articleIDs(credited); !slices.Equal(got, []uint32{1, 3, 8}) {
t.Errorf("created or credited %v", got)
}
})
t.Run("entries are those created by the user", func(t *testing.T) {
db := backend.open(t)
seedExportRows(t, db)
entries, err := persistence.NewEntryRepository(db).ListByCreator(7)
if err != nil || len(entries) != 1 || entries[0].ID != 1 {
t.Errorf("entries %+v %v", entries, err)
}
})
t.Run("images are those uploaded by the user without soft-deleted ones", func(t *testing.T) {
db := backend.open(t)
seedExportRows(t, db)
images, err := persistence.NewImageRepository(db).ListByUploader(7)
if err != nil || len(images) != 1 || images[0].ID != 1 {
t.Errorf("images %+v %v", images, err)
}
})
})
}
}
func articleIDs(listed []article.Article) []uint32 {
ids := make([]uint32, 0, len(listed))
for _, item := range listed {
ids = append(ids, item.ID)
}
return ids
}
func seedExportRows(t *testing.T, db *gorm.DB) {
t.Helper()
rows := []any{
&entity.Article{ID: 1, Title: "Mine", Slug: "mine", CreatedBy: 7},
&entity.Article{ID: 2, Title: "Ines", Slug: "ines", CreatedBy: 8},
&entity.Article{ID: 3, Title: "Credited", Slug: "credited", CreatedBy: 8, IsOnline: true},
&entity.Article{ID: 7, Title: "Credited draft", Slug: "credited-draft", CreatedBy: 8},
&entity.Article{ID: 8, Title: "Mine draft", Slug: "mine-draft", CreatedBy: 7},
&entity.Article{ID: 4, Title: "Gone", Slug: "gone", CreatedBy: 7},
&entity.Article{ID: 6, Title: "Credited gone", Slug: "credited-gone", CreatedBy: 8, IsOnline: true},
&entity.ArticleAuthor{ArticleID: 3, AuthorID: 5},
&entity.ArticleAuthor{ArticleID: 7, AuthorID: 5},
&entity.ArticleAuthor{ArticleID: 6, AuthorID: 5},
&entity.Entry{ID: 1, ContentTypeID: 1, Data: "{}", CreatedBy: 7, UpdatedBy: 7},
&entity.Entry{ID: 2, ContentTypeID: 1, Data: "{}", CreatedBy: 8, UpdatedBy: 8},
&entity.Image{ID: 1, Path: "/a.png", Filename: "a.png", UploadedByID: 7},
&entity.Image{ID: 2, Path: "/b.png", Filename: "b.png", UploadedByID: 8},
&entity.Image{ID: 3, Path: "/c.png", Filename: "c.png", UploadedByID: 7},
}
for _, row := range rows {
if err := db.Create(row).Error; err != nil {
t.Fatalf("seed %T: %v", row, err)
}
}
for _, id := range []uint32{4, 6} {
if err := db.Delete(&entity.Article{}, id).Error; err != nil {
t.Fatalf("soft delete article %d: %v", id, err)
}
}
if err := db.Delete(&entity.Image{}, 3).Error; err != nil {
t.Fatalf("soft delete image: %v", err)
}
}
func TestAccountExportDataFilenameHTTP(t *testing.T) {
cases := map[string]string{
"Marta.Alves_2": "renewcms-export-marta-alves-2-2026-10-29.zip",
"ÉLODIE": "renewcms-export-lodie-2026-10-29.zip",
"***": "renewcms-export-user-2026-10-29.zip",
"az09": "renewcms-export-az09-2026-10-29.zip",
}
for username, filename := range cases {
t.Run(username, func(t *testing.T) {
f := newExportFixture(t)
stored := f.users.users[exportMartaID]
stored.Username = username
f.users.users[exportMartaID] = stored
rec := f.export(t, exportMartaID)
expected := `attachment; filename="` + filename + `"`
if rec.Code != http.StatusOK || rec.Header().Get("Content-Disposition") != expected {
t.Errorf("got %d %q, want %q", rec.Code, rec.Header().Get("Content-Disposition"), expected)
}
})
}
}
+231
View File
@@ -0,0 +1,231 @@
package test
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/author"
"RenewCMS/internal/infrastructure/useCases"
"bytes"
"context"
"errors"
"net/http"
"net/http/httptest"
"slices"
"strconv"
"testing"
"time"
)
func exportedArticleIDs(t *testing.T, f *exportFixture) []float64 {
t.Helper()
files := openExportArchive(t, f.export(t, exportMartaID))
ids := []float64{}
for _, raw := range exportList(t, exportJSON(t, files, "authored-content.json"), "articles") {
ids = append(ids, raw.(map[string]any)["id"].(float64))
}
slices.Sort(ids)
return ids
}
func TestAccountExportDataArticleScope(t *testing.T) {
t.Run("a draft by someone else credited to the user is excluded", func(t *testing.T) {
f := newExportFixture(t)
f.authors.seedLinked(1, "Marta Alves", "marta-alves", exportMartaID)
f.articles.articles[4] = article.Article{ID: 4, Title: "Ines draft", CreatedBy: exportInesID}
f.articles.credit(4, 1)
if ids := exportedArticleIDs(t, f); !slices.Equal(ids, []float64{1, 2}) {
t.Errorf("article ids %v", ids)
}
})
t.Run("a published article by someone else credited to the user is included", func(t *testing.T) {
f := newExportFixture(t)
f.authors.seedLinked(1, "Marta Alves", "marta-alves", exportMartaID)
f.articles.articles[4] = article.Article{ID: 4, Title: "Ines published", CreatedBy: exportInesID, IsOnline: true}
f.articles.credit(4, 1)
if ids := exportedArticleIDs(t, f); !slices.Equal(ids, []float64{1, 2, 4}) {
t.Errorf("article ids %v", ids)
}
})
t.Run("the user's own draft is included", func(t *testing.T) {
f := newExportFixture(t)
if ids := exportedArticleIDs(t, f); !slices.Equal(ids, []float64{1, 2}) || f.articles.articles[2].IsOnline {
t.Errorf("article ids %v", ids)
}
})
}
func TestAccountExportDataCoAuthorsAreReduced(t *testing.T) {
f := newExportFixture(t)
f.authors.seedLinked(1, "Marta Alves", "marta-alves", exportMartaID)
coAuthor := f.authors.seed(2, "Dario Costa", "dario-costa")
coAuthor.Bio = "Plasterer from Porto"
coAuthor.Website = new(string)
coAuthor.AvatarID = idPointer(9)
coAuthor.SocialLinks = []author.SocialLink{{URL: "https://example.com/dario"}}
f.authors.authors[2] = coAuthor
f.articles.credit(1, 1, 2)
files := openExportArchive(t, f.export(t, exportMartaID))
var keys []string
collectKeys(exportJSON(t, files, "authored-content.json"), &keys)
for _, key := range keys {
if slices.Contains([]string{"bio", "website", "social_links", "avatar_id"}, key) {
t.Errorf("authored-content.json exposes %q", key)
}
}
first := exportList(t, exportJSON(t, files, "authored-content.json"), "articles")[0].(map[string]any)
credits := first["authors"].([]any)
if len(credits) != 2 || credits[1].(map[string]any)["name"] != "Dario Costa" || credits[1].(map[string]any)["slug"] != "dario-costa" {
t.Errorf("credits %v", credits)
}
profile := exportJSON(t, files, "profile.json")["author"].(map[string]any)
if _, ok := profile["social_links"]; !ok {
t.Errorf("profile.json author must stay complete: %v", profile)
}
}
func TestEventConcernsOtherPerson(t *testing.T) {
ownAuthor := idPointer(5)
targeted := func(kind string, id uint32) audit.Event {
event := audit.NewEvent("x", audit.CategoryUsers)
event.Target = audit.Target{Type: kind, ID: strconv.Itoa(int(id))}
return event
}
cases := []struct {
name string
event audit.Event
own *uint32
want bool
}{
{"own author", targeted(audit.TargetAuthor, 5), ownAuthor, false},
{"another author", targeted(audit.TargetAuthor, 6), ownAuthor, true},
{"an author when the user has none", targeted(audit.TargetAuthor, 5), nil, true},
{"an author target without an id", audit.Event{Target: audit.Target{Type: audit.TargetAuthor, Name: "Dario"}}, ownAuthor, true},
{"the user themself", targeted(audit.TargetUser, 7), ownAuthor, false},
{"another user", targeted(audit.TargetUser, 8), ownAuthor, true},
{"an invite", targeted(audit.TargetInvite, 3), ownAuthor, true},
{"an article", targeted(audit.TargetArticle, 3), ownAuthor, false},
{"no target", audit.Event{}, nil, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := tc.event.ConcernsOtherPerson(7, tc.own); got != tc.want {
t.Errorf("got %v, want %v", got, tc.want)
}
})
}
}
func TestAccountExportDataDropsEventsOnOtherAuthors(t *testing.T) {
f := newExportFixture(t)
f.authors.seedLinked(1, "Marta Alves", "marta-alves", exportMartaID)
own := audit.NewEvent(audit.ActionAuthorEdit, audit.CategoryAuthors)
own.ID, own.Actor = 200, audit.Actor{Type: audit.ActorUser, ID: idPointer(exportMartaID)}
own.Target = audit.NewTarget(audit.TargetAuthor, 1, "Marta Alves")
other := own
other.ID = 201
other.Target = audit.NewTarget(audit.TargetAuthor, 2, "Dario Costa")
f.events.events = append(f.events.events, own, other)
files := openExportArchive(t, f.export(t, exportMartaID))
ids := []float64{}
for _, raw := range exportList(t, exportJSON(t, files, "account-activity.json"), "events") {
ids = append(ids, raw.(map[string]any)["id"].(float64))
}
if !slices.Contains(ids, 200) || slices.Contains(ids, 201) {
t.Errorf("event ids %v", ids)
}
}
func TestAccountExportDataEncodesStraightIntoTheArchive(t *testing.T) {
f := newExportFixture(t)
files := openExportArchive(t, f.export(t, exportMartaID))
for name, content := range files {
if !bytes.HasSuffix(content, []byte("}\n")) || bytes.HasSuffix(content, []byte("\n\n")) || !bytes.HasPrefix(content, []byte("{\n \"")) {
t.Errorf("%s is not indented JSON ending with a single newline", name)
}
}
}
func TestAccountExportDataConcurrencyCap(t *testing.T) {
newBlocked := func(t *testing.T) (*exportFixture, chan struct{}, chan struct{}) {
f := newExportFixtureWithCap(t, 1)
entered := make(chan struct{}, 1)
release := make(chan struct{})
once := false
f.images.onListByUploader = func() {
if once {
return
}
once = true
entered <- struct{}{}
<-release
}
return f, entered, release
}
t.Run("a build waits for a free slot and gives up when its request ends", func(t *testing.T) {
f, entered, release := newBlocked(t)
firstDone := make(chan *httptest.ResponseRecorder)
go func() { firstDone <- f.export(t, exportMartaID) }()
<-entered
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond)
defer cancel()
req := httptest.NewRequestWithContext(ctx, http.MethodGet, exportRoute, nil)
req.AddCookie(displayNameCookie(t, f.token, exportInesID, exportTokenVer))
waiting := httptest.NewRecorder()
f.router.ServeHTTP(waiting, req)
if waiting.Code != http.StatusServiceUnavailable {
t.Errorf("waiting build = %d, want 503", waiting.Code)
}
close(release)
if first := <-firstDone; first.Code != http.StatusOK {
t.Errorf("first build = %d", first.Code)
}
if len(f.recorder.events) != 1 {
t.Errorf("events %d, want only the first build's", len(f.recorder.events))
}
if retried := f.export(t, exportInesID); retried.Code != http.StatusOK {
t.Errorf("refused user must keep the slot free, got %d", retried.Code)
}
})
t.Run("the slot is released when a build fails", func(t *testing.T) {
f := newExportFixtureWithCap(t, 1)
f.images.fail.failNext("ListByUploader")
failed := f.export(t, exportMartaID)
next := f.export(t, exportMartaID)
if failed.Code != http.StatusInternalServerError || next.Code != http.StatusOK {
t.Errorf("failed %d then %d", failed.Code, next.Code)
}
})
t.Run("the use case returns the context error", func(t *testing.T) {
f, entered, release := newBlocked(t)
usecase := useCases.NewExportUserDataUseCase(f.users, f.roles, f.authors, f.keys, f.articles, f.entries, f.types, f.images, f.events, f.clock.now, f.recorder, 1)
f.images.onListByUploader = func() { entered <- struct{}{}; <-release }
done := make(chan error)
go func() { _, err := usecase.Export(context.Background(), exportMartaID); done <- err }()
<-entered
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err := usecase.Export(ctx, exportInesID)
if !errors.Is(err, context.Canceled) {
t.Errorf("err %v", err)
}
close(release)
if err := <-done; err != nil {
t.Errorf("first build %v", err)
}
})
}
+2 -2
View File
@@ -69,14 +69,14 @@ func TestActivityDomain(t *testing.T) {
})
}
t.Run("The repository exposes no way to edit or delete one event", func(t *testing.T) {
t.Run("The repository exposes no way to edit or delete one event, only to anonymise a user's events", func(t *testing.T) {
repoType := reflect.TypeOf((*audit.Repository)(nil)).Elem()
var names []string
for i := 0; i < repoType.NumMethod(); i++ {
names = append(names, repoType.Method(i).Name)
}
sort.Strings(names)
wanted := []string{"Append", "DeleteOlderThan", "DeleteOldest", "List", "SizeBytes", "Summary"}
wanted := []string{"AnonymiseUser", "Append", "DeleteOlderThan", "DeleteOldest", "List", "SizeBytes", "Summary"}
if !reflect.DeepEqual(names, wanted) {
t.Errorf("expected methods %v, got %v", wanted, names)
}
+2 -2
View File
@@ -94,7 +94,7 @@ func TestActivityPersistence(t *testing.T) {
}
found[strings.Join(parts, ",")] = true
}
for _, wanted := range []string{"created_at", "actor_type,actor_id,created_at", "category,created_at"} {
for _, wanted := range []string{"created_at", "actor_type,actor_id,created_at", "category,created_at", "target_type,target_id"} {
if !found[wanted] {
t.Errorf("missing index on (%s), have %v", wanted, found)
}
@@ -108,7 +108,7 @@ func TestActivityPersistence(t *testing.T) {
names = append(names, repoType.Method(i).Name)
}
sort.Strings(names)
wanted := []string{"Append", "DeleteOlderThan", "DeleteOldest", "List", "SizeBytes", "Summary"}
wanted := []string{"AnonymiseUser", "Append", "DeleteOlderThan", "DeleteOldest", "List", "SizeBytes", "Summary"}
if !reflect.DeepEqual(names, wanted) {
t.Errorf("expected %v, got %v", wanted, names)
}
+2
View File
@@ -621,3 +621,5 @@ func mustMarshalChanges(t *testing.T, event audit.Event) []byte {
}
return encoded
}
func (f *sizedEventsFake) AnonymiseUser(uint32, *uint32, string) error { return nil }
+4
View File
@@ -185,3 +185,7 @@ func retentionDays(days int) useCases.UpdateActivityRetentionCommand {
func retentionMaxGB(maxGB int) useCases.UpdateActivityRetentionCommand {
return useCases.UpdateActivityRetentionCommand{MaxGB: &maxGB}
}
func (p *activityProbeRepo) AnonymiseUser(userID uint32, ownAuthorID *uint32, name string) error {
return p.inner.AnonymiseUser(userID, ownAuthorID, name)
}
+6
View File
@@ -50,9 +50,15 @@ func routerRoutes(t *testing.T) map[string]bool {
}
var routesNotYetInTheCatalogue = []string{
"DELETE /v1/auth/email/change",
"POST /v1/auth/email/change",
"POST /v1/auth/email/confirm",
"POST /v1/auth/email/resend",
"DELETE /v1/role/{id}",
"DELETE /v1/user/{id}",
"DELETE /v1/user/me",
"GET /v1/user/lookup",
"GET /v1/user/me/export",
"GET /v1/permission",
"GET /v1/role",
"GET /v1/role/{id}",
+15
View File
@@ -5,6 +5,7 @@ import (
"RenewCMS/internal/domain/author"
"RenewCMS/internal/infrastructure/useCases"
"errors"
"slices"
"sort"
"strings"
"testing"
@@ -52,6 +53,20 @@ func (m *mockArticleRepo) withAuthors(a article.Article) article.Article {
return a
}
func (m *mockArticleRepo) ListForAccount(creatorID uint32, authorID *uint32) ([]article.Article, error) {
if err := m.fail.trigger("ListForAccount"); err != nil {
return nil, err
}
listed := []article.Article{}
for _, id := range m.sortedIds() {
stored := m.articles[id]
if stored.CreatedBy == creatorID || stored.IsOnline && authorID != nil && slices.Contains(m.credits[id], *authorID) {
listed = append(listed, m.withAuthors(stored))
}
}
return listed, nil
}
func (m *mockArticleRepo) credit(articleID uint32, authorIDs ...uint32) {
m.credits[articleID] = append([]uint32{}, authorIDs...)
}
+15
View File
@@ -112,6 +112,7 @@ type mockEntryRepo struct {
failUpdateOn int
updatedIDs []uint32
afterListPage func()
fail failSwitch
}
var _ entry.Repository = &mockEntryRepo{}
@@ -183,6 +184,20 @@ func sortEntries(items []entry.Entry, order entry.Sort) {
})
}
func (m *mockEntryRepo) ListByCreator(creatorID uint32) ([]entry.Entry, error) {
if err := m.fail.trigger("ListByCreator"); err != nil {
return nil, err
}
listed := []entry.Entry{}
for _, stored := range m.entries {
if stored.CreatedBy == creatorID {
listed = append(listed, stored)
}
}
sort.Slice(listed, func(i, j int) bool { return listed[i].ID < listed[j].ID })
return listed, nil
}
func (m *mockEntryRepo) Update(record entry.Entry) (entry.Entry, error) {
m.updateCalls++
m.updatedIDs = append(m.updatedIDs, record.ID)
+21
View File
@@ -92,6 +92,27 @@ func TestGetCurrentUserHttp(t *testing.T) {
}
})
t.Run("The profile carries pending_email, null when nothing is pending", func(t *testing.T) {
token := authtoken.New([]byte("test-secret"))
userRepo := newMockUserRepo()
userRepo.users[7] = user.User{ID: 7, Username: "marta", Email: "marta@renew.example", IsVerified: true, RoleID: ownerRoleID}
userRepo.users[8] = user.User{ID: 8, Username: "ines", Email: "ines@renew.example", IsVerified: true, RoleID: ownerRoleID, PendingEmail: "new@renew.example"}
router := newGetCurrentUserRouter(userRepo, &stubHasher{}, token)
none := sendGetCurrentUserRequest(router, http.MethodGet, "/v1/auth/me", mintGetCurrentUserCookie(t, token, 7, 0))
pending := sendGetCurrentUserRequest(router, http.MethodGet, "/v1/auth/me", mintGetCurrentUserCookie(t, token, 8, 0))
var noneBody, pendingBody map[string]any
_ = json.Unmarshal(none.Body.Bytes(), &noneBody)
_ = json.Unmarshal(pending.Body.Bytes(), &pendingBody)
if value, present := noneBody["pending_email"]; !present || value != nil {
t.Errorf("expected pending_email null, got %v (present %v)", value, present)
}
if pendingBody["pending_email"] != "new@renew.example" {
t.Errorf("expected the pending address, got %v", pendingBody["pending_email"])
}
})
t.Run("An unverified user still fetches their own profile, with is_verified false, not a 403", func(t *testing.T) {
token := authtoken.New([]byte("test-secret"))
userRepo := newMockUserRepo()
+19
View File
@@ -83,6 +83,8 @@ type mockImageRepo struct {
onDelete func(id uint32)
storedNameLookups int
uploaderLists int
onListByUploader func()
}
func newMockImageRepo() *mockImageRepo {
@@ -114,6 +116,23 @@ func (m *mockImageRepo) Create(input imageDomain.Image) (imageDomain.Image, erro
return img, nil
}
func (m *mockImageRepo) ListByUploader(userID uint32) ([]imageDomain.Image, error) {
m.uploaderLists++
if m.onListByUploader != nil {
m.onListByUploader()
}
if err := m.fail.trigger("ListByUploader"); err != nil {
return nil, err
}
listed := []imageDomain.Image{}
for _, img := range m.sortedImages() {
if img.UploadedByID == userID {
listed = append(listed, img)
}
}
return listed, nil
}
func (m *mockImageRepo) ListMissingMetadata() ([]imageDomain.Image, error) {
if err := m.fail.trigger("ListMissingMetadata"); err != nil {
return nil, err
+8
View File
@@ -197,6 +197,12 @@ func TestSQLiteSchemaIsUntouchedByTheModelChanges(t *testing.T) {
t.Errorf("a legacy user must read a NULL role_assigned_by_id: %v", row)
}
delete(row, "role_assigned_by_id")
for _, column := range addedUserEmailChangeColumns {
if value, added := row[column]; !added || (value != nil && column != "email_change_expiration") {
t.Errorf("a legacy user must read an empty %s: %v", column, row)
}
delete(row, column)
}
}
for _, row := range after.rows["invites"] {
if row["role_id"] == nil {
@@ -210,6 +216,8 @@ func TestSQLiteSchemaIsUntouchedByTheModelChanges(t *testing.T) {
})
}
var addedUserEmailChangeColumns = []string{"pending_email", "email_change_code", "email_change_expiration"}
var addedArticleColumns = []string{"site_id", "created_by", "slug", "excerpt", "seo_title", "seo_description", "cover_image_id", "cover_image_alt", "social_title", "social_description", "social_image_id", "canonical_url", "noindex", "seo_dismissed"}
func assertSiteColumnAdded(t *testing.T, db *gorm.DB) {
@@ -61,6 +61,62 @@ func TestTransactionalEmailTemplates(t *testing.T) {
}
})
t.Run("render() produces the verify-new-email email's subject and link", func(t *testing.T) {
repo := mailer.NewMailRepository(config.SmtpSettings{})
subject, body, err := repo.Render("verify-new-email", useCases.MailVerifyNewEmailData{
Host: "https://cms.example.com",
Username: "marta",
VerificationCode: "abc",
})
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if subject != "Confirm your new RenewCMS email address" {
t.Errorf("unexpected subject %q", subject)
}
if !strings.Contains(body, "https://cms.example.com/account/email/confirm?c=abc") || strings.Contains(body, "{{") {
t.Errorf("expected the confirmation link and no unreplaced action, got %q", body)
}
})
t.Run("render() produces the email-change-notice email's subject, new address and profile link", func(t *testing.T) {
repo := mailer.NewMailRepository(config.SmtpSettings{})
subject, body, err := repo.Render("email-change-notice", useCases.MailEmailChangeNoticeData{
Host: "https://cms.example.com",
Username: "marta",
NewEmail: "new@renew.example",
})
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if subject != "Your RenewCMS email address change was requested" {
t.Errorf("unexpected subject %q", subject)
}
if !strings.Contains(body, "new@renew.example") || !strings.Contains(body, "https://cms.example.com/profile") || strings.Contains(body, "{{") {
t.Errorf("expected the new address, the profile link and no unreplaced action, got %q", body)
}
})
t.Run("render() produces the account-deleted email without personal data", func(t *testing.T) {
repo := mailer.NewMailRepository(config.SmtpSettings{})
subject, body, err := repo.Render("account-deleted", useCases.MailAccountDeletedData{Host: "https://cms.example.com"})
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if subject != "Your RenewCMS account has been deleted" {
t.Errorf("unexpected subject %q", subject)
}
if !strings.Contains(body, "https://cms.example.com") || strings.Contains(body, "{{") || strings.Contains(body, "@renew") || strings.Contains(body, "<button") || strings.Contains(body, "<a ") {
t.Errorf("expected the host only, no button, no address and no unreplaced action, got %q", body)
}
})
t.Run("render() falls back to the old default subject for an unrecognized template name", func(t *testing.T) {
repo := mailer.NewMailRepository(config.SmtpSettings{})
+1 -1
View File
@@ -124,7 +124,7 @@ func assertSevenKeys(t *testing.T, body []byte) map[string]json.RawMessage {
func assertMeKeys(t *testing.T, body []byte) map[string]json.RawMessage {
t.Helper()
decoded := decodeKeys(t, body)
expected := append(append([]string{}, displayNameResponseKeys...), "author", "access")
expected := append(append([]string{}, displayNameResponseKeys...), "pending_email", "author", "access")
if len(decoded) != len(expected) {
t.Errorf("expected exactly %d keys, got %d: %s", len(expected), len(decoded), string(body))
}
+80
View File
@@ -6,6 +6,8 @@ import (
"RenewCMS/internal/infrastructure/useCases"
"errors"
"sort"
"strings"
"sync"
"testing"
"time"
)
@@ -58,6 +60,7 @@ type mockUserRepo struct {
beforeTransfer func()
beforeClearVerification func()
knownRoles *mockRoleRepo
confirmLock sync.Mutex
}
func newMockUserRepo() *mockUserRepo {
@@ -80,6 +83,8 @@ func (m *mockUserRepo) sortedIds() []uint32 {
}
func (m *mockUserRepo) Get(id uint32) (user.User, error) {
m.confirmLock.Lock()
defer m.confirmLock.Unlock()
m.getCalls++
if err := m.fail.trigger("Get"); err != nil {
return user.User{}, err
@@ -117,6 +122,81 @@ func (m *mockUserRepo) GetByEmail(email string) (user.User, error) {
return user.User{}, user.ErrUserNotFound
}
func (m *mockUserRepo) IsEmailInUse(email string, exceptUserId uint32) (bool, error) {
m.confirmLock.Lock()
defer m.confirmLock.Unlock()
return m.emailInUse(email, exceptUserId)
}
func (m *mockUserRepo) emailInUse(email string, exceptUserId uint32) (bool, error) {
if err := m.fail.trigger("IsEmailInUse"); err != nil {
return false, err
}
for id, stored := range m.users {
if id != exceptUserId && strings.EqualFold(stored.Email, email) {
return true, nil
}
}
return false, nil
}
func (m *mockUserRepo) SetPendingEmailChange(userId uint32, pendingEmail string, code string, expiration time.Time) (user.User, error) {
if err := m.fail.trigger("SetPendingEmailChange"); err != nil {
return user.User{}, err
}
stored, ok := m.users[userId]
if !ok {
return user.User{}, user.ErrUserNotFound
}
stored.PendingEmail = pendingEmail
stored.EmailChangeCode = code
stored.EmailChangeExpiration = expiration
m.users[userId] = stored
return stored, nil
}
func (m *mockUserRepo) ClearPendingEmailChange(userId uint32) (user.User, error) {
if err := m.fail.trigger("ClearPendingEmailChange"); err != nil {
return user.User{}, err
}
stored, ok := m.users[userId]
if !ok {
return user.User{}, user.ErrUserNotFound
}
stored.PendingEmail = ""
stored.EmailChangeCode = ""
stored.EmailChangeExpiration = time.Time{}
m.users[userId] = stored
return stored, nil
}
func (m *mockUserRepo) ConfirmEmailChange(userId uint32, pendingEmail string, code string, now time.Time) (user.User, error) {
m.confirmLock.Lock()
defer m.confirmLock.Unlock()
if err := m.fail.trigger("ConfirmEmailChange"); err != nil {
return user.User{}, err
}
stored, ok := m.users[userId]
if !ok {
return user.User{}, user.ErrUserNotFound
}
if stored.PendingEmail != pendingEmail || stored.EmailChangeCode != code || !now.Before(stored.EmailChangeExpiration) {
return user.User{}, user.ErrInvalidEmailChangeCode
}
taken, _ := m.emailInUse(pendingEmail, userId)
if !taken {
stored.Email = pendingEmail
}
stored.PendingEmail = ""
stored.EmailChangeCode = ""
stored.EmailChangeExpiration = time.Time{}
m.users[userId] = stored
if taken {
return user.User{}, user.ErrEmailUnavailable
}
return stored, nil
}
func (m *mockUserRepo) GetAll() ([]user.User, error) {
m.getAllCalls++
if err := m.fail.trigger("GetAll"); err != nil {
+1 -1
View File
@@ -606,7 +606,7 @@ func TestUsersAPIPublicUserShape(t *testing.T) {
w := newUsersAPIWorld(t)
rec := w.do(t, uaAdmin, http.MethodGet, "/v1/auth/me", "")
body := usersAPIDecode(t, rec)
apiKeyAssertExactFields(t, body, "id", "username", "email", "is_verified", "created_at", "updated_at", "display_name", "author", "access")
apiKeyAssertExactFields(t, body, "id", "username", "email", "is_verified", "created_at", "updated_at", "display_name", "pending_email", "author", "access")
if usersAPIRoleName(t, rec) != "Admin" {
t.Errorf("access.role must stay")
}