feat: change email, export my data, delete account (with data anonymization)
This commit is contained in:
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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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}",
|
||||
|
||||
@@ -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...)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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{})
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user