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

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

No files matched your search

+21 -2
View File
@@ -98,6 +98,10 @@ type UseCases struct {
RequestPasswordResetUseCase *useCases.RequestPasswordResetUseCase RequestPasswordResetUseCase *useCases.RequestPasswordResetUseCase
ResetPasswordUseCase *useCases.ResetPasswordUseCase ResetPasswordUseCase *useCases.ResetPasswordUseCase
UpdateUserProfileUseCase *useCases.UpdateUserProfileUseCase UpdateUserProfileUseCase *useCases.UpdateUserProfileUseCase
RequestEmailChangeUseCase *useCases.RequestEmailChangeUseCase
ResendEmailChangeUseCase *useCases.ResendEmailChangeUseCase
CancelEmailChangeUseCase *useCases.CancelEmailChangeUseCase
ConfirmEmailChangeUseCase *useCases.ConfirmEmailChangeUseCase
ListUsersUseCase *useCases.ListUsersUseCase ListUsersUseCase *useCases.ListUsersUseCase
CreateInviteUseCase *useCases.CreateInviteUseCase CreateInviteUseCase *useCases.CreateInviteUseCase
CancelInviteUseCase *useCases.CancelInviteUseCase CancelInviteUseCase *useCases.CancelInviteUseCase
@@ -105,6 +109,8 @@ type UseCases struct {
ListUsersAndInvitesUseCase *useCases.ListUsersAndInvitesUseCase ListUsersAndInvitesUseCase *useCases.ListUsersAndInvitesUseCase
ChangeUserRoleUseCase *useCases.ChangeUserRoleUseCase ChangeUserRoleUseCase *useCases.ChangeUserRoleUseCase
RemoveUserUseCase *useCases.RemoveUserUseCase RemoveUserUseCase *useCases.RemoveUserUseCase
ExportUserDataUseCase *useCases.ExportUserDataUseCase
DeleteOwnAccountUseCase *useCases.DeleteOwnAccountUseCase
TransferOwnershipUseCase *useCases.TransferOwnershipUseCase TransferOwnershipUseCase *useCases.TransferOwnershipUseCase
GetInviteUseCase *useCases.GetInviteUseCase GetInviteUseCase *useCases.GetInviteUseCase
AcceptInviteUseCase *useCases.AcceptInviteUseCase AcceptInviteUseCase *useCases.AcceptInviteUseCase
@@ -174,6 +180,7 @@ type Repositories struct {
EntryRepo domainEntry.Repository EntryRepo domainEntry.Repository
AuthorRepo domainAuthor.Repository AuthorRepo domainAuthor.Repository
UserRepo domainUser.Repository UserRepo domainUser.Repository
UserEraser domainUser.Eraser
RoleRepo domainRole.Repository RoleRepo domainRole.Repository
ImageRepo domainImage.Repository ImageRepo domainImage.Repository
Transactor domainTransaction.Transactor Transactor domainTransaction.Transactor
@@ -208,6 +215,8 @@ const (
updateHelperFileName = "update-helper.json" updateHelperFileName = "update-helper.json"
updatesDirName = "updates" updatesDirName = "updates"
backupsDirName = "backups" backupsDirName = "backups"
maxConcurrentDataExports = 2
) )
func imageStorageConfig(settings config.Settings) storage.Config { func imageStorageConfig(settings config.Settings) storage.Config {
@@ -319,6 +328,7 @@ func getRepositories(connection *database.Connection, settings config.Settings)
EntryRepo: persistence.NewEntryRepository(db), EntryRepo: persistence.NewEntryRepository(db),
AuthorRepo: persistence.NewAuthorRepository(db), AuthorRepo: persistence.NewAuthorRepository(db),
UserRepo: persistence.NewUserRepository(db), UserRepo: persistence.NewUserRepository(db),
UserEraser: persistence.NewAccountEraser(db),
RoleRepo: persistence.NewRoleRepository(db), RoleRepo: persistence.NewRoleRepository(db),
ImageRepo: persistence.NewImageRepository(db), ImageRepo: persistence.NewImageRepository(db),
Transactor: persistence.NewTransactor(db), Transactor: persistence.NewTransactor(db),
@@ -350,9 +360,12 @@ func getRepositories(connection *database.Connection, settings config.Settings)
func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings config.Settings) *UseCases { func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings config.Settings) *UseCases {
hasher := hashing.NewBcryptHasher() hasher := hashing.NewBcryptHasher()
mailDispatcher := mailer.NewAsyncDispatcher()
sessionRevocation := useCases.NewSessionRevocationUseCase(repos.SessionRepo, time.Now)
renderer := markdown.NewConverter() renderer := markdown.NewConverter()
keyGenerator := hashing.NewSHA256KeyGenerator() keyGenerator := hashing.NewSHA256KeyGenerator()
recorder := auditing.NewRecorder(repos.AuditRepo, time.Now, log.Default()) recorder := auditing.NewRecorder(repos.AuditRepo, time.Now, log.Default())
mediaUsage := useCases.NewGetMediaUsageUseCase(repos.ImageRepo, repos.ArticleRepo, repos.SiteRepo, repos.AuthorRepo, repos.ContentTypeRepo, repos.EntryRepo)
mergeMedia := useCases.NewMergeMediaUseCase(repos.ImageRepo, repos.ArticleRepo, imageStorage, repos.VariantFileRepo, recorder, repos.ContentTypeRepo, repos.EntryRepo, repos.Transactor, log.Default()) mergeMedia := useCases.NewMergeMediaUseCase(repos.ImageRepo, repos.ArticleRepo, imageStorage, repos.VariantFileRepo, recorder, repos.ContentTypeRepo, repos.EntryRepo, repos.Transactor, log.Default())
runPurge := useCases.NewRunPurgeUseCase(repos.ImageRepo, repos.ArticleRepo, mergeMedia, repos.PurgeSettingsRepo, time.Now, log.Default(), recorder, repos.SiteRepo, repos.AuthorRepo) runPurge := useCases.NewRunPurgeUseCase(repos.ImageRepo, repos.ArticleRepo, mergeMedia, repos.PurgeSettingsRepo, time.Now, log.Default(), recorder, repos.SiteRepo, repos.AuthorRepo)
@@ -366,7 +379,7 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings
PurgeScheduler: scheduler.NewSystemRunner(purgeScheduler, log.Default()), PurgeScheduler: scheduler.NewSystemRunner(purgeScheduler, log.Default()),
ActivityCleanupScheduler: scheduler.NewSystemCleanupRunner(activityCleanup, log.Default()), ActivityCleanupScheduler: scheduler.NewSystemCleanupRunner(activityCleanup, log.Default()),
RetentionScheduler: scheduler.NewSystemRetentionRunner(useCases.NewEnforceRetentionUseCase(repos.UserRepo, repos.InviteRepo, repos.SessionRepo), log.Default()), RetentionScheduler: scheduler.NewSystemRetentionRunner(useCases.NewEnforceRetentionUseCase(repos.UserRepo, repos.InviteRepo, repos.SessionRepo), log.Default()),
Token: authtoken.New([]byte(settings.JWTSecret), authtoken.WithRevocationStore(useCases.NewSessionRevocationUseCase(repos.SessionRepo, time.Now))), Token: authtoken.New([]byte(settings.JWTSecret), authtoken.WithRevocationStore(sessionRevocation)),
CreateArticleUseCase: useCases.NewCreateArticleUseCase(repos.ArticleRepo, renderer, recorder, repos.SiteRepo, repos.AuthorRepo), CreateArticleUseCase: useCases.NewCreateArticleUseCase(repos.ArticleRepo, renderer, recorder, repos.SiteRepo, repos.AuthorRepo),
GetArticleUseCase: useCases.NewGetArticleUseCase(repos.ArticleRepo, renderer), GetArticleUseCase: useCases.NewGetArticleUseCase(repos.ArticleRepo, renderer),
ListArticlesUseCase: useCases.NewListArticlesUseCase(repos.ArticleRepo, renderer, repos.SiteRepo), ListArticlesUseCase: useCases.NewListArticlesUseCase(repos.ArticleRepo, renderer, repos.SiteRepo),
@@ -416,6 +429,10 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings
RequestPasswordResetUseCase: useCases.NewRequestPasswordResetUseCase(repos.UserRepo, hasher, repos.MailRepo, settings.PublicURL, recorder), RequestPasswordResetUseCase: useCases.NewRequestPasswordResetUseCase(repos.UserRepo, hasher, repos.MailRepo, settings.PublicURL, recorder),
ResetPasswordUseCase: useCases.NewResetPasswordUseCase(repos.UserRepo, hasher, repos.APIKeyRepo, time.Now, recorder), ResetPasswordUseCase: useCases.NewResetPasswordUseCase(repos.UserRepo, hasher, repos.APIKeyRepo, time.Now, recorder),
UpdateUserProfileUseCase: useCases.NewUpdateUserProfileUseCase(repos.UserRepo, recorder), UpdateUserProfileUseCase: useCases.NewUpdateUserProfileUseCase(repos.UserRepo, recorder),
RequestEmailChangeUseCase: useCases.NewRequestEmailChangeUseCase(repos.UserRepo, hasher, repos.MailRepo, mailDispatcher, settings.PublicURL, time.Now, recorder),
ResendEmailChangeUseCase: useCases.NewResendEmailChangeUseCase(repos.UserRepo, hasher, repos.MailRepo, mailDispatcher, settings.PublicURL, time.Now, recorder),
CancelEmailChangeUseCase: useCases.NewCancelEmailChangeUseCase(repos.UserRepo, recorder),
ConfirmEmailChangeUseCase: useCases.NewConfirmEmailChangeUseCase(repos.UserRepo, hasher, time.Now, recorder),
ListUsersUseCase: useCases.NewListUsersUseCase(repos.UserRepo), ListUsersUseCase: useCases.NewListUsersUseCase(repos.UserRepo),
CreateInviteUseCase: useCases.NewCreateInviteUseCase(repos.UserRepo, repos.InviteRepo, repos.RoleRepo, repos.ContentTypeRepo, hasher, repos.MailRepo, settings.PublicURL, recorder), CreateInviteUseCase: useCases.NewCreateInviteUseCase(repos.UserRepo, repos.InviteRepo, repos.RoleRepo, repos.ContentTypeRepo, hasher, repos.MailRepo, settings.PublicURL, recorder),
CancelInviteUseCase: useCases.NewCancelInviteUseCase(repos.InviteRepo, recorder), CancelInviteUseCase: useCases.NewCancelInviteUseCase(repos.InviteRepo, recorder),
@@ -423,6 +440,8 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings
ListUsersAndInvitesUseCase: useCases.NewListUsersAndInvitesUseCase(repos.UserRepo, repos.InviteRepo, repos.RoleRepo), ListUsersAndInvitesUseCase: useCases.NewListUsersAndInvitesUseCase(repos.UserRepo, repos.InviteRepo, repos.RoleRepo),
ChangeUserRoleUseCase: useCases.NewChangeUserRoleUseCase(repos.UserRepo, repos.RoleRepo, repos.ContentTypeRepo, recorder), ChangeUserRoleUseCase: useCases.NewChangeUserRoleUseCase(repos.UserRepo, repos.RoleRepo, repos.ContentTypeRepo, recorder),
RemoveUserUseCase: useCases.NewRemoveUserUseCase(repos.UserRepo, repos.RoleRepo, repos.ContentTypeRepo, recorder), RemoveUserUseCase: useCases.NewRemoveUserUseCase(repos.UserRepo, repos.RoleRepo, repos.ContentTypeRepo, recorder),
ExportUserDataUseCase: useCases.NewExportUserDataUseCase(repos.UserRepo, repos.RoleRepo, repos.AuthorRepo, repos.APIKeyRepo, repos.ArticleRepo, repos.EntryRepo, repos.ContentTypeRepo, repos.ImageRepo, repos.AuditRepo, time.Now, recorder, maxConcurrentDataExports),
DeleteOwnAccountUseCase: useCases.NewDeleteOwnAccountUseCase(repos.UserRepo, repos.RoleRepo, hasher, repos.UserEraser, sessionRevocation, repos.MailRepo, mailDispatcher, settings.PublicURL, recorder, repos.AuthorRepo, repos.ImageRepo, mediaUsage, imageStorage, repos.VariantFileRepo),
TransferOwnershipUseCase: useCases.NewTransferOwnershipUseCase(repos.UserRepo, repos.RoleRepo, recorder), TransferOwnershipUseCase: useCases.NewTransferOwnershipUseCase(repos.UserRepo, repos.RoleRepo, recorder),
GetInviteUseCase: useCases.NewGetInviteUseCase(repos.InviteRepo, hasher), GetInviteUseCase: useCases.NewGetInviteUseCase(repos.InviteRepo, hasher),
AcceptInviteUseCase: useCases.NewAcceptInviteUseCase(repos.UserRepo, repos.InviteRepo, repos.RoleRepo, hasher, recorder), AcceptInviteUseCase: useCases.NewAcceptInviteUseCase(repos.UserRepo, repos.InviteRepo, repos.RoleRepo, hasher, recorder),
@@ -433,7 +452,7 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings
RenameMediaUseCase: useCases.NewRenameMediaUseCase(repos.ImageRepo, recorder), RenameMediaUseCase: useCases.NewRenameMediaUseCase(repos.ImageRepo, recorder),
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(repos.ImageRepo, imageStorage, repos.VariantFileRepo, recorder, repos.SiteRepo, repos.AuthorRepo, repos.ArticleRepo), DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(repos.ImageRepo, imageStorage, repos.VariantFileRepo, recorder, repos.SiteRepo, repos.AuthorRepo, repos.ArticleRepo),
MergeMediaUseCase: mergeMedia, MergeMediaUseCase: mergeMedia,
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(repos.ImageRepo, repos.ArticleRepo, repos.SiteRepo, repos.AuthorRepo, repos.ContentTypeRepo, repos.EntryRepo), GetMediaUsageUseCase: mediaUsage,
GetPurgeSettingsUseCase: useCases.NewGetPurgeSettingsUseCase(repos.PurgeSettingsRepo), GetPurgeSettingsUseCase: useCases.NewGetPurgeSettingsUseCase(repos.PurgeSettingsRepo),
UpdatePurgeSettingsUseCase: useCases.NewUpdatePurgeSettingsUseCase(repos.PurgeSettingsRepo, time.Now), UpdatePurgeSettingsUseCase: useCases.NewUpdatePurgeSettingsUseCase(repos.PurgeSettingsRepo, time.Now),
PreviewPurgeUseCase: useCases.NewPreviewPurgeUseCase(repos.ImageRepo, repos.ArticleRepo, repos.SiteRepo, repos.AuthorRepo, repos.ContentTypeRepo, repos.EntryRepo), PreviewPurgeUseCase: useCases.NewPreviewPurgeUseCase(repos.ImageRepo, repos.ArticleRepo, repos.SiteRepo, repos.AuthorRepo, repos.ContentTypeRepo, repos.EntryRepo),
+151
View File
@@ -0,0 +1,151 @@
package auth
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/claims"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"net/http"
)
type EmailChangeHandler struct {
RequestUseCase *useCases.RequestEmailChangeUseCase
ResendUseCase *useCases.ResendEmailChangeUseCase
CancelUseCase *useCases.CancelEmailChangeUseCase
ConfirmUseCase *useCases.ConfirmEmailChangeUseCase
}
type RequestEmailChangeCredentials struct {
NewEmail string `json:"new_email" validate:"required,max=255,inviteemail"`
CurrentPassword string `json:"current_password" validate:"required"`
}
type ConfirmEmailChangeCredentials struct {
Code string `json:"code" validate:"required"`
}
func NewEmailChangeHandler(request *useCases.RequestEmailChangeUseCase, resend *useCases.ResendEmailChangeUseCase, cancel *useCases.CancelEmailChangeUseCase, confirm *useCases.ConfirmEmailChangeUseCase) *EmailChangeHandler {
return &EmailChangeHandler{
RequestUseCase: request,
ResendUseCase: resend,
CancelUseCase: cancel,
ConfirmUseCase: confirm,
}
}
func (h *EmailChangeHandler) Request(w http.ResponseWriter, r *http.Request) {
var credentials RequestEmailChangeCredentials
if err := json.NewDecoder(r.Body).Decode(&credentials); err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
if err := validator.Validate.Struct(credentials); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
userId, ok := claims.UserId(r)
if !ok {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return
}
pending, err := h.RequestUseCase.RequestEmailChange(r.Context(), useCases.RequestEmailChangeCommand{
UserID: userId,
NewEmail: credentials.NewEmail,
CurrentPassword: credentials.CurrentPassword,
})
if err != nil {
writeEmailChangeError(w, err)
return
}
writePendingEmail(w, pending)
}
func (h *EmailChangeHandler) Resend(w http.ResponseWriter, r *http.Request) {
userId, ok := claims.UserId(r)
if !ok {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return
}
pending, err := h.ResendUseCase.ResendEmailChange(r.Context(), userId)
if err != nil {
writeEmailChangeError(w, err)
return
}
writePendingEmail(w, pending)
}
func (h *EmailChangeHandler) Cancel(w http.ResponseWriter, r *http.Request) {
userId, ok := claims.UserId(r)
if !ok {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return
}
if err := h.CancelUseCase.CancelEmailChange(r.Context(), userId); err != nil {
writeEmailChangeError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *EmailChangeHandler) Confirm(w http.ResponseWriter, r *http.Request) {
var credentials ConfirmEmailChangeCredentials
if err := json.NewDecoder(r.Body).Decode(&credentials); err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
if err := validator.Validate.Struct(credentials); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
userId, ok := claims.UserId(r)
if !ok {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return
}
email, err := h.ConfirmUseCase.ConfirmEmailChange(r.Context(), userId, credentials.Code)
if err != nil {
writeEmailChangeError(w, err)
return
}
body, err := json.Marshal(map[string]string{"email": email})
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
_, _ = w.Write(body)
}
func writePendingEmail(w http.ResponseWriter, pending string) {
body, err := json.Marshal(map[string]string{"pending_email": pending})
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
_, _ = w.Write(body)
}
func writeEmailChangeError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, user.ErrUserNotFound):
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
case errors.Is(err, user.ErrWrongPassword):
http.Error(w, apperr.WrongPasswordErrorCode, http.StatusForbidden)
case errors.Is(err, user.ErrSameEmail):
http.Error(w, apperr.SameEmailErrorCode, http.StatusBadRequest)
case errors.Is(err, user.ErrNoPendingEmailChange):
http.Error(w, apperr.NoPendingEmailChangeErrorCode, http.StatusNotFound)
case errors.Is(err, user.ErrInvalidEmailChangeCode):
http.Error(w, apperr.InvalidEmailChangeCodeErrorCode, http.StatusForbidden)
case errors.Is(err, user.ErrEmailUnavailable):
http.Error(w, apperr.EmailUnavailableErrorCode, http.StatusConflict)
default:
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
+1 -10
View File
@@ -40,16 +40,7 @@ func NewLoginHandler(token *authtoken.Auth, create *useCases.CreateUserUseCase,
} }
func removeJwtCookie(w http.ResponseWriter, isProduction bool) { func removeJwtCookie(w http.ResponseWriter, isProduction bool) {
http.SetCookie(w, &http.Cookie{ http.SetCookie(w, authtoken.ExpiredCookie(isProduction))
Name: authtoken.CookieName,
Value: "",
Expires: time.Now(),
MaxAge: -1,
Secure: isProduction,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Path: "/",
})
} }
func setJwtCookie(w http.ResponseWriter, token *authtoken.Auth, isProduction bool, u user.User) error { func setJwtCookie(w http.ResponseWriter, token *authtoken.Auth, isProduction bool, u user.User) error {
+11 -3
View File
@@ -41,8 +41,9 @@ type meAccess struct {
type meResponse struct { type meResponse struct {
publicuser.PublicUser publicuser.PublicUser
Author *meAuthor `json:"author"` PendingEmail *string `json:"pending_email"`
Access meAccess `json:"access"` Author *meAuthor `json:"author"`
Access meAccess `json:"access"`
} }
func toMeAccess(access useCases.Access) meAccess { func toMeAccess(access useCases.Access) meAccess {
@@ -60,6 +61,13 @@ func toMeAuthor(found *author.Author) *meAuthor {
return &meAuthor{Byline: found.Byline(), ArticleCount: found.ArticleCount} return &meAuthor{Byline: found.Byline(), ArticleCount: found.ArticleCount}
} }
func pendingEmailOf(account user.User) *string {
if account.PendingEmail == "" {
return nil
}
return &account.PendingEmail
}
func (h *MeHandler) Me(w http.ResponseWriter, r *http.Request) { func (h *MeHandler) Me(w http.ResponseWriter, r *http.Request) {
userId, ok := claims.UserId(r) userId, ok := claims.UserId(r)
if !ok { if !ok {
@@ -89,7 +97,7 @@ func (h *MeHandler) Me(w http.ResponseWriter, r *http.Request) {
return return
} }
body, err := json.Marshal(meResponse{PublicUser: publicuser.New(loadedUser), Author: toMeAuthor(linked), Access: toMeAccess(access)}) body, err := json.Marshal(meResponse{PublicUser: publicuser.New(loadedUser), PendingEmail: pendingEmailOf(loadedUser), Author: toMeAuthor(linked), Access: toMeAccess(access)})
if err != nil { if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "Internal server error", http.StatusInternalServerError)
return return
+81
View File
@@ -0,0 +1,81 @@
package user
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/authtoken"
"RenewCMS/api/pkg/claims"
validator "RenewCMS/api/pkg/validator"
domain "RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"net/http"
"time"
)
type DeleteAccountHandler struct {
DeleteUseCase *useCases.DeleteOwnAccountUseCase
IsProduction bool
}
type DeleteAccountRequest struct {
CurrentPassword string `json:"current_password" validate:"required"`
Confirmation string `json:"confirmation" validate:"required,eq=DELETE"`
}
func NewDeleteAccountHandler(deleteAccount *useCases.DeleteOwnAccountUseCase, isProduction bool) *DeleteAccountHandler {
return &DeleteAccountHandler{DeleteUseCase: deleteAccount, IsProduction: isProduction}
}
func (h *DeleteAccountHandler) Delete(w http.ResponseWriter, r *http.Request) {
var request DeleteAccountRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
if err := validator.Validate.Struct(request); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
userId, ok := claims.UserId(r)
if !ok {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return
}
command := useCases.DeleteOwnAccountCommand{UserID: userId, CurrentPassword: request.CurrentPassword}
command.SessionID, command.SessionExpiresAt = sessionOf(r)
if err := h.DeleteUseCase.Delete(r.Context(), command); err != nil {
writeDeleteAccountError(w, err)
return
}
http.SetCookie(w, authtoken.ExpiredCookie(h.IsProduction))
w.WriteHeader(http.StatusNoContent)
}
func sessionOf(r *http.Request) (string, time.Time) {
tokenClaims, err := authtoken.FromContext(r.Context())
if err != nil {
return "", time.Time{}
}
id, _ := tokenClaims["jti"].(string)
expiry, _ := tokenClaims["exp"].(float64)
return id, time.Unix(int64(expiry), 0)
}
func writeDeleteAccountError(w http.ResponseWriter, err error) {
var lastAdmin domain.LastAdminError
switch {
case errors.Is(err, domain.ErrUserNotFound):
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
case errors.Is(err, domain.ErrWrongPassword):
http.Error(w, apperr.WrongPasswordErrorCode, http.StatusForbidden)
case errors.As(err, &lastAdmin):
w.Header().Set(apperr.LastRoleHeader, lastAdmin.Role)
http.Error(w, apperr.LastAdminErrorCode, http.StatusConflict)
case errors.Is(err, domain.ErrAccountNotDeletable):
http.Error(w, apperr.AccountNotDeletableErrorCode, http.StatusConflict)
default:
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
+44
View File
@@ -0,0 +1,44 @@
package user
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/claims"
"RenewCMS/internal/infrastructure/useCases"
"context"
"errors"
"net/http"
"strconv"
)
type ExportHandler struct {
ExportUseCase *useCases.ExportUserDataUseCase
}
func NewExportHandler(export *useCases.ExportUserDataUseCase) *ExportHandler {
return &ExportHandler{ExportUseCase: export}
}
func (h *ExportHandler) Export(w http.ResponseWriter, r *http.Request) {
userId, ok := claims.UserId(r)
if !ok {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return
}
archive, err := h.ExportUseCase.Export(r.Context(), userId)
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
http.Error(w, "Service unavailable", http.StatusServiceUnavailable)
return
}
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Disposition", "attachment; filename=\""+archive.Filename+"\"")
w.Header().Set("Content-Length", strconv.Itoa(len(archive.Content)))
w.WriteHeader(http.StatusOK)
_, _ = w.Write(archive.Content)
}
+90
View File
@@ -0,0 +1,90 @@
package middleware
import (
"RenewCMS/api/pkg/apperr"
"net/http"
"strconv"
"sync"
"time"
)
type successSlots struct {
mu sync.Mutex
window time.Duration
now func() time.Time
taken map[string]time.Time
}
func NewUserSuccessLimiter(window time.Duration) func(http.Handler) http.Handler {
return NewUserSuccessLimiterAt(window, time.Now)
}
func NewUserSuccessLimiterAt(window time.Duration, now func() time.Time) func(http.Handler) http.Handler {
slots := &successSlots{window: window, now: now, taken: make(map[string]time.Time)}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
key := userRateLimitKey(r)
claimed, retryAfter, granted := slots.claim(key)
if !granted {
w.Header().Set("Retry-After", strconv.Itoa(retryAfter))
http.Error(w, apperr.TooManyRequestsErrorMessage, http.StatusTooManyRequests)
return
}
recorder := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
succeeded := false
defer func() {
if !succeeded {
slots.release(key, claimed)
}
}()
next.ServeHTTP(recorder, r)
succeeded = recorder.status < http.StatusBadRequest
})
}
}
func (s *successSlots) claim(key string) (time.Time, int, bool) {
s.mu.Lock()
defer s.mu.Unlock()
now := s.now()
s.sweepExpired(now)
if claimedAt, held := s.taken[key]; held {
return time.Time{}, retryAfterSeconds(s.window - now.Sub(claimedAt)), false
}
s.taken[key] = now
return now, 0, true
}
func (s *successSlots) release(key string, claimedAt time.Time) {
s.mu.Lock()
defer s.mu.Unlock()
if current, held := s.taken[key]; held && current.Equal(claimedAt) {
delete(s.taken, key)
}
}
func (s *successSlots) sweepExpired(now time.Time) {
for key, claimedAt := range s.taken {
if now.Sub(claimedAt) >= s.window {
delete(s.taken, key)
}
}
}
type statusRecorder struct {
http.ResponseWriter
status int
}
func (s *statusRecorder) WriteHeader(status int) {
s.status = status
s.ResponseWriter.WriteHeader(status)
}
func (s *statusRecorder) Unwrap() http.ResponseWriter {
return s.ResponseWriter
}
+9
View File
@@ -127,3 +127,12 @@ const InvalidRoleErrorMessage = "invalid_role"
const UserRoleChangedErrorMessage = "user_role_changed" const UserRoleChangedErrorMessage = "user_role_changed"
const InvalidTransferErrorMessage = "invalid_transfer" const InvalidTransferErrorMessage = "invalid_transfer"
const ConfirmationMismatchErrorMessage = "confirmation_mismatch" const ConfirmationMismatchErrorMessage = "confirmation_mismatch"
const WrongPasswordErrorCode = "wrong_password"
const SameEmailErrorCode = "same_email"
const NoPendingEmailChangeErrorCode = "no_pending_email_change"
const InvalidEmailChangeCodeErrorCode = "invalid_email_change_code"
const EmailUnavailableErrorCode = "email_unavailable"
const LastAdminErrorCode = "last_admin"
const AccountNotDeletableErrorCode = "account_not_deletable"
const LastRoleHeader = "X-Last-Role"
+19
View File
@@ -0,0 +1,19 @@
package authtoken
import (
"net/http"
"time"
)
func ExpiredCookie(secure bool) *http.Cookie {
return &http.Cookie{
Name: CookieName,
Value: "",
Expires: time.Now(),
MaxAge: -1,
Secure: secure,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Path: "/",
}
}
+50 -2
View File
@@ -52,6 +52,14 @@ func GetHelloWorld(w http.ResponseWriter, _ *http.Request) {
const ( const (
apiKeyMaxRequests = 120 apiKeyMaxRequests = 120
apiKeyWindow = time.Minute apiKeyWindow = time.Minute
emailChangeMaxRequests = 5
emailChangeWindow = 15 * time.Minute
dataExportWindow = 15 * time.Minute
accountDeleteMaxRequests = 5
accountDeleteWindow = 15 * time.Minute
) )
func (c *UseCases) apiKeyAuth() func(http.Handler) http.Handler { func (c *UseCases) apiKeyAuth() func(http.Handler) http.Handler {
@@ -119,6 +127,7 @@ func NewAuthRouter(container *UseCases, isProduction bool, rateLimit config.Auth
verificationHandler := auth.NewVerificationHandler(container.VerifyUserUseCase, container.CancelRegistrationUseCase, isProduction) verificationHandler := auth.NewVerificationHandler(container.VerifyUserUseCase, container.CancelRegistrationUseCase, isProduction)
meHandler := auth.NewMeHandler(container.GetUserUseCase, container.GetMyAuthorUseCase, container.GetMyAccessUseCase) meHandler := auth.NewMeHandler(container.GetUserUseCase, container.GetMyAuthorUseCase, container.GetMyAccessUseCase)
passwordResetHandler := auth.NewPasswordResetHandler(container.RequestPasswordResetUseCase, container.ResetPasswordUseCase) passwordResetHandler := auth.NewPasswordResetHandler(container.RequestPasswordResetUseCase, container.ResetPasswordUseCase)
emailChangeHandler := auth.NewEmailChangeHandler(container.RequestEmailChangeUseCase, container.ResendEmailChangeUseCase, container.CancelEmailChangeUseCase, container.ConfirmEmailChangeUseCase)
acceptInviteHandler := auth.NewAcceptInviteHandler(container.GetInviteUseCase, container.AcceptInviteUseCase, container.Token, isProduction) acceptInviteHandler := auth.NewAcceptInviteHandler(container.GetInviteUseCase, container.AcceptInviteUseCase, container.Token, isProduction)
r.Post("/register", loginHandler.Register) r.Post("/register", loginHandler.Register)
@@ -149,6 +158,22 @@ func NewAuthRouter(container *UseCases, isProduction bool, rateLimit config.Auth
r.Post("/verify", verificationHandler.Verify) r.Post("/verify", verificationHandler.Verify)
r.Delete("/register", verificationHandler.CancelRegistration) r.Delete("/register", verificationHandler.CancelRegistration)
}) })
r.Group(func(r chi.Router) {
r.Use(middleware.RequireSession)
r.Use(authtoken.Verifier(container.Token))
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
r.Use(middleware.RequireSignedIn)
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Delete("/email/change", emailChangeHandler.Cancel)
r.Post("/email/confirm", emailChangeHandler.Confirm)
r.Group(func(r chi.Router) {
r.Use(middleware.NewUserRateLimiter(emailChangeMaxRequests, emailChangeWindow))
r.Post("/email/change", emailChangeHandler.Request)
r.Post("/email/resend", emailChangeHandler.Resend)
})
})
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(authtoken.Verifier(container.Token)) r.Use(authtoken.Verifier(container.Token))
r.Use(container.apiKeyAuth()) r.Use(container.apiKeyAuth())
@@ -159,7 +184,7 @@ func NewAuthRouter(container *UseCases, isProduction bool, rateLimit config.Auth
return r return r
} }
func NewUserRouter(container *UseCases, rateLimit config.AuthRateLimitSettings, trustedProxies []string) http.Handler { func NewUserRouter(container *UseCases, isProduction bool, rateLimit config.AuthRateLimitSettings, trustedProxies []string) http.Handler {
r := chi.NewRouter() r := chi.NewRouter()
userHandler := user.NewHandler(container.UpdateUserProfileUseCase) userHandler := user.NewHandler(container.UpdateUserProfileUseCase)
@@ -167,6 +192,8 @@ func NewUserRouter(container *UseCases, rateLimit config.AuthRateLimitSettings,
inviteHandler := user.NewInviteHandler(container.CreateInviteUseCase, container.CancelInviteUseCase, container.RenewInviteUseCase, container.ListUsersAndInvitesUseCase) inviteHandler := user.NewInviteHandler(container.CreateInviteUseCase, container.CancelInviteUseCase, container.RenewInviteUseCase, container.ListUsersAndInvitesUseCase)
roleHandler := user.NewRoleHandler(container.ChangeUserRoleUseCase) roleHandler := user.NewRoleHandler(container.ChangeUserRoleUseCase)
removeHandler := user.NewRemoveHandler(container.RemoveUserUseCase) removeHandler := user.NewRemoveHandler(container.RemoveUserUseCase)
exportHandler := user.NewExportHandler(container.ExportUserDataUseCase)
deleteAccountHandler := user.NewDeleteAccountHandler(container.DeleteOwnAccountUseCase, isProduction)
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(authtoken.Verifier(container.Token)) r.Use(authtoken.Verifier(container.Token))
@@ -187,6 +214,27 @@ func NewUserRouter(container *UseCases, rateLimit config.AuthRateLimitSettings,
r.Post("/invite/{id}/renew", inviteHandler.RenewInvite) r.Post("/invite/{id}/renew", inviteHandler.RenewInvite)
}) })
}) })
r.Group(func(r chi.Router) {
r.Use(middleware.RequireSession)
r.Use(authtoken.Verifier(container.Token))
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
r.Use(middleware.RequireSignedIn)
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.With(middleware.NewUserSuccessLimiter(dataExportWindow)).Get("/me/export", exportHandler.Export)
})
r.Group(func(r chi.Router) {
r.Use(middleware.RequireSession)
r.Use(authtoken.Verifier(container.Token))
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
r.Use(middleware.RequireSignedIn)
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Use(middleware.NewUserRateLimiter(accountDeleteMaxRequests, accountDeleteWindow))
r.Delete("/me", deleteAccountHandler.Delete)
})
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(middleware.RequireSession) r.Use(middleware.RequireSession)
r.Use(authtoken.Verifier(container.Token)) r.Use(authtoken.Verifier(container.Token))
@@ -535,7 +583,7 @@ func InitBackendRoutes(container *UseCases, settings config.Settings) *chi.Mux {
r.Use(authtoken.Verifier(container.Token)) r.Use(authtoken.Verifier(container.Token))
r.Mount("/article", NewArticleRouter(container, settings.TrustedProxies)) r.Mount("/article", NewArticleRouter(container, settings.TrustedProxies))
}) })
r.Mount("/user", NewUserRouter(container, settings.AuthRateLimit, settings.TrustedProxies)) r.Mount("/user", NewUserRouter(container, settings.IsProduction(), settings.AuthRateLimit, settings.TrustedProxies))
r.Mount("/settings", NewSettingsRouter(container, settings.TrustedProxies)) r.Mount("/settings", NewSettingsRouter(container, settings.TrustedProxies))
r.Mount("/site", NewSiteRouter(container, settings.TrustedProxies)) r.Mount("/site", NewSiteRouter(container, settings.TrustedProxies))
r.Mount("/role", NewRoleRouter(container, settings.TrustedProxies)) r.Mount("/role", NewRoleRouter(container, settings.TrustedProxies))
+92 -3
View File
@@ -33,7 +33,7 @@ for example `https://cms.example/v1/article/`.
| --- | --- | | --- | --- |
| Articles | GET /v1/article/, GET /v1/article/{id}, GET /v1/article/{id}/seo, POST /v1/article/{id}/seo/check, POST /v1/article/, PATCH /v1/article/{id}, PATCH /v1/article/{id}/publish, PATCH /v1/article/{id}/unpublish, DELETE /v1/article/{id} | | Articles | GET /v1/article/, GET /v1/article/{id}, GET /v1/article/{id}/seo, POST /v1/article/{id}/seo/check, POST /v1/article/, PATCH /v1/article/{id}, PATCH /v1/article/{id}/publish, PATCH /v1/article/{id}/unpublish, DELETE /v1/article/{id} |
| Media | GET /v1/media/, GET /v1/media/{id}, POST /v1/media/, PATCH /v1/media/{id}, DELETE /v1/media/{id}, POST /v1/media/merge, GET /v1/media/{id}/usage, GET /v1/media/purge-settings (owner or admin), PATCH /v1/media/purge-settings (owner or admin), GET /v1/media/purge-preview (owner or admin), POST /v1/media/purge (owner or admin), GET /v1/media/{id}/variants (owner or admin, session only), POST /v1/media/{id}/variants (owner or admin, session only), POST /v1/media/{id}/variants/{name} (owner or admin, session only), DELETE /v1/media/{id}/variants/{name} (owner or admin, session only) | | Media | GET /v1/media/, GET /v1/media/{id}, POST /v1/media/, PATCH /v1/media/{id}, DELETE /v1/media/{id}, POST /v1/media/merge, GET /v1/media/{id}/usage, GET /v1/media/purge-settings (owner or admin), PATCH /v1/media/purge-settings (owner or admin), GET /v1/media/purge-preview (owner or admin), POST /v1/media/purge (owner or admin), GET /v1/media/{id}/variants (owner or admin, session only), POST /v1/media/{id}/variants (owner or admin, session only), POST /v1/media/{id}/variants/{name} (owner or admin, session only), DELETE /v1/media/{id}/variants/{name} (owner or admin, session only) |
| Users and invites | GET /v1/user/ (`users.view`), PATCH /v1/user/me, POST /v1/user/invite (`users.invite`), DELETE /v1/user/invite/{id} (`users.invite`), POST /v1/user/invite/{id}/renew (`users.invite`), GET /v1/auth/me (with the linked `author`) | | Users and invites | GET /v1/user/ (`users.view`), PATCH /v1/user/me, GET /v1/user/me/export, DELETE /v1/user/me, POST /v1/user/invite (`users.invite`), DELETE /v1/user/invite/{id} (`users.invite`), POST /v1/user/invite/{id}/renew (`users.invite`), GET /v1/auth/me (with the linked `author`) |
| Settings | GET /v1/settings | | Settings | GET /v1/settings |
| Sites | GET /v1/site/, GET /v1/site/{key}/sitemap | | Sites | GET /v1/site/, GET /v1/site/{key}/sitemap |
| Authors | GET /v1/author/ | | Authors | GET /v1/author/ |
@@ -74,7 +74,7 @@ never changes the response or the change itself.
| --- | --- | | --- | --- |
| `articles` | `article.create`, `article.edit`, `article.publish`, `article.unpublish`, `article.delete` | | `articles` | `article.create`, `article.edit`, `article.publish`, `article.unpublish`, `article.delete` |
| `media` | `media.upload`, `media.rename`, `media.delete`, `media.merge`, `media.purge`, `media.variant_create`, `media.variant_update`, `media.variant_delete`, `media.variant_enable`, `media.variant_disable`, `media.variant_purge`, `media.variant_generate_all`, `media.variant_purge_image`, `media.variant_purge_all`, `media.variant_purge_unused` | | `media` | `media.upload`, `media.rename`, `media.delete`, `media.merge`, `media.purge`, `media.variant_create`, `media.variant_update`, `media.variant_delete`, `media.variant_enable`, `media.variant_disable`, `media.variant_purge`, `media.variant_generate_all`, `media.variant_purge_image`, `media.variant_purge_all`, `media.variant_purge_unused` |
| `users` | `user.register`, `user.verify`, `user.update_profile`, `user.change_role`, `user.remove`, `user.transfer_ownership` (flagged `security`), `invite.create`, `invite.cancel`, `invite.renew`, `invite.accept` | | `users` | `user.register`, `user.verify`, `user.update_profile`, `user.request_email_change`, `user.resend_email_change`, `user.cancel_email_change`, `user.confirm_email_change`, `user.export_data`, `user.delete_account` (flagged `security`), `user.change_role`, `user.remove`, `user.transfer_ownership` (flagged `security`), `invite.create`, `invite.cancel`, `invite.renew`, `invite.accept` |
| `sites` | `site.create`, `site.edit`, `site.set_default`, `site.delete` | | `sites` | `site.create`, `site.edit`, `site.set_default`, `site.delete` |
| `authors` | `author.create`, `author.edit`, `author.delete` | | `authors` | `author.create`, `author.edit`, `author.delete` |
| `system` | `system.media_purge` (scheduled), `system.update_checked`, `system.update_downloaded`, `system.restart_requested`, `system.activity_cleanup`, `system.activity_retention_changed` | | `system` | `system.media_purge` (scheduled), `system.update_checked`, `system.update_downloaded`, `system.restart_requested`, `system.activity_cleanup`, `system.activity_retention_changed` |
@@ -406,6 +406,95 @@ Activity: `role.create`, `role.edit`, `role.delete` and `role.reassign` in the `
Deleting a content type removes its per-type rows from every role (the affected roles' `version` goes up by one), and Deleting a content type removes its per-type rows from every role (the affected roles' `version` goes up by one), and
creating one with the key of a deleted type clears any stale row for that key. creating one with the key of a deleted type clears any stale row for that key.
## Changing the email address
Four session-only routes under `/v1/auth` let a verified user change their own email address. A missing or invalid
cookie is 401, an API key is 403 `This action needs a real session.` and an unverified user is 403
`You must verify your account to access this resource.`
| Route | Body | Success |
| --- | --- | --- |
| `POST /v1/auth/email/change` | `{ "new_email": "...", "current_password": "..." }` | 200 `{ "pending_email": "<new>" }` |
| `POST /v1/auth/email/resend` | none | 200 `{ "pending_email": "<new>" }` |
| `DELETE /v1/auth/email/change` | none | 204, no body |
| `POST /v1/auth/email/confirm` | `{ "code": "<raw code>" }` | 200 `{ "email": "<new>" }` |
`users.email` does not change until the link is confirmed, so the old address keeps receiving password-reset links and
the password and username keep working. A request stores `pending_email`, a bcrypt hash of a random code and an
expiration two hours ahead, then mails the raw code as a link to the new address (`verify-new-email`) and a notice to
the old one (`email-change-notice`). A new request or a resend replaces the previous code; cancelling clears it. The
link works once.
Errors are plain text and the code is the whole body: 400 `same_email` (the new address equals the current one,
whatever its case), 403 `wrong_password`, 404 `no_pending_email_change` (resend only), 403
`invalid_email_change_code` (nothing pending, wrong, expired, cancelled or issued to another user) and 409
`email_unavailable` (the address belongs to another user at confirmation time; the pending change is cleared). A body
that is not JSON is 400 with the body-mismatch message, and a failed validation is 400 with the validator text
(`new_email` must be a full address, `current_password` and `code` are required).
A request for an address that another user already holds answers exactly like a success, stores the pending change and
still sends the notice to the old address, but sends no verification mail, so the response never reveals that an account
exists. A failed send is logged without the code and does not change the response. `POST /v1/auth/email/change` and
`POST /v1/auth/email/resend` share one limit of 5 attempts per user per 15 minutes; the sixth is 429 with a
`Retry-After` header. Cancel and confirm are not limited.
Audit actions (category `users`, source `web`): `user.request_email_change`, `user.resend_email_change` and
`user.confirm_email_change` are flagged `security`; `user.cancel_email_change` is not, and cancelling with nothing
pending records nothing. A refused request, resend or confirm records a failed event of the same action, flagged `security`, with a specific reason. No email address is written into an event, and a wrong password records a failed
`user.request_email_change` with the reason `Wrong password.`
## Exporting your own data
`GET /v1/user/me/export` answers a ZIP of the caller's personal data. It needs a real, verified session: no cookie is 401,
an API key is 403 "This action needs a real session." and an unverified account is 403. There is no body and no permission
gate, since it only reads the caller's own data. The success answer is 200 with `Content-Type: application/zip`,
`Cache-Control: no-store` and `Content-Disposition: attachment; filename="renewcms-export-<username>-<YYYY-MM-DD>.zip"`;
the username is lowercased and reduced to `a-z`, `0-9` and `-`, and the date is the UTC day of the export. The archive is
built in memory first, so a failure is a plain text 500 and never a half file.
| File | Content |
| --- | --- |
| `profile.json` | `id`, `username`, `display_name`, `email`, `pending_email`, `role {id, name}`, `is_verified`, `created_at`, `updated_at`, `author` (the linked author profile or `null`), `api_keys [{id, name, prefix, created_at, last_used_at, revoked_at}]` |
| `authored-content.json` | `articles [{id, title, body, slug, site_id, is_online, created_at, updated_at, authors}]` created by the caller (drafts included) or published and crediting their author profile, with `authors [{id, name, slug}]`, and `entries [{id, content_type, data, created_at, updated_at}]` created by the caller; soft-deleted articles are left out |
| `uploaded-media.json` | `images [{id, filename, mime, size, hash, uploaded_at}]`: references only, no file bytes; soft-deleted images are left out |
| `account-activity.json` | `events [{id, created_at, action, category, target, outcome, security, source, ip, user_agent, params}]` where the caller is the actor, without events whose target is another user, an invite or another person's author profile |
The archive never holds the password hash, a verification, reset or email-change code, the token version or an API-key
hash. One export succeeds per user every 15 minutes; a second attempt is 429 "Too many requests. Please try again later."
with a `Retry-After` header in seconds. An export that fails with a 500 does not use the slot. A successful export records
one `user.export_data` audit event (category `users`, not flagged `security`).
The archive is built in memory. At most 2 exports are built at the same time across all users (the constant
`maxConcurrentDataExports` in `api/dependecyInjection.go`); another request waits for a free slot. If the request is
cancelled or times out while waiting, the answer is 503 "Service unavailable" and the user's 15-minute slot is not used.
## Deleting your own account
`DELETE /v1/user/me` deletes the caller's account and personal data. It needs a real, verified session: no cookie is 401,
an API key is 403 "This action needs a real session." and an unverified account is 403. It is not `DELETE /v1/user/{id}`,
which stays the admin action and is unchanged.
Body: `{"current_password": "...", "confirmation": "DELETE"}`. The password is required, and `confirmation` must be exactly
`DELETE` (case-sensitive, no spaces). The checks run in this order:
| Answer | Body | When |
| --- | --- | --- |
| 400 | the validator message, or "The request cannot be processed due to a mismatch in the format of the body." | the body is not JSON, the password is empty or `confirmation` is not `DELETE` |
| 403 | `wrong_password` | the password is not the account's |
| 409 | `last_admin`, with the header `X-Last-Role: Owner` or `X-Last-Role: Admin` | the caller holds the built-in Owner role and no other verified user does, or the built-in Admin role and no other verified user holds Owner or Admin. Other roles, custom ones included, are never blocked. |
| 409 | `account_not_deletable` | the holders of the role changed between the check and the removal; nothing was removed |
| 429 | "Too many requests. Please try again later." with `Retry-After` | more than 5 attempts per user in 15 minutes, counted before any check |
| 500 | "Internal server error" | the removal failed; everything is rolled back |
| 204 | none, and a `Set-Cookie` that expires `jwt` | the account is deleted |
The removal is one atomic operation: the user row, all the user's API keys, the linked author (deleted when no article
credits it, otherwise kept as "Deleted user" with the slug `deleted-user-<author id>`, no bio, avatar, website or links and
no user; the avatar image is deleted too, with its stored file after the commit, unless something else uses it), and the user's past activity events (actor and target names become "Deleted user", the IP address, user agent, `params` and `changes` are emptied, and author events on the user's own profile lose the profile name; the events stay). Articles, entries and images keep the old user id. The current session is revoked, and
one `account-deleted` mail (the site address only, no personal data) is sent to the address the account had; a failed send
is logged and does not change the 204. A successful deletion records one `user.delete_account` event (category `users`,
flagged `security`, actor and target named "Deleted user", no email in the params); a refused attempt records a failed
`user.delete_account` event with the reason `Wrong password.`, `Last Owner.` or `Last administrator.`.
## Users, invites and ownership ## Users, invites and ownership
A user has exactly one stored role, `role_id`, and nothing else: there is no `role` string any more (the A user has exactly one stored role, `role_id`, and nothing else: there is no `role` string any more (the
@@ -413,7 +502,7 @@ A user has exactly one stored role, `role_id`, and nothing else: there is no `ro
`{ id, name, built_in, locked }`. It appears as `access.role` in `GET /v1/auth/me`, as `role` on user and invite items `{ id, name, built_in, locked }`. It appears as `access.role` in `GET /v1/auth/me`, as `role` on user and invite items
(`null` for a user whose role cannot be resolved) and in the responses below. `PublicUser` is exactly `id`, `username`, (`null` for a user whose role cannot be resolved) and in the responses below. `PublicUser` is exactly `id`, `username`,
`email`, `is_verified`, `created_at`, `updated_at` and `display_name` in every response that includes a user (login, `email`, `is_verified`, `created_at`, `updated_at` and `display_name` in every response that includes a user (login,
verify, register, invite accept, `GET /v1/auth/me`); `GET /v1/auth/me` adds `author` and `access`. verify, register, invite accept, `GET /v1/auth/me`); `GET /v1/auth/me` adds `pending_email` (string or `null`), `author` and `access`.
`role_assigned_by` is `{ id, name }` or `null`: who last set the user's role. It is stored as the nullable column `role_assigned_by` is `{ id, name }` or `null`: who last set the user's role. It is stored as the nullable column
`role_assigned_by_id` on the user (no foreign key, no backfill, existing rows are `NULL`) and the name is never stored: it `role_assigned_by_id` on the user (no foreign key, no backfill, existing rows are `NULL`) and the name is never stored: it
+8 -8
View File
@@ -15,14 +15,15 @@ Readers of your public site are not tracked by RenewCMS: the public routes set n
| Data | Where | Kept | | Data | Where | Kept |
|---|---|---| |---|---|---|
| Username, email, display name, role, bcrypt password hash | `users` table | Until the account is deleted (see [Gaps](#gaps)). | | Username, email, display name, role, bcrypt password hash | `users` table | Until the user deletes the account (see Erasure under [Answering requests from the people concerned](#answering-requests-from-the-people-concerned)). |
| Verification and password-reset codes | `users` table, bcrypt-hashed | Verification: until the account is verified. Reset: one hour, then useless; cleared when used. | | Verification and password-reset codes | `users` table, bcrypt-hashed | Verification: until the account is verified. Reset: one hour, then useless; cleared when used. |
| Pending email change: the new address (`pending_email`), its bcrypt-hashed code (`email_change_code`) and the expiry (`email_change_expiration`) | `users` table | Two hours, then useless; cleared when confirmed, cancelled or replaced. |
| Unverified sign-ups | `users` table | Deleted automatically 7 days after their verification code expired. | | Unverified sign-ups | `users` table | Deleted automatically 7 days after their verification code expired. |
| Invites: invitee email, hashed code, inviter | `invites` table | Deleted automatically 30 days after they expired or were accepted. | | Invites: invitee email, hashed code, inviter | `invites` table | Deleted automatically 30 days after they expired or were accepted. |
| Sessions | The `jwt` cookie in the user's browser, valid 24 hours | Logging out revokes the session on the server; the revocation (a random id and an expiry, nothing else) is deleted once the session would have expired anyway. | | Sessions | The `jwt` cookie in the user's browser, valid 24 hours | Logging out revokes the session on the server; the revocation (a random id and an expiry, nothing else) is deleted once the session would have expired anyway. |
| API keys: name, prefix, SHA-256 hash, creator, last use | `api_keys` table | Until revoked. A revoked key stays listed. | | API keys: name, prefix, SHA-256 hash, creator, last use | `api_keys` table | Until revoked. A revoked key stays listed. |
| Activity log: who (user id and name, API key name, or the username typed at a failed login), what, when, IP address, browser (user agent, truncated to 255 characters) | `audit_events` table | Settings → Activity: 30, 90 (default), 365 days or forever, and an optional size cap. Cleaned up once a night, at 03:00 server time. | | Activity log: who (user id and name, API key name, or the username typed at a failed login), what, when, IP address, browser (user agent, truncated to 255 characters) | `audit_events` table | Settings → Activity: 30, 90 (default), 365 days or forever, and an optional size cap. Cleaned up once a night, at 03:00 server time. |
| Authors: name, slug, bio, avatar, website, social links; the linked user's email is shown to admins | `authors` table | Until deleted. | | Authors: name, slug, bio, avatar, website, social links; the linked user's email is shown to admins | `authors` table | Until deleted. When the linked user deletes their account, the author is deleted if no article credits it, otherwise it is kept as "Deleted user" with no bio, avatar, website or links. |
| Articles and custom entries | `articles`, `entries` tables | Deleted articles are soft-deleted: the row and its content stay in the database (see [Gaps](#gaps)). | | Articles and custom entries | `articles`, `entries` tables | Deleted articles are soft-deleted: the row and its content stay in the database (see [Gaps](#gaps)). |
| Uploaded images | `UPLOAD_DIR` or the S3 bucket, plus the `images` table (file name, size, uploader) | Until deleted from the media library. Deleted images are soft-deleted in the table; the file itself is removed. | | Uploaded images | `UPLOAD_DIR` or the S3 bucket, plus the `images` table (file name, size, uploader) | Until deleted from the media library. Deleted images are soft-deleted in the table; the file itself is removed. |
@@ -45,7 +46,7 @@ RenewCMS sets no analytics, advertising or third-party cookie, so no consent ban
| Destination | When | What | | Destination | When | What |
|---|---|---| |---|---|---|
| Your SMTP provider (`SMTP_HOST`) | Sign-up, invite, password reset | Recipient address, the message (with a one-time link). STARTTLS or implicit TLS is required for any non-loopback host unless you set `SMTP_ALLOW_INSECURE=true`. | | Your SMTP provider (`SMTP_HOST`) | Sign-up, invite, password reset, email change, account deleted | Recipient address, the message (with a one-time link, except the account-deleted notice, which holds the site address only). STARTTLS or implicit TLS is required for any non-loopback host unless you set `SMTP_ALLOW_INSECURE=true`. |
| Your S3 provider, if `STORAGE_DRIVER=s3` | Every upload and image read | The image files. | | Your S3 provider, if `STORAGE_DRIVER=s3` | Every upload and image read | The image files. |
| `UPDATE_FEED_URL` (default `https://git.floriansylvain.fr`) | Only when the owner clicks "Check for updates" or downloads one | The server's IP address and a standard HTTP request. No account or content data. | | `UPDATE_FEED_URL` (default `https://git.floriansylvain.fr`) | Only when the owner clicks "Check for updates" or downloads one | The server's IP address and a standard HTTP request. No account or content data. |
@@ -62,16 +63,15 @@ The back office loads nothing from third parties: fonts, icons and scripts are s
## Answering requests from the people concerned ## Answering requests from the people concerned
- **Access and portability (art. 15 and 20)**: there is no self-service export yet. The data of a user is their row in `users`, their author profile, their API keys and the activity events where they are the actor; an owner can export the activity log as CSV from the Activity page and filter it by person. - **Access and portability (art. 15 and 20)**: every verified user downloads a copy of their data from the Your data card on Profile (`GET /v1/user/me/export`). It is a ZIP of four pretty-printed JSON files: `profile.json` (account, role, author profile, API-key metadata without the key or its hash), `authored-content.json` (every article they created, drafts included, the published articles crediting their author profile, and entries they created; credited authors appear as id, name and slug only; other people's drafts are never included), `uploaded-media.json` (references to the files they uploaded, no bytes) and `account-activity.json` (the events where they are the actor). It never holds the password hash, any code, the token version or other people's data, and it leaves out anonymous failed-login events (their actor is not an account), events whose target is another user, an invite or another person's author profile, and soft-deleted articles and images. The archive is built in memory, and at most 2 exports are built at the same time across all users; a request whose wait for a free slot is cancelled or times out answers 503 and does not use the user's slot. One export is allowed per user every 15 minutes, and each one is recorded as `user.export_data`. An owner can also export the activity log as CSV from the Activity page and filter it by person.
- **Rectification (art. 16)**: users can change their display name and author profile themselves. Changing an email address currently needs a database edit. - **Rectification (art. 16)**: users can change their display name, author profile and email address themselves. An email change needs the current password and a link sent to the new address; the old address is told about the request and keeps working until the link is opened.
- **Erasure (art. 17)**: an unverified account can cancel its own registration. Deleting a verified user has no button yet; see [Gaps](#gaps). - **Erasure (art. 17)**: every verified user deletes their own account from the Danger zone card on Profile (`DELETE /v1/user/me`), with their password and the typed word `DELETE`; an unverified account can still cancel its own registration. The last Owner, and the last administrator (built-in Admin role with no Owner), are refused until another verified user holds the role, so the workspace is never left without one. In one atomic operation, **removed**: the user row (username, display name, email, password hash, codes), every API key, every session, and the linked author profile when no article credits it. An author profile credited on articles is **scrubbed**, not deleted: it becomes "Deleted user" with the slug `deleted-user-<id>` (that prefix is reserved, so nobody can pick it), no bio, website, social links or user link. Its **avatar image** is deleted (the media row in the same operation, the stored file and its generated variants right after the commit; a storage failure is logged and does not undo the deletion) unless the image is also used by an article (body, cover or social image), another author, a site's default social image or a custom entry, in which case it stays. In the **activity log** the events where the user is the actor or the target lose their name (it becomes "Deleted user"), their IP address and browser, and their `params` and `changes` (which can hold display names, author names, bios and invitee emails); author events the user made on their own profile also lose the profile name. The events themselves (action, outcome, time) stay, and so do author events another person made on that profile, and events written by other people about the user, which keep their text but not the user's name. A mail "Your RenewCMS account has been deleted" (the site address only) is sent to the address the account had, and the deletion is recorded as a `user.delete_account` event under "Deleted user", with the IP address and browser of the request: it is the one trace of the erasure.
What remains, honestly: articles, entries and media the user created keep the old user id in `created_by` / `updated_by` / `uploaded_by_id` (shown as "Deleted user"; the id no longer points to anyone); `invites.invited_by` can point at the deleted id; anonymous failed-login events keep the username that was typed (see [Gaps](#gaps)); soft-deleted articles and images stay as rows (see [Gaps](#gaps)); and the browser of the person may still hold unsaved drafts and interface choices in `localStorage` / `sessionStorage` on their device, which RenewCMS cannot reach.
## Gaps ## Gaps
These are known and tracked; until they are fixed, handle them by hand. These are known and tracked; until they are fixed, handle them by hand.
- No way to delete a verified user or remove an admin from the interface.
- No self-service export or email change.
- Soft-deleted articles and images keep their rows indefinitely. - Soft-deleted articles and images keep their rows indefinitely.
- The username typed at a failed login is stored as typed, so a password typed in the wrong field can end up in the activity log; clean such events by hand if it happens. - The username typed at a failed login is stored as typed, so a password typed in the wrong field can end up in the activity log; clean such events by hand if it happens.
- Images uploaded before metadata stripping keep their metadata. - Images uploaded before metadata stripping keep their metadata.
+292
View File
@@ -0,0 +1,292 @@
Feature: Change my email address from Profile, verified by a link sent to the new address
As a verified RenewCMS user
I want to change my email address with my current password and a link sent to the new address
So that I keep control of my account address and nobody can take it over without my password
or without access to the new inbox
Background:
Given the design of record is docs/design/screens/account.md "Change email" and "Verification landing",
docs/design/routes.md "Account", the board "RenewCMS Screens Account" group "Change email" (14 states)
and the board "RenewCMS Screens Auth" group "Verify new email" (7 states)
And four endpoints are added under /v1/auth, each behind RequireSession, authtoken.Verifier,
AuditContext, RequireSignedIn, authtoken.Authenticator, RequireCurrentToken and RequireVerifiedUser,
so a missing or invalid jwt cookie gives 401 "You must be logged in to access this resource.",
an API key gives 403 "This action needs a real session." and an unverified user gives
403 "You must verify your account to access this resource.":
| method | path | body | success |
| POST | /v1/auth/email/change | {"new_email": "...", "current_password": "..."} | 200 {"pending_email": "<new>"} |
| POST | /v1/auth/email/resend | none | 200 {"pending_email": "<new>"} |
| DELETE | /v1/auth/email/change | none | 204, no body |
| POST | /v1/auth/email/confirm | {"code": "<raw code>"} | 200 {"email": "<new>"} |
And POST change and POST resend share one per-user limiter of 5 attempts per 15 minutes
(middleware.NewUserRateLimiter); the sixth answers 429 "Too many requests. Please try again later."
with a Retry-After header; DELETE and confirm are not limited
And the request struct uses validate tags checked with validator.Validate.Struct:
new_email "required,email" (same email rule as the invite request, extended in validator.InitValidator)
and current_password "required"; there is no hand-written validation in the handler
And error bodies are plain text from http.Error, snake_case codes are the whole body:
403 "wrong_password", 400 "same_email", 404 "no_pending_email_change", 403 "invalid_email_change_code",
409 "email_unavailable"; success bodies are JSON
And the user domain gains PendingEmail, EmailChangeCode (bcrypt hash of a random UUID, like the reset
code) and EmailChangeExpiration on user.User and the users table (portable across SQLite, MySQL and
PostgreSQL), the sentinels ErrWrongPassword, ErrSameEmail, ErrNoPendingEmailChange,
ErrInvalidEmailChangeCode and ErrEmailUnavailable, and repository methods that set, clear and
confirm a pending change; confirming is one atomic step that re-checks the address is free
And a use case (RequestEmailChange, ResendEmailChange, CancelEmailChange, ConfirmEmailChange) owns
every rule; the handler never touches a repository, GORM or the mailer
And password checking uses the domain hash.Hasher (never bcrypt in the handler)
And the link code lives 2 hours and works once; users.email is untouched until confirmation, so the
old address keeps receiving password-reset links and the password and username keep working
And GET /v1/auth/me gains "pending_email" (string or null) so the Pending state survives a reload
And three mail templates are built from docs/design/source/emails and added to
internal/infrastructure/mailer/templates:
| template | to | data | subject |
| verify-new-email | the new address | Host, Username, VerificationCode | Confirm your new RenewCMS email address |
| email-change-notice| the old address | Host, Username, NewEmail | Your RenewCMS email address change was requested |
And Host is PUBLIC_URL and Username is the account's username
And the audit actions user.request_email_change, user.resend_email_change, user.cancel_email_change and
user.confirm_email_change are added (category users, source web); request, resend and confirm are
security events; no email address is written into an event
And the layering rule holds and no comment is added anywhere
Rule: Requesting a change
Background:
Given the user "marta" (id 7, verified) has email "marta@renew.example" and the password "limewash2026"
Scenario: A valid request stores a pending change and sends two mails
When marta sends POST /v1/auth/email/change with
{"new_email": "marta.alves@atelier-renew.example", "current_password": "limewash2026"}
Then the response is 200 with {"pending_email": "marta.alves@atelier-renew.example"}
And users.email is still "marta@renew.example"
And PendingEmail is "marta.alves@atelier-renew.example", the stored code is a hash (never the raw
code) and the expiration is within 5 seconds of now plus 2 hours
And one mail is sent to "marta.alves@atelier-renew.example" with template "verify-new-email" and
data Host, Username "marta" and the raw VerificationCode
And one mail is sent to "marta@renew.example" with template "email-change-notice" and data Host,
Username "marta" and NewEmail "marta.alves@atelier-renew.example"
And one audit event user.request_email_change is recorded: actor marta, target user 7, success,
security, with no address in its params
Scenario Outline: A refused request changes nothing and sends nothing
When marta sends POST /v1/auth/email/change with <body>
Then the response is <status> with the body "<text>"
And PendingEmail is empty and no mail is sent
Examples:
| body | status | text |
| {"new_email": "marta.alves@atelier", "current_password": "limewash2026"} | 400 | the validator message for new_email |
| {"new_email": "", "current_password": "limewash2026"} | 400 | the validator message for new_email |
| {"new_email": "marta.alves@atelier-renew.example", "current_password": ""} | 400 | the validator message for current_password |
| not-json | 400 | The request cannot be processed due to a mismatch in the format of the body. |
| {"new_email": "MARTA@renew.example", "current_password": "limewash2026"} | 400 | same_email |
| {"new_email": "marta.alves@atelier-renew.example", "current_password": "nope"} | 403 | wrong_password |
Scenario: A wrong password records a failed security event
When marta sends the request with the password "nope"
Then one audit event user.request_email_change is recorded with outcome failed, security true and
failure reason "Wrong password."
Scenario: An address already in use answers like success and sends no verification mail
Given another user has the email "ines@renew.example"
When marta sends POST /v1/auth/email/change with new_email "Ines@Renew.example"
Then the status, the body and every header except Date are identical to the valid request
And a pending change for "Ines@Renew.example" is stored so a reload cannot tell the difference
And no mail is sent to "ines@renew.example"
And the email-change-notice is still sent to "marta@renew.example"
Scenario: A new request replaces the previous link
Given marta requested a change to "a@renew.example" and received code 1
When she requests "b@renew.example"
Then only code 2 confirms, code 1 answers 403 "invalid_email_change_code", and PendingEmail is "b@renew.example"
Scenario: A failed send is logged and the response is unchanged
Given the mail repository fails on Send with "smtp: connection refused"
When marta sends a valid request
Then the response is 200 with the same body, the pending change is stored, and the process log names
the failed send and the error but not the raw code
And a 500 would reveal whether the address is taken, which is why the failure is not surfaced
Scenario: The sixth request in 15 minutes is rate limited
Given marta made five requests or resends in the last 15 minutes
When she sends one more
Then the response is 429 "Too many requests. Please try again later." with a Retry-After header
And no mail is sent and nothing is stored
Rule: Resending and cancelling while pending
Scenario: Resend issues a new code and invalidates the old one
Given marta has a pending change to "marta.alves@atelier-renew.example" with code 1
When she sends POST /v1/auth/email/resend
Then the response is 200 with {"pending_email": "marta.alves@atelier-renew.example"}
And one mail with template "verify-new-email" carries a new code, the expiration is reset to now plus 2 hours
And code 1 answers 403 "invalid_email_change_code"
And no email-change-notice is sent again
And one audit event user.resend_email_change is recorded
Scenario: Resend for a taken address sends nothing
Given marta's pending address belongs to another user
When she sends POST /v1/auth/email/resend
Then the response is 200 with the same body and no mail is sent
Scenario: Resend with nothing pending
When marta sends POST /v1/auth/email/resend
Then the response is 404 "no_pending_email_change"
Scenario: Cancel clears the pending change
Given marta has a pending change
When she sends DELETE /v1/auth/email/change
Then the response is 204 and PendingEmail, code and expiration are empty
And the old link answers 403 "invalid_email_change_code"
And one audit event user.cancel_email_change is recorded
Scenario: Cancel with nothing pending is harmless
When marta sends DELETE /v1/auth/email/change
Then the response is 204 and no audit event is recorded
Rule: Confirming from the link
Scenario: A valid code changes the email and is single use
Given marta has a pending change to "marta.alves@atelier-renew.example" with a live code
When she sends POST /v1/auth/email/confirm with that code
Then the response is 200 with {"email": "marta.alves@atelier-renew.example"}
And users.email is "marta.alves@atelier-renew.example", the pending fields are empty
And her session keeps working (token_version is unchanged)
And one audit event user.confirm_email_change is recorded
When she sends the same code again
Then the response is 403 "invalid_email_change_code"
Scenario Outline: A code that does not work answers one 403
Given <setup>
When marta confirms with <code>
Then the response is 403 "invalid_email_change_code" and users.email is unchanged
Examples:
| setup | code |
| nothing is pending | any code |
| the code expired 1 second ago | the right code |
| a pending change exists | a wrong code |
| the code was issued to another user | the right code of ines |
| the change was cancelled | the old code |
Scenario: The address was taken in the meantime
Given marta has a live code for "ines@renew.example" and the address now belongs to another user
When she confirms
Then the response is 409 "email_unavailable", users.email is unchanged and the pending change is cleared
And the body is the same for an address that was always taken at request time
Scenario: An empty code is refused before any lookup
When marta sends {"code": ""}
Then the response is 400 with the validator message and nothing changes
Rule: Profile, the Account card (SPA)
Background:
Given /profile (docs/design/screens/profile.md, features/profile_page.feature) shows the Email
field and a link "Change email"; the order of the cards is Display name, Account, Your role,
Author profile, Password, Your data, Danger zone
And the session carries pending_email from GET /v1/auth/me
And the global handling of 403 in useApi passes a 403 with body "wrong_password" through to the
caller; the form reads the body text and never the status alone
And every new SPA string (the Account card states, toasts and errors, and the verification landing
states) is added to the i18n catalogue (messages.ts, features/spa_internationalization.feature) in
English and in French under the same keys, and the English wording is the design's verbatim
And the French wording is an assumption: docs/design has no French copy, so the coder authors it and
flags it for the user's review
And the mail templates verify-new-email and email-change-notice are English only, because the existing
mailer does not localise (verification, password-reset and invitation hard-code lang="en" and the
subjects are fixed English strings)
And every state below is one Vitest spec and one row of `task visual:check -- account`, named as in
the board, using the sample data of the frame (marta, marta@renew.example,
marta.alves@atelier-renew.example)
Scenario Outline: Form states
Given the user is on /profile
When <trigger>
Then the state "<state>" is visible with <copy>
And <requests>
Examples:
| state | trigger | copy | requests |
| Default | the page loads, nothing pending | the Email field and the link "Change email" | none |
| Form open | "Change email" is activated | "New email address", "Current password", the hint "We'll send a verification link to the new address. Your email stays the same until you open it.", "Cancel", "Send verification link"; focus on the email field | none |
| Invalid format | "marta.alves@atelier" is typed and the field blurs or is submitted | "Enter a full email address, like name@example.com." | none |
| Same as current | "marta@renew.example" is submitted | "That's already your email address." | none |
| Password missing | submitted with an empty password | "Enter your current password.", focus on the password | none |
| Wrong password (403) | POST answers 403 "wrong_password" | "That password isn't correct. Try again." under the password, password cleared and focused | one POST |
| Sending | POST has not resolved | fields and button disabled, button reads "Sending…" | one POST |
| Rate limited (429) | POST answers 429 | "Too many requests. Try again in a few minutes.", "Send verification link" disabled | one POST |
| Server or network error | POST answers 500 or fails | "Something went wrong on our side. Nothing was changed. Try again in a moment." and the typed values kept | one POST |
Scenario: Pending, from a successful request
When POST /v1/auth/email/change answers 200 with pending_email "marta.alves@atelier-renew.example"
Then the form closes and the Email field still shows "marta@renew.example"
And the card shows "Waiting for verification", "We sent a link to marta.alves@atelier-renew.example. Until you open it, you keep logging in with marta@renew.example.", "Resend link" and "Cancel change"
And a toast "Verification link sent" / "Check marta.alves@atelier-renew.example. The link works once, within 2 hours." appears
And reloading /profile with pending_email set shows the same Pending card without a toast
Scenario Outline: Pending actions
Given the Pending card is shown
When <trigger>
Then <outcome>
Examples:
| trigger | outcome |
| "Resend link" is activated, in flight | state "Pending: resending": "Sending…" and the button disabled |
| the resend answers 200 | state "Pending: resent": toast "Link sent again" / "The previous link no longer works." |
| the resend answers 429 with Retry-After 270 | state "Pending: resend rate limited (429)": "Resend in 4:30" disabled, "Too many requests. Try again in a few minutes."; the count runs down from the header, not the wall clock |
| "Cancel change" is activated | DELETE /v1/auth/email/change with no confirmation step, then state "Cancelled": the Default state and toast "Email change cancelled" / "You keep logging in with marta@renew.example." |
Rule: The verification landing /account/email/confirm?c=CODE (SPA)
Background:
Given the route is a session route inside AuthCard; without a session it shows the inline login as
/register/validate does, then continues the confirmation
And the AuthCard states are those of the board group "Verify new email"
Scenario Outline: Landing states
Given the user opens /account/email/confirm<query>
When <trigger>
Then the state "<state>" shows <copy>
Examples:
| query | trigger | state | copy |
| ?c=abc123 | POST /v1/auth/email/confirm unresolved | Confirming | "Confirming your new email…" |
| ?c=abc123 | the POST answers 200 {"email": "marta.alves@atelier-renew.example"} | Success | "Your email address has changed", "Your account now uses marta.alves@atelier-renew.example. Use it the next time you log in.", "Go to profile"; toast "Email address changed" / "Your account now uses marta.alves@atelier-renew.example."; the session email is replaced |
| ?c=abc123 | the POST answers 403 | Invalid or expired (403) | "This link doesn't work", "It may have expired (links last 2 hours), been cancelled or already been used.", "Request a new link from your profile.", "Go to profile" |
| ?c=abc123 | the POST answers 409 "email_unavailable" | Address unavailable (409) | "We couldn't change your email address", "This address can't be used for your account. Go back to your profile and try a different one.", "Go to profile" |
| ?c=abc123 | no session (401 from /auth/me) | No session (401) | "Log in to confirm your new email", "You opened this link somewhere you're not logged in. Log in here and we'll confirm the change right after.", "Log in and confirm" |
| | no c parameter | Missing code | "This link is incomplete", "The confirmation code is missing. Open the link from your email again, or copy the full address into your browser.", "Go to log in"; no request is sent |
| ?c=abc123 | the POST answers 500 or fails | Server error | "Couldn't confirm the change", "Something went wrong on our side. Nothing was changed. Try again in a moment.", "Try again", which sends the POST again |
Scenario: Log in and confirm
Given the "No session (401)" state is shown
When the user logs in inline with valid credentials
Then POST /v1/auth/email/confirm is sent once with the code and the Success state follows
And the code is not written to the URL history or logs by the SPA
Scenario: A confirmation is not repeated by reload noise
Given the Success state was reached
When the user reloads the page
Then the POST is sent again and the "Invalid or expired (403)" state is shown, because the code is single use
Rule: Mail templates
Scenario Outline: Each template renders from its data
When the mailer renders "<template>" with <data>
Then the HTML contains <contains> and no unreplaced {{ }} action
And the subject is "<subject>"
Examples:
| template | data | contains | subject |
| verify-new-email | Host https://cms.example.com, Username marta, VerificationCode abc | the link https://cms.example.com/account/email/confirm?c=abc | Confirm your new RenewCMS email address |
| email-change-notice | Host https://cms.example.com, Username marta, NewEmail new@renew.example | new@renew.example and the link https://cms.example.com/profile | Your RenewCMS email address change was requested |
Rule: Documentation and privacy
Scenario: The docs describe the feature
Then docs/api.md documents the four endpoints, their bodies, statuses and the pending_email field of /auth/me
And docs/privacy.md says users can change their email themselves (art. 16), lists the three new users
columns in the stored-data table, and removes "Changing an email address currently needs a database edit"
and the email half of "No self-service export or email change" from Gaps
+110
View File
@@ -0,0 +1,110 @@
# QA procedure: account_change_email
Package: `test` (black-box), extending `test/user_test.go` (`mockUserRepo`, `stubHasher`) and `test/mail_test.go` (`mockMailRepo`, `recordedMail`, `failSwitch`). Reuse the existing recording audit fake. Fake type names must be unique across the package.
## Test infrastructure additions needed first
1. `mockUserRepo`: add the new repository methods for setting, clearing and confirming a pending change.
- Each honours `fail.trigger` and returns `user.ErrUserNotFound` for an unknown id.
- Confirm checks "email not held by another user" and returns `user.ErrEmailUnavailable`.
- Add call counters, in the style of `clearVerificationCodeCalls`.
2. Seed fixtures with `stubHasher` hashes: `EmailChangeCode: "hashed:abc123"` stands for the raw code `abc123`. The password "limewash2026" is seeded as `"hashed:limewash2026"`.
3. A reusable helper builds the real `RequestEmailChangeUseCase`, `ResendEmailChangeUseCase`, `CancelEmailChangeUseCase` and `ConfirmEmailChangeUseCase` over `mockUserRepo`, `stubHasher`, `mockMailRepo`, the audit fake and `publicURL "https://cms.example.com"`.
## Setup shared by every case
4. Call `validator.InitValidator()` once.
5. Handler checks build the handler from the use cases via its `NewHandler`/named constructor. Mount the four routes on a fresh `chi.NewRouter()` that mirrors the `NewAuthRouter` session group: `middleware.RequireSession`, `authtoken.Verifier`, `AuditContext`, `RequireSignedIn`, `authtoken.Authenticator`, `RequireCurrentToken`, `RequireVerifiedUser`. Add the shared `middleware.NewUserRateLimiter(5, 15*time.Minute)` in front of change and resend only.
6. Use a test `authtoken` instance and mint jwt cookies carrying `user_id` and `token_version`. Seed user 7 "marta" (`IsVerified: true`).
7. No live server, real SQLite file or SMTP.
## Use-case checks
8. Request, valid. Assert:
- nil error.
- `PendingEmail` set, and `users.email` unchanged.
- Stored code equals `"hashed:"+raw`, and the expiration is within 5 s of now+2h.
- `SentEmails` has exactly two entries.
- Entry 1 is `verify-new-email` to the new address; its data (type-assert `useCases.MailVerifyNewEmailData`) carries Host, Username and the raw code.
- Entry 2 is `email-change-notice` to the old address, with NewEmail in its data.
- One audit event `user.request_email_change`, success, `Security` true, with no `@` in its Params or Changes.
9. Request errors are table-driven, each asserted with `errors.Is`:
- Wrong password gives `ErrWrongPassword`, with no mail, nothing stored and one failed security event.
- Same address in different case gives `ErrSameEmail`.
10. Taken address, matched case-insensitively. Assert the same return value and the same stored `PendingEmail` as case 8, only the notice mail sent (none to the other user), and the same audit event shape.
11. Mail failure via `mailRepo.fail.failNext("Send")`. Capture the log (`log.SetOutput(&buf)` with a cleanup). Assert nil error, the pending change stored, a log line naming the failure, and neither the raw code nor its hash in the log.
12. Resend. Assert:
- A new code and expiry, and the old raw code now fails `stubHasher.Compare`.
- One `verify-new-email` mail and no notice.
- `ErrNoPendingEmailChange` when nothing is pending.
- Nothing sent for a taken address.
13. Cancel. Assert fields cleared, nil error when nothing is pending, and an audit event only when something was cancelled.
14. Confirm, success. Assert:
- `users.email` is the new address, the pending fields are cleared, and `TokenVersion` is unchanged.
- A second confirm returns `ErrInvalidEmailChangeCode`.
- The `user.confirm_email_change` event is recorded.
15. Confirm, failures. Assert:
- Expired, wrong, other user's, cancelled and none pending all return `ErrInvalidEmailChangeCode`.
- Taken returns `ErrEmailUnavailable` and clears the pending change.
## HTTP checks
16. Request, valid and refused. Using the examples table of the feature, assert the status and exact body and that `SentEmails` is empty on refusals. The text for the 400 cases is the validator message; assert it equals `validator.Validate.Struct` output.
17. Taken-address response parity. Compare status, body and headers except Date between case 8 and case 10 using `reflect.DeepEqual` on the header map minus Date.
18. Auth gates, for each of the four routes:
- No cookie gives 401 "You must be logged in to access this resource.".
- A bearer API key gives 403 "This action needs a real session.".
- An unverified user gives 403 "You must verify your account to access this resource.".
19. Rate limit. Send 5 valid-or-wrong-password requests mixed with resends, then assert the sixth is 429 with a numeric `Retry-After` and the body "Too many requests. Please try again later.". Assert DELETE and confirm still answer.
20. Resend, cancel and confirm statuses and bodies exactly as in the feature (200 JSON, 404 `no_pending_email_change`, 204 empty, 403 `invalid_email_change_code`, 409 `email_unavailable`).
21. `GET /auth/me` returns `pending_email` as null, then as the address after a request.
## Mailer checks (no SMTP)
22. Render `verify-new-email` and `email-change-notice` with `MailRepository.Render` and fake data.
- Assert the link strings, the subjects, and that no `{{` remains.
- Assert the HTML escapes a username such as `<b>`.
## Vitest (SPA), `web/src/**/__tests__/`
Mock `/v1` with the response in the second column. One spec per state, named with the design state name. Strings live in `messages.ts` in English and French under the same keys.
I18n checks (extend the existing `web/src/i18n/*.spec.ts` style, e.g. `spa.spec.ts`):
- Key parity: every new key exists in both `en` and `fr`, with no empty value and the same interpolation placeholders (new address, old address, countdown).
- French render: for each state with `setLocale('fr')`, assert the French text is shown and no English string remains; switching `en`, `fr`, `en` restores the English copy.
- English values equal the design copy verbatim.
Flagged assumption: the French wording is authored by the coder because docs/design has none. French strings to author, for the user's review: the "Change email" link, the form labels and hint, the four field errors, "Sending…", "Waiting for verification" and its text, "Resend link", "Cancel change", "Resend in {time}", the three toast pairs (sent, resent, cancelled), the 429 and server notices, and all seven landing states (title, text, button).
| State | Mocked /v1 |
|---|---|
| Default, Form open | `GET /auth/me` with pending_email null |
| Invalid format, Same as current, Password missing | none (no request asserted) |
| Wrong password (403) | `POST /auth/email/change` 403 "wrong_password" |
| Sending | POST pending forever |
| Pending, then reload | POST 200; `GET /auth/me` with pending_email |
| Pending: resending / resent / resend rate limited | `POST /auth/email/resend` unresolved / 200 / 429 with Retry-After 270 |
| Cancelled | `DELETE /auth/email/change` 204 |
| Rate limited (429), Server or network error | POST 429; POST 500 and a rejected fetch |
| Landing: all seven states | `POST /auth/email/confirm` per the feature table; the 401 state uses `GET /auth/me` 401 and then an inline login |
Also assert:
- The 403 `wrong_password` is not treated as a global unverified redirect.
- Typed values are kept in the 429 and 500 states.
- The code is read from `?c=` only.
## Visual
Run `task visual:check -- account` and `task visual:check -- auth`. Every capture must be within 10 % at desktop and mobile, light and dark. No `exception` without the user's `approvedBy`.
- Board data to reuse in `web/visual/states.ts` setups: username marta, old email marta@renew.example, new email marta.alves@atelier-renew.example, resend countdown 4:30 (Retry-After 270).
- States: the 14 "Change email" states and the 7 "Verify new email" states of the Auth board.
- Toast states (Pending, Pending: resent, Cancelled) capture the toast.
- None of these is clock-sensitive.
## Manual verification
- Real SMTP delivery and rendering of both mails in a real client and in dark mode.
- Opening the link from a different browser to see the inline login, then the confirmation.
- Keyboard and focus order: the email field on open, password focus after 403, focus return after Cancel.
- Hover and motion of the toasts.
- Real embedded frontend: open `/account/email/confirm?c=...` on the built binary.
+235
View File
@@ -0,0 +1,235 @@
Feature: Delete my own account and my personal data
As a verified RenewCMS user
I want to delete my account with my password and a typed confirmation
So that I can exercise my right to erasure (GDPR art. 17) while the content I wrote stays on the site
without my name, and the workspace is never left without an Owner or an administrator
Background:
Given the design of record is docs/design/screens/account.md "Delete account", the board
"RenewCMS Screens Account" group "Delete account" (10 states) and the board "RenewCMS Screens Auth"
group "Account deleted" (1 state)
And RemoveUserUseCase and DELETE /v1/user/{id} are not reused, because they refuse the caller as the
target (role.ErrOwnRole), require users.remove and a covering role, never remove a Locked role (the
Owner), take no password and only delete the user row; a new DeleteOwnAccountUseCase is added and
RemoveUserUseCase is not changed
And DELETE /v1/user/me is added to NewUserRouter behind RequireSession, Verifier, AuditContext,
RequireSignedIn, Authenticator, RequireCurrentToken and RequireVerifiedUser, with a per-user limiter of
5 attempts per 15 minutes (429 "Too many requests. Please try again later." with Retry-After); an API
key gets 403 "This action needs a real session."
And the body is {"current_password": "...", "confirmation": "DELETE"} validated with the tags
current_password "required" and confirmation "required,eq=DELETE" (exact, case-sensitive) through
validator.Validate.Struct; the board sends the password only, so confirmation is an addition
And password checking uses the domain hash.Hasher
And the check order is: body validation 400, wrong password 403 "wrong_password", last Owner or
administrator 409 "last_admin", then one atomic removal
And "last administrator" means: the user holds the built-in Owner role and no other verified user does
(Owner variant), or the user holds the built-in Admin role and no other verified user holds Owner or
Admin (Admin variant); other roles are never blocked; the 409 carries the header X-Last-Role with
"Owner" or "Admin"
And one atomic persistence operation (owned by persistence; the use case sees only a domain interface)
does, or does none of:
| effect | result |
| user | the users row is deleted; every jwt of the user stops working because RequireCurrentToken cannot find it |
| API keys | all the user's api_keys rows are deleted |
| author | the linked author is deleted if no article credits it; otherwise it is scrubbed in place: name "Deleted user", slug "deleted-user-<authorId>", empty bio, no avatar, website or social links, user_id NULL |
| content | articles (created_by), entries (created_by, updated_by) and images (uploaded_by_id) keep the old id and are not touched |
| audit events | the user's past events lose their name (actor and target names become "Deleted user") and their ip and user_agent become null; the events stay |
| re-check | the role holders are counted again inside the same operation; a change since the check answers 409 "account_not_deletable" and nothing is removed |
And after the commit the use case revokes the current session id, then sends the template
"account-deleted" once to the address the account had (data: Host only), and the handler clears the
jwt cookie (Max-Age 0, same attributes as logout)
And a failed send is logged and does not undo the deletion, and the response is still 204
And the audit event user.delete_account is recorded after the removal: actor and target id of the
deleted user, both named "Deleted user", category users, security true, success; its params hold no email
And a failed attempt records user.delete_account with outcome failed, security true and the reason
"Wrong password." or "Last Owner." / "Last administrator." on the user's own events
And the success response is 204 with no body and an expired jwt cookie
And the layering rule holds and no comment is added anywhere
Rule: The endpoint
Background:
Given the users "marta" (id 7, Writer), "jonas" (id 2, Admin) and "ines" (id 3, Owner)
Scenario: A Writer deletes their account
Given marta has the API keys 1 and 2, an author profile credited on 3 articles, 2 entries, 4 uploaded
images and 12 audit events
And her email was "marta@renew.example"
When marta sends DELETE /v1/user/me with {"current_password": "limewash2026", "confirmation": "DELETE"}
Then the response is 204 with no body and a Set-Cookie that expires jwt
And user 7 no longer exists, API keys 1 and 2 no longer exist
And her author row remains with name "Deleted user", slug "deleted-user-<id>", user_id null and no bio
And the 3 articles, 2 entries and 4 images are unchanged
And her 12 events show "Deleted user" and have a null ip and user_agent
And her old jwt on GET /v1/auth/me answers 401
And one mail "account-deleted" with Host only is sent to "marta@renew.example"
And one audit event user.delete_account is recorded
Scenario: An author profile that credits no article is deleted
Given marta's author profile credits nothing
When she deletes her account
Then the author row is gone
Scenario Outline: Refused requests change nothing
When marta sends DELETE /v1/user/me with <body>
Then the response is <status> with the body "<text>"
And user 7, her API keys, her author and her events are unchanged, and no mail is sent
Examples:
| body | status | text |
| {"current_password": "limewash2026", "confirmation": "delete"} | 400 | the validator message for confirmation |
| {"current_password": "limewash2026", "confirmation": ""} | 400 | the validator message for confirmation |
| {"current_password": "", "confirmation": "DELETE"} | 400 | the validator message for current_password |
| not-json | 400 | The request cannot be processed due to a mismatch in the format of the body. |
| {"current_password": "nope", "confirmation": "DELETE"} | 403 | wrong_password |
Scenario Outline: The last Owner or administrator is blocked
Given the caller holds <role> and <others>
When the caller sends a valid DELETE /v1/user/me
Then the response is <result>
Examples:
| role | others | result |
| Owner | no other Owner, one Admin | 409 "last_admin" with X-Last-Role: Owner |
| Owner | no other Owner, no Admin | 409 "last_admin" with X-Last-Role: Owner |
| Owner | another verified Owner | 204 |
| Admin | no Owner and no other Admin | 409 "last_admin" with X-Last-Role: Admin |
| Admin | one Owner | 204 |
| Admin | another verified Admin and no Owner | 204 |
| Admin | only an unverified Admin and no Owner | 409 "last_admin" with X-Last-Role: Admin |
| Writer | no Owner and no Admin | 204 |
| a custom role with every admin permission | no Owner and no Admin | 204 |
Scenario: A blocked attempt leaves everything and records a failed event
Given the caller is the last Owner
When the caller sends a valid DELETE /v1/user/me
Then the user, keys, author and events are unchanged and no mail is sent
And one audit event user.delete_account is recorded with outcome failed, security true and the reason "Last Owner."
Scenario: A concurrent role change stops the removal
Given the check passed and another user's role changed before the atomic removal
When the removal runs
Then the response is 409 "account_not_deletable" and nothing was removed
Scenario: A mail failure does not undo the deletion
Given the mail repository fails on Send
When marta deletes her account
Then the response is 204, the user is gone and the process log names the failed send
Scenario: A failure in the removal rolls everything back
Given deleting the API keys fails inside the operation
When marta deletes her account
Then the response is 500 "Internal server error" and user 7, her author and her events are unchanged, and no mail is sent
Scenario: Rate limited after five attempts
Given marta made five attempts in the last 15 minutes
When she sends one more
Then the response is 429 with a Retry-After header, and nothing is checked or changed
Scenario Outline: Only a real verified session may delete
When <caller> sends DELETE /v1/user/me
Then the response is <status>
Examples:
| caller | status |
| nobody | 401 |
| an API key bearer | 403 |
| an unverified user | 403 |
Scenario: DELETE /v1/user/{id} is unchanged
Given the scenarios of the users_row_actions feature
Then all still pass, and DELETE /v1/user/me is not routed to the {id} handler
Rule: Mail template
Scenario: account-deleted renders without personal data
When the mailer renders "account-deleted" with Host https://cms.example.com
Then the subject is "Your RenewCMS account has been deleted"
And the HTML has no button, no username and no address, and no unreplaced {{ }} action
Rule: Danger zone and the delete dialog (SPA)
Background:
Given the Danger zone card is the last card of /profile with the title "Danger zone", the text "Delete
your account and your personal data. This can't be undone." and the button "Delete my account"
And a dialog shows the design's content: "Delete your account?", "This permanently deletes your
account. It can't be undone.", the list "Your username, display name and email address", "Your author
profile and API keys", "Your sessions: you'll be logged out everywhere", the section "Your articles
and media stay" / "They stay on the site, credited to "Deleted user". Your name is removed from
them.", the link "Export my data first", the fields "Current password" and "Type DELETE to
confirm", and the actions "Keep my account" and "Delete my account"
And every new SPA string (the Danger zone card, the dialog and its blocked variants, errors, and the
Login notice "Your account has been deleted" with its text) is added to the i18n catalogue
(messages.ts, features/spa_internationalization.feature) in English and in French under the same
keys, with the design's English wording verbatim
And the French wording is an assumption: docs/design has no French copy, so the coder authors it and
flags it for the user's review
And the account-deleted mail is English only, because the existing mailer does not localise
(templates hard-code lang="en" and the subjects are fixed English strings)
And every state is a Vitest spec and a `task visual:check -- account` row, named as in the board
Scenario Outline: Dialog states
Given the user opened the dialog on /profile
When <trigger>
Then the state "<state>" shows <copy>
Examples:
| state | trigger | copy |
| Danger zone | the page loads | the card only; no request |
| Confirm: empty | the dialog opens | "Delete my account" disabled |
| Confirm: typing | a password and "DEL" are entered | still disabled |
| Confirm: ready | a password and exactly "DELETE" are entered | enabled; "delete", "Delete" and "DELETE " (trailing space) keep it disabled |
| Wrong password (403) | DELETE answers 403 "wrong_password" | "That password isn't correct. Try again." under the password, password cleared and focused, the word kept |
| Deleting | DELETE unresolved | "Deleting…", fields and "Keep my account" disabled, no close |
| Rate limited (429) | DELETE answers 429 | "Too many requests. Try again in a few minutes.", Confirm stays enabled |
| Server error | DELETE answers 500 or fails | "Couldn't delete your account. Nothing was deleted. Try again in a moment.", values kept |
| Blocked: last administrator | DELETE answers 409 "last_admin" with X-Last-Role Admin | "You can't delete your account yet", "You are the last administrator. RenewCMS needs at least one. Promote another user first, then come back to delete your account.", "Back to profile", "Go to Users" |
| Blocked: last Owner (Vitest only, no board frame) | the same with X-Last-Role Owner | "You can't delete your account yet", "You are the last Owner. RenewCMS needs at least one. Transfer ownership or promote another user first, then come back to delete your account.", "Back to profile", "Go to Users" |
| Blocked: cannot be deleted | DELETE answers 409 "account_not_deletable" | "This account can't be deleted", "RenewCMS can't delete this account right now. Ask an administrator to help, then try again.", "Back to profile" (no "Go to Users") |
Scenario Outline: Blocked dialogs cannot be dismissed
Given a "Blocked" state is shown
When the user <action>
Then the dialog stays open and shows no close button
Examples:
| action |
| presses Escape |
| clicks the backdrop |
Scenario: A blocked dialog always offers a next step
Then "Back to profile" closes the dialog, and "Go to Users" navigates to /users
Scenario: Export my data first
Given the dialog is open
When the user activates "Export my data first"
Then the download of slice account_export_data starts without closing the dialog
Scenario: Success signs out and redirects with the one-time message
Given the user confirmed with a valid password and "DELETE"
When DELETE /v1/user/me answers 204
Then the session store is cleared without calling POST /v1/auth/logout and the router goes to /login?reason=account-deleted
And the Login screen shows a success notice (icon circle-check) "Your account has been deleted" /
"Your articles and media were kept without your name."
And the query is removed from the URL once consumed, so a reload shows the plain Login with no notice
And the leave-page confirmation of Profile does not open
Scenario: The one-time message needs the exact reason
When a visitor opens /login?reason=anything-else
Then no account-deleted notice is shown
Scenario: Deleted creators are named "Deleted user" in the SPA
Given an entry whose created_by is 7 and GET /v1/user/lookup does not return user 7
Then the screen that names its creator shows "Deleted user"
Rule: Documentation and privacy
Scenario: The docs describe the feature
Then docs/api.md documents DELETE /v1/user/me: body, 204, 400, 403 wrong_password, 409 last_admin with
X-Last-Role, 409 account_not_deletable, 429
And docs/privacy.md:
- replaces the Erasure bullet and the "No way to delete a verified user" Gap with the self-service flow;
- states what is kept (articles, entries, media, anonymised author and activity events) and what is removed;
- adds the account-deleted mail to "What leaves the server";
- leaves the Gaps entry about soft-deleted rows and the failed-login username entry intact.
+101
View File
@@ -0,0 +1,101 @@
# QA procedure: account_delete
Package: `test` (black-box), extending `test/user_test.go` (`mockUserRepo`, `stubHasher`), `test/mail_test.go` (`mockMailRepo`, `failSwitch`) and the existing role, author, api-key and audit fakes. Reuse them and keep new type names unique.
## Test infrastructure additions needed first
1. A fake of the atomic domain operation behind `DeleteOwnAccountUseCase` that mutates the in-memory fakes together.
- It honours `fail.trigger` at each step ("DeleteAPIKeys", "ScrubAuthor", "AnonymiseEvents", "DeleteUser").
- On any failure it restores a snapshot, so rollback is observable.
2. The role fake: seed built-in Owner, Admin, Writer and a custom role with all admin permissions. Add a `CountHolders`-style method (or whichever method the coder adds to the domain interface) returning verified holders only.
3. The audit fake: record events and support the anonymise method; assert the rewritten events by id.
4. The session revocation: reuse the `authtoken` revocation fake; record revoked ids.
## Setup shared by every case
5. Call `validator.InitValidator()` once.
6. Build `useCases.NewDeleteOwnAccountUseCase(...)` over the fakes, `stubHasher`, `mockMailRepo`, a clock, `publicURL "https://cms.example.com"` and the audit fake.
7. Handler checks mount `DELETE /v1/user/me` on a fresh `chi.NewRouter()` with the `NewUserRouter` session chain and the 5 per 15 minutes limiter. Also mount `DELETE /{id}` behind its permission middleware, to prove the routing order. Mint jwt cookies with `user_id` and `token_version`.
8. Seed marta (id 7, Writer, "hashed:limewash2026"), jonas (2, Admin), ines (3, Owner). No live server, real SMTP or real SQLite.
## Use-case checks
9. Happy path for a Writer. Assert:
- The user is gone, keys 1 and 2 are gone.
- The author row is scrubbed (name, slug `deleted-user-<id>`, bio "", avatar/website nil, links empty, user_id nil) when it credits 3 articles.
- The author is deleted when it credits none.
- Articles, entries and images are byte-for-byte unchanged (`reflect.DeepEqual` before and after).
- Her 12 events are anonymised and the other users' events are untouched.
- The session id is revoked.
- Exactly one `account-deleted` mail goes to the old address, with data `useCases.MailAccountDeletedData{Host}` and no other field.
- Exactly one `user.delete_account` event, with actor and target name "Deleted user" and no `@` in the params.
10. Password. A wrong password returns `user.ErrWrongPassword` (`errors.Is`), changes nothing, sends nothing and records one failed security event.
11. Last-role matrix. Table-driven from the feature (9 rows):
- Owner/Admin blocked rows return `user.ErrLastAdmin` carrying the role label "Owner" or "Admin" (assert with `errors.As` on whatever the coder exports).
- Unverified holders do not count.
- A custom all-permission role does not count and is not blocked.
- Allowed rows delete.
12. Concurrent role change. The fake changes a holder between the check and the operation. Assert `ErrAccountNotDeletable` and an untouched state.
13. Rollback. For each step failure in case 1, assert that all fakes equal their pre-state, no mail is sent, no revocation is recorded and the error is returned.
14. Mail failure with the log captured. Assert nil error, the user gone, and a log line naming the failed send.
15. Order of checks. A wrong password plus a last-Owner caller returns `ErrWrongPassword`, not last_admin (no role probing without the password).
## HTTP checks
16. Success. Assert 204, an empty body, and a `Set-Cookie` for `jwt` with `Max-Age=0` or an expired date. A follow-up `GET` with the old cookie through the real middleware chain gives 401.
17. Refusals table of the feature: status and exact body for each row. For the 400 rows, compare to the `validator.Validate.Struct` message. Assert the fakes are unchanged.
18. Last role: 409, body `last_admin`, header `X-Last-Role` equal to `Owner` or `Admin`.
19. `account_not_deletable` gives 409 with that body.
20. Rate limit. Make 5 attempts (mix wrong-password and blocked), then assert the sixth is 429 with `Retry-After` and that no state changed.
21. Auth gates: 401, API key 403 and unverified 403, with exact bodies.
22. Routing. `DELETE /v1/user/me` reaches the new handler, never `removeHandler`. `DELETE /v1/user/7?expected_role_id=3` still behaves as `features/users_row_actions.qa.md` says.
23. Mail rendering. Render `account-deleted` with the real `MailRepository.Render`. Assert the subject, no `@`, no username, no button, no leftover `{{`.
## Persistence (real in-memory SQLite, same models as `api/dependecyInjection.go`)
24. One test runs the real atomic operation. Assert the user, the keys, the scrub-or-delete of the author, the audit anonymisation, a forced mid-way failure leaving everything intact, and the portability of the SQL (no driver-specific syntax). If a real-DB test already exists for MySQL/PostgreSQL (`task test:mysql`/`test:postgres`), run the same test there.
## Vitest (SPA)
Strings live in `messages.ts` in English and French under the same keys.
I18n checks (extend the existing `web/src/i18n/*.spec.ts` style, e.g. `spa.spec.ts`):
- Key parity: every new key exists in both `en` and `fr`, with no empty value and the same placeholders.
- French render: for the Danger zone, the dialog, each Blocked variant and the Login notice with `setLocale('fr')`, assert the French text is shown and no English string remains; switching `en`, `fr`, `en` restores the English copy.
- The confirmation word stays the literal "DELETE" in both languages, and the French dialog label tells the user to type it.
- English values equal the design copy verbatim.
Flagged assumption: the French wording is authored by the coder because docs/design has none. French strings to author, for the user's review: the Danger zone title, text and button; the dialog title, text, the three list items, the "Your articles and media stay" heading and text, "Export my data first", the two field labels, "Keep my account", "Delete my account", "Deleting…"; the wrong-password, 429 and server errors; the three Blocked variants (Owner, administrator, cannot be deleted) with their buttons; and the Login notice title and text.
| State | Mocked /v1 |
|---|---|
| Danger zone, Confirm: empty/typing/ready | none |
| Wrong password (403) | `DELETE /user/me` 403 "wrong_password" |
| Deleting | DELETE pending |
| Rate limited (429), Server error | 429; 500 and a rejected fetch |
| Blocked: last administrator | 409 "last_admin" + `X-Last-Role: Admin` |
| Blocked: last Owner | 409 "last_admin" + `X-Last-Role: Owner` |
| Blocked: cannot be deleted | 409 "account_not_deletable" |
| Success | 204; assert no `POST /auth/logout`, `session.clear()`, router at `/login?reason=account-deleted` |
Also assert:
- Confirm enablement for "DELETE", "delete", "DELETE ", "".
- A blocked dialog ignores Escape and the backdrop and has no close button.
- The Login notice appears for `reason=account-deleted` only, and the query is gone after it is consumed.
- Typed values are kept on 429 and 500.
- The "Deleted user" fallback where a creator is missing from the lookup.
## Visual
`task visual:check -- account` and `-- auth`.
- Board data to reuse: password mask `••••••••••`, the typed word "DEL" in Confirm: typing and "DELETE" elsewhere, and the Auth board's "Account deleted" notice.
- States: all 10 delete states of the Account board and "Account deleted" on the Auth board. The Owner variant has no frame.
- None is clock-sensitive.
## Manual verification
- Real SMTP: the account-deleted mail arrives once at the old address, and the link-free rendering in a real client.
- Delete a real account in a browser against a real database file: log back in as another user and check an article byline reads "Deleted user", and Activity shows "Deleted user".
- On the embedded frontend, check that after deletion `/login?reason=account-deleted` shows the notice once.
- Keyboard and focus: the dialog focus trap, the focus on the cleared password after 403, Tab order in the blocked dialog.
- MySQL and PostgreSQL via `task test:mysql` / `task test:postgres`.
+135
View File
@@ -0,0 +1,135 @@
Feature: Download a copy of my personal data as a ZIP of JSON files
As a verified RenewCMS user
I want one click on Profile to download the personal data RenewCMS holds about me
So that I can exercise my right of access and portability (GDPR art. 15 and 20) without asking an admin
Background:
Given the design of record is docs/design/screens/account.md "Your data", direct flow only, and the
board "RenewCMS Screens Account" group "Your data"
And the emailed-link variant, export-ready.html and /account/export/ are out of scope
And GET /v1/user/me/export is added to NewUserRouter behind RequireSession, Verifier, AuditContext,
RequireSignedIn, Authenticator, RequireCurrentToken and RequireVerifiedUser, so an API key gets
403 "This action needs a real session." and no cookie gets 401
And the route allows one successful export per user per 15 minutes; a second attempt answers 429
"Too many requests. Please try again later." with a Retry-After header in seconds; an export that
fails with a 500 does not consume the slot
And the success response is 200, Content-Type application/zip, Cache-Control no-store and
Content-Disposition attachment; filename="renewcms-export-<username>-<YYYY-MM-DD>.zip", with the
username reduced to [a-z0-9-] and the date in UTC from the use case's clock
And the ZIP holds exactly four UTF-8 JSON files, snake_case fields, pretty-printed:
| file | content |
| profile.json | id, username, display_name, email, pending_email, role {id, name}, is_verified, created_at, updated_at, author (the author profile or null), api_keys [{id, name, prefix, created_at, last_used_at, revoked_at}] |
| authored-content.json | articles [{id, title, body, slug, site_id, is_online, created_at, updated_at, authors}] and entries [{id, content_type, data, created_at, updated_at}] created by the user or credited to the user's author |
| uploaded-media.json | images [{id, filename, mime, size, hash, uploaded_at}] uploaded by the user: references only, no bytes |
| account-activity.json | events recorded with the user as actor: id, created_at, action, category, target, outcome, security, source, ip, user_agent, params |
And the ZIP never contains the password hash, any verification, reset or email-change code, the token
version, an API-key hash, or other people's data
And soft-deleted articles and images are excluded
And a use case (ExportUserData) reads through domain repositories (user, role, author, apikey, article,
entry, image, audit) and writes the archive with the stdlib; the handler streams the result; the
archive is built in memory first so a failure never sends a half file
And one audit event user.export_data is recorded on success (actor and target the user, category users)
And the layering rule holds and no comment is added anywhere
Rule: The endpoint
Background:
Given the user "marta" (id 7, verified) with 2 articles, 1 entry, 3 uploaded images and 5 audit events as actor
Scenario: A first export returns the archive
When marta sends GET /v1/user/me/export on 2026-10-29
Then the response is 200 with Content-Disposition attachment; filename="renewcms-export-marta-2026-10-29.zip"
And the ZIP has exactly the four files above
And profile.json has username "marta", email, role name and no key named password, code or token_version
And authored-content.json has 2 articles and 1 entry
And uploaded-media.json has 3 references and no file bytes
And account-activity.json has the 5 events plus nothing recorded for anonymous failed logins
And one audit event user.export_data is recorded
Scenario: A second export within 15 minutes is refused
Given marta exported 3 minutes ago
When she sends GET /v1/user/me/export
Then the response is 429 "Too many requests. Please try again later." with a numeric Retry-After of about 720
And no archive is built and no audit event is recorded
Scenario: The slot is per user and expires
Given marta exported 16 minutes ago, and ines exported 1 minute ago
Then marta's request succeeds and ines's request answers 429
Scenario: A failed export does not consume the slot
Given a repository fails while marta's data is being read
When she sends GET /v1/user/me/export
Then the response is 500 with a plain text body, no partial archive is sent and no audit event is recorded
When the failure is gone and she sends the request again
Then the response is 200
Scenario Outline: Only a real verified session may export
When <caller> sends GET /v1/user/me/export
Then the response is <status> with the body "<text>"
Examples:
| caller | status | text |
| nobody (no cookie) | 401 | You must be logged in to access this resource. |
| an API key bearer | 403 | This action needs a real session. |
| an unverified user | 403 | You must verify your account to access this resource. |
Scenario: Someone else's data is never included
Given ines has articles, entries, images and events
When marta exports
Then no record of ines appears in any file, including events where ines is the target of marta's action
Scenario: A user with a linked author profile
Given marta has an author profile credited on article 4 which marta did not create
When she exports
Then profile.json has the author profile and authored-content.json includes article 4
Rule: Your data card (SPA)
Background:
Given the Your data card follows the Account and Password cards, with the title "Your data", the text
"Download a copy of the personal data RenewCMS holds about you, as JSON files in a ZIP." and the
four content rows Profile, Authored content, Uploaded media, Account activity with the design's
one-line descriptions
And the download is a fetch of GET /v1/user/me/export read as a blob and saved with the filename
from Content-Disposition
And every new SPA string of the card (title, text, content rows, button, states, toasts and errors) is
added to the i18n catalogue (messages.ts, features/spa_internationalization.feature) in English and
in French under the same keys, with the design's English wording verbatim
And the French wording is an assumption: docs/design has no French copy, so the coder authors it and
flags it for the user's review
And the ZIP file names and JSON keys are not translated
And every state is a Vitest spec and a `task visual:check -- account` row, named as in the board
Scenario Outline: States
Given the user is on /profile
When <trigger>
Then the state "<state>" shows <copy>
Examples:
| state | trigger | copy |
| Direct (recommended): default | the page loads | "Download my data", "Ready in a few seconds." |
| Direct: preparing | "Download my data" is activated, GET unresolved | "Preparing download…", button disabled with a spinner |
| Direct: started | the GET answers 200 with the attachment | the browser download starts, then a toast "Download started" / "renewcms-export-marta-2026-10-29.zip is in your downloads."; the card returns to the default |
| Direct: rate limited (429) | the GET answers 429 with Retry-After | "You can export once every 15 minutes", "Your last export was at 09:41. Try again in 12 minutes.", "Available in 12:00" |
| Direct: error | the GET answers 500 or fails | "Couldn't prepare your data", "Nothing was changed. Try again in a moment.", button enabled to retry |
Scenario: The rate limited state is clock-sensitive and is skipped
Then web/visual/states.ts marks "Direct: rate limited (429)" with skip 'clock-sensitive', no visual
check exists for it, and no Vitest spec freezes the system date; the Vitest spec asserts only the
copy driven by a mocked Retry-After header
Scenario: The async states are out of scope and carry exception entries
Then the board states "Alternative, emailed link: default", "Alternative: requested", "Alternative: already in progress"
and "Alternative: error" are not built in the SPA
And web/visual/states.ts lists each of them with an exception entry so `task visual:check -- account`
does not fail on a missing setup
And each exception needs the user's explicit approval recorded as approvedBy
Rule: Documentation and privacy
Scenario: The docs describe the feature
Then docs/api.md documents GET /v1/user/me/export with the headers, the four files and the 429
And docs/privacy.md replaces "there is no self-service export yet" with the Profile download, lists
the four files, notes that anonymous failed-login events and soft-deleted rows are not included,
and removes the export half of the "No self-service export or email change" gap
+72
View File
@@ -0,0 +1,72 @@
# QA procedure: account_export_data
Package: `test` (black-box), extending `test/user_test.go` (`mockUserRepo`), the existing article, entry, image, author, api-key, role and audit fakes (reuse; do not duplicate type names), and `stubHasher`.
## Test infrastructure additions needed first
1. Fakes: add the list-by-creator and list-by-uploader methods to the article, entry and image fakes, and an actor-filtered list to the audit fake (honouring `audit.Filter.Actor`).
2. A `zipEntries(t, body []byte) map[string][]byte` helper that reads the archive with `archive/zip` and fails the test if there are directory entries or duplicate names.
3. A fixed clock for the use case (`func() time.Time`) so the filename date is deterministic.
## Setup shared by every case
4. Call `validator.InitValidator()` once.
5. Build `useCases.NewExportUserDataUseCase(...)` over the fakes. Mount `GET /v1/user/me/export` on a fresh `chi.NewRouter()` with the session chain of `NewUserRouter` and the per-user success-only limiter (one per 15 minutes). The limiter takes a clock, or the tests drive the window with two `httptest` calls and a short injected window for the expiry case.
6. Seed marta (id 7), ines (id 8), articles, entries, images, audit events and an author per the feature. No live server, real SMTP or real SQLite.
## Use-case checks
7. Run the use case for marta. Assert:
- Exactly four entries with the expected names.
- Each parses as JSON, with the field names of the feature.
- `profile.json` has no key containing `password`, `code`, `token` or `hash` (walk the decoded JSON recursively).
- The API-key rows have neither the hashed key nor the raw key.
8. Scope. Assert that ines's rows never appear. Assert article 4 (credited to marta's author, not created by her) is included. Assert that soft-deleted rows are excluded and that anonymous failed-login events are excluded.
9. Empty user. Assert four files, each with empty arrays (never null) and `author` null.
10. A failing fake repository gives a non-nil error and no archive bytes.
11. The audit event `user.export_data` is recorded only on success.
## HTTP checks
12. Success. Assert the status, `Content-Type: application/zip`, `Cache-Control: no-store`, and `Content-Disposition` equal to `attachment; filename="renewcms-export-marta-2026-10-29.zip"`. Also assert the archive opens.
13. A username with unusual characters gives a sanitised filename.
14. Rate limit. A second call within 15 minutes gives 429 with a numeric `Retry-After` (<= 900 and > 0) and the exact body "Too many requests. Please try again later.". Another user is unaffected. A call after the window gives 200.
15. A 500 does not consume the slot (fail the fake once, then succeed).
16. Auth gates: 401, API key 403 and unverified 403, with exact bodies.
## Vitest (SPA)
Strings live in `messages.ts` in English and French under the same keys.
I18n checks (extend the existing `web/src/i18n/*.spec.ts` style, e.g. `spa.spec.ts`):
- Key parity: every new key exists in both `en` and `fr`, with no empty value and the same placeholders (filename, minutes, countdown).
- French render: for each state with `setLocale('fr')`, assert the French text is shown and no English string remains; switching `en`, `fr`, `en` restores the English copy.
- English values equal the design copy verbatim.
Flagged assumption: the French wording is authored by the coder because docs/design has none. French strings to author, for the user's review: the card title and text, the four content rows (title and description), "Download my data", "Ready in a few seconds.", "Preparing download…", the "Download started" toast pair, the rate-limit title, text and "Available in {time}", and the error title and text.
| State | Mocked /v1 |
|---|---|
| Direct (recommended): default | none |
| Direct: preparing | `GET /user/me/export` pending |
| Direct: started | 200, `application/zip`, the Content-Disposition header; assert the toast text and that a download was triggered with that filename |
| Direct: rate limited (429) | 429 with Retry-After 720; assert the copy and the "Available in 12:00" label, no date mocking |
| Direct: error | 500 and a rejected fetch |
- Also assert that a second click while preparing sends no second request.
- Also assert that focus returns to the button after an error.
## Visual
`task visual:check -- account`. Board data to reuse: username marta, the filename `renewcms-export-marta-2026-10-29.zip` in the toast, and the Retry-After of 720.
- Checked states: "Direct (recommended): default", "Direct: preparing", "Direct: started", "Direct: error".
- `skip: clock-sensitive`: "Direct: rate limited (429)".
- Async states ("Alternative, emailed link: default", "Alternative: requested", "Alternative: already in progress", "Alternative: error"): the user accepted the recommendation to skip them in `web/visual/states.ts` through `exception` entries. The `approvedBy` field of each exception must still be recorded as the user at coder time; the coder must not invent an approver, and the entries are not valid until it is set.
## Manual verification
- Open a real downloaded ZIP and read all four files.
- Download in Firefox and Chromium and check the saved filename.
- Large account: time a user with thousands of events.
- The 15-minute window against a real clock and a restart.
- Keyboard: the button and the toast announce "Download started".
@@ -94,6 +94,7 @@ type Repository interface {
Get(id uint32) (Article, error) Get(id uint32) (Article, error)
GetByName(name string) (Article, error) GetByName(name string) (Article, error)
GetAll() ([]Article, error) GetAll() ([]Article, error)
ListForAccount(creatorID uint32, authorID *uint32) ([]Article, error)
Create(post Article) (Article, error) Create(post Article) (Article, error)
UpdateBody(id uint32, body string) (Article, error) UpdateBody(id uint32, body string) (Article, error)
Update(id uint32, update Update) (Article, error) Update(id uint32, update Update) (Article, error)
+7
View File
@@ -106,6 +106,12 @@ const (
ActionUserChangeRole = "user.change_role" ActionUserChangeRole = "user.change_role"
ActionUserTransferOwnership = "user.transfer_ownership" ActionUserTransferOwnership = "user.transfer_ownership"
ActionUserRemove = "user.remove" ActionUserRemove = "user.remove"
ActionUserRequestEmail = "user.request_email_change"
ActionUserResendEmail = "user.resend_email_change"
ActionUserCancelEmail = "user.cancel_email_change"
ActionUserConfirmEmail = "user.confirm_email_change"
ActionUserExportData = "user.export_data"
ActionUserDeleteAccount = "user.delete_account"
ActionInviteCreate = "invite.create" ActionInviteCreate = "invite.create"
ActionInviteCancel = "invite.cancel" ActionInviteCancel = "invite.cancel"
ActionInviteRenew = "invite.renew" ActionInviteRenew = "invite.renew"
@@ -132,6 +138,7 @@ const (
const ( const (
SystemActorName = "System" SystemActorName = "System"
ReasonWrongLogin = "Wrong username or password." ReasonWrongLogin = "Wrong username or password."
ReasonWrongPassword = "Wrong password."
MaxUserAgentLength = 255 MaxUserAgentLength = 255
ParamRemoved = "removed" ParamRemoved = "removed"
ParamFrom = "from" ParamFrom = "from"
+19
View File
@@ -0,0 +1,19 @@
package audit
import "strconv"
func (e Event) ConcernsOtherPerson(userID uint32, ownAuthorID *uint32) bool {
switch e.Target.Type {
case TargetUser:
return e.Target.ID != formatID(userID)
case TargetInvite:
return true
case TargetAuthor:
return ownAuthorID == nil || e.Target.ID != formatID(*ownAuthorID)
}
return false
}
func formatID(id uint32) string {
return strconv.FormatUint(uint64(id), 10)
}
@@ -9,6 +9,7 @@ type Repository interface {
DeleteOlderThan(cutoff time.Time) (int, error) DeleteOlderThan(cutoff time.Time) (int, error)
DeleteOldest(limit int, before time.Time) (int, error) DeleteOldest(limit int, before time.Time) (int, error)
SizeBytes() (int64, error) SizeBytes() (int64, error)
AnonymiseUser(userID uint32, ownAuthorID *uint32, name string) error
} }
type SettingsRepository interface { type SettingsRepository interface {
+29
View File
@@ -0,0 +1,29 @@
package author
import "strconv"
const DeletedName = "Deleted user"
const DeletedSlugPrefix = "deleted-user-"
func DeletedSlug(id uint32) string {
return DeletedSlugPrefix + strconv.FormatUint(uint64(id), 10)
}
func DeletedSlugAttempt(id uint32, attempt int) string {
if attempt <= 1 {
return DeletedSlug(id)
}
return DeletedSlug(id) + "-" + strconv.Itoa(attempt)
}
func IsReservedSlug(candidate string) bool {
return len(candidate) >= len(DeletedSlugPrefix) && candidate[:len(DeletedSlugPrefix)] == DeletedSlugPrefix
}
func avoidReserved(candidate string) string {
if IsReservedSlug(candidate) {
return FallbackSlug + "-" + candidate
}
return candidate
}
+3 -3
View File
@@ -112,7 +112,7 @@ type Page struct {
} }
func IsValidSlug(candidate string) bool { func IsValidSlug(candidate string) bool {
return slug.IsValid(candidate) return slug.IsValid(candidate) && !IsReservedSlug(candidate)
} }
func IsValidName(name string) bool { func IsValidName(name string) bool {
@@ -172,9 +172,9 @@ func ValidateLinks(links []SocialLink) error {
} }
func GenerateSlug(name string) string { func GenerateSlug(name string) string {
return slug.Generate(name, FallbackSlug) return avoidReserved(slug.Generate(name, FallbackSlug))
} }
func SlugWithSuffix(base string, attempt int) string { func SlugWithSuffix(base string, attempt int) string {
return slug.WithSuffix(base, attempt) return avoidReserved(slug.WithSuffix(base, attempt))
} }
@@ -64,6 +64,7 @@ type Repository interface {
Create(entry Entry) (Entry, error) Create(entry Entry) (Entry, error)
Get(contentTypeID uint32, id uint32) (Entry, error) Get(contentTypeID uint32, id uint32) (Entry, error)
ListPage(filters Filters) (PageResult, error) ListPage(filters Filters) (PageResult, error)
ListByCreator(creatorID uint32) ([]Entry, error)
Update(entry Entry) (Entry, error) Update(entry Entry) (Entry, error)
Delete(contentTypeID uint32, id uint32) error Delete(contentTypeID uint32, id uint32) error
DeleteByContentType(contentTypeID uint32) error DeleteByContentType(contentTypeID uint32) error
@@ -10,6 +10,7 @@ type Repository interface {
Get(id uint32) (Image, error) Get(id uint32) (Image, error)
Delete(id uint32) error Delete(id uint32) error
List(query SearchQuery) ([]Image, error) List(query SearchQuery) ([]Image, error)
ListByUploader(userID uint32) ([]Image, error)
FindByHash(hash string) ([]Image, error) FindByHash(hash string) ([]Image, error)
Rename(id uint32, filename string) (Image, error) Rename(id uint32, filename string) (Image, error)
ListDuplicates() ([]Image, error) ListDuplicates() ([]Image, error)
@@ -0,0 +1,5 @@
package mail
type Dispatcher interface {
Dispatch(job func())
}
+10
View File
@@ -22,9 +22,16 @@ var ErrPasswordTooLong = errors.New("password too long")
var ErrRoleChanged = errors.New("user role changed concurrently") var ErrRoleChanged = errors.New("user role changed concurrently")
var ErrInvalidTransfer = errors.New("invalid ownership transfer") var ErrInvalidTransfer = errors.New("invalid ownership transfer")
var ErrConfirmationMismatch = errors.New("confirmation email does not match") var ErrConfirmationMismatch = errors.New("confirmation email does not match")
var ErrWrongPassword = errors.New("wrong password")
var ErrSameEmail = errors.New("new email is the current email")
var ErrNoPendingEmailChange = errors.New("no pending email change")
var ErrInvalidEmailChangeCode = errors.New("invalid email change code")
var ErrEmailUnavailable = errors.New("email unavailable")
const MaxLookupIDs = 100 const MaxLookupIDs = 100
const EmailChangeLifetime = 2 * time.Hour
const MaxPasswordBytes = 72 const MaxPasswordBytes = 72
func ValidatePassword(password string) error { func ValidatePassword(password string) error {
@@ -47,6 +54,9 @@ type User struct {
VerificationCode string `json:"verification_code"` VerificationCode string `json:"verification_code"`
VerificationExpiration time.Time `json:"verification_expiration"` VerificationExpiration time.Time `json:"verification_expiration"`
PasswordResetExpiration time.Time `json:"password_reset_expiration"` PasswordResetExpiration time.Time `json:"password_reset_expiration"`
PendingEmail string `json:"pending_email"`
EmailChangeCode string `json:"email_change_code"`
EmailChangeExpiration time.Time `json:"email_change_expiration"`
TokenVersion uint32 `json:"token_version"` TokenVersion uint32 `json:"token_version"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
+42
View File
@@ -0,0 +1,42 @@
package user
import "errors"
const DeletedUserName = "Deleted user"
const (
LastRoleOwner = "Owner"
LastRoleAdmin = "Admin"
)
var ErrLastOwner = errors.New("last owner")
var ErrLastAdmin = errors.New("last administrator")
var ErrAccountNotDeletable = errors.New("account not deletable")
type LastAdminError struct {
Role string
}
func (e LastAdminError) Error() string {
if e.Role == LastRoleOwner {
return ErrLastOwner.Error()
}
return ErrLastAdmin.Error()
}
func (e LastAdminError) Is(target error) bool {
if target == ErrLastAdmin {
return true
}
return target == ErrLastOwner && e.Role == LastRoleOwner
}
type Erasure struct {
UserID uint32
GuardRoleIDs []uint32
AvatarImageID *uint32
}
type Eraser interface {
Erase(erasure Erasure) error
}
@@ -17,6 +17,10 @@ type Repository interface {
ClearVerificationCode(userId uint32) (User, error) ClearVerificationCode(userId uint32) (User, error)
ResetPassword(userId uint32, hashedPassword string) (User, error) ResetPassword(userId uint32, hashedPassword string) (User, error)
UpdateDisplayName(userId uint32, displayName string) (User, error) UpdateDisplayName(userId uint32, displayName string) (User, error)
IsEmailInUse(email string, exceptUserId uint32) (bool, error)
SetPendingEmailChange(userId uint32, pendingEmail string, code string, expiration time.Time) (User, error)
ClearPendingEmailChange(userId uint32) (User, error)
ConfirmEmailChange(userId uint32, pendingEmail string, code string, now time.Time) (User, error)
UpdateRole(id uint32, from uint32, to uint32, assignedBy uint32) (User, error) UpdateRole(id uint32, from uint32, to uint32, assignedBy uint32) (User, error)
TransferOwnership(from uint32, to uint32, ownerRoleID uint32, formerRoleID uint32) error TransferOwnership(from uint32, to uint32, ownerRoleID uint32, formerRoleID uint32) error
} }
@@ -0,0 +1,25 @@
package mailer
import (
domain "RenewCMS/internal/domain/mail"
"log"
)
type AsyncDispatcher struct{}
func NewAsyncDispatcher() *AsyncDispatcher {
return &AsyncDispatcher{}
}
func (a *AsyncDispatcher) Dispatch(job func()) {
go func() {
defer func() {
if recovered := recover(); recovered != nil {
log.Println("panic in a background mail job:", recovered)
}
}()
job()
}()
}
var _ domain.Dispatcher = &AsyncDispatcher{}
@@ -62,6 +62,12 @@ func subjectFor(templateName string, data any) string {
return "Verify your RenewCMS account" return "Verify your RenewCMS account"
case "password-reset": case "password-reset":
return "Reset your RenewCMS password" return "Reset your RenewCMS password"
case "verify-new-email":
return "Confirm your new RenewCMS email address"
case "email-change-notice":
return "Your RenewCMS email address change was requested"
case "account-deleted":
return "Your RenewCMS account has been deleted"
} }
return defaultSubject return defaultSubject
} }
@@ -0,0 +1,62 @@
<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<title>Your account has been deleted</title>
<style>
body { margin: 0; padding: 0; width: 100%; -webkit-text-size-adjust: 100%; }
table { border-collapse: collapse; }
a { color: #111111; }
@media (max-width: 620px) {
.rc-card { padding: 28px 20px !important; }
.rc-btn a { display: block !important; }
}
@media (prefers-color-scheme: dark) {
.rc-bg { background-color: #0a0a0a !important; }
.rc-card { background-color: #111111 !important; border-color: #333333 !important; }
.rc-h1, .rc-word { color: #fafafa !important; }
.rc-text { color: #e6e4e0 !important; }
.rc-muted { color: #999999 !important; }
.rc-mark { border: 1px solid #444444 !important; }
.rc-btn td { background-color: #fafafa !important; }
.rc-btn a { background-color: #fafafa !important; color: #111111 !important; border-color: #fafafa !important; }
.rc-url { color: #e6e4e0 !important; background-color: #1a1a1a !important; }
.rc-rule { border-color: #333333 !important; }
}
</style>
</head>
<body class="rc-bg" style="margin:0;padding:0;background-color:#fafafa;">
<div style="display:none;max-height:0;overflow:hidden;opacity:0;color:transparent;">Your RenewCMS account was deleted and your personal data removed.</div>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" class="rc-bg" style="background-color:#fafafa;">
<tr>
<td align="center" style="padding:40px 12px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="max-width:600px;">
<tr>
<td style="padding:0 0 20px 0;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
<tr>
<td class="rc-mark" width="32" height="32" align="center" valign="middle" bgcolor="#111111" style="width:32px;height:32px;background-color:#111111;color:#ffffff;font-family:Arial,Helvetica,sans-serif;font-size:19px;font-weight:bold;line-height:32px;">R</td>
<td class="rc-word" style="padding-left:10px;font-family:'IBM Plex Sans',Arial,Helvetica,sans-serif;font-size:17px;font-weight:600;color:#111111;">RenewCMS</td>
</tr>
</table>
</td>
</tr>
<tr>
<td class="rc-card" style="background-color:#ffffff;border:1px solid #e6e4e0;padding:40px;font-family:'IBM Plex Sans',Arial,Helvetica,sans-serif;">
<h1 class="rc-h1" style="margin:0 0 16px 0;font-size:24px;line-height:32px;font-weight:600;color:#111111;">Your account has been deleted</h1>
<p class="rc-text" style="margin:0 0 28px 0;font-size:16px;line-height:26px;color:#222222;">Your RenewCMS account at {{.Host}} has been deleted.</p>
<p class="rc-text rc-rule" style="margin:0;padding-top:0;border-top:0;font-size:14px;line-height:22px;color:#222222;">If you didn't ask for this, contact an administrator of {{.Host}}.</p>
</td>
</tr>
<tr>
<td class="rc-muted" style="padding:20px 0 0 0;font-family:'IBM Plex Sans',Arial,Helvetica,sans-serif;font-size:12px;line-height:18px;color:#666666;">RenewCMS · {{.Host}}</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
@@ -0,0 +1,69 @@
<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<title>Your email address change was requested</title>
<style>
body { margin: 0; padding: 0; width: 100%; -webkit-text-size-adjust: 100%; }
table { border-collapse: collapse; }
a { color: #111111; }
@media (max-width: 620px) {
.rc-card { padding: 28px 20px !important; }
.rc-btn a { display: block !important; }
}
@media (prefers-color-scheme: dark) {
.rc-bg { background-color: #0a0a0a !important; }
.rc-card { background-color: #111111 !important; border-color: #333333 !important; }
.rc-h1, .rc-word { color: #fafafa !important; }
.rc-text { color: #e6e4e0 !important; }
.rc-muted { color: #999999 !important; }
.rc-mark { border: 1px solid #444444 !important; }
.rc-btn td { background-color: #fafafa !important; }
.rc-btn a { background-color: #fafafa !important; color: #111111 !important; border-color: #fafafa !important; }
.rc-url { color: #e6e4e0 !important; background-color: #1a1a1a !important; }
.rc-rule { border-color: #333333 !important; }
}
</style>
</head>
<body class="rc-bg" style="margin:0;padding:0;background-color:#fafafa;">
<div style="display:none;max-height:0;overflow:hidden;opacity:0;color:transparent;">A change of email address was requested for your RenewCMS account. Nothing has changed yet.</div>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" class="rc-bg" style="background-color:#fafafa;">
<tr>
<td align="center" style="padding:40px 12px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="max-width:600px;">
<tr>
<td style="padding:0 0 20px 0;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
<tr>
<td class="rc-mark" width="32" height="32" align="center" valign="middle" bgcolor="#111111" style="width:32px;height:32px;background-color:#111111;color:#ffffff;font-family:Arial,Helvetica,sans-serif;font-size:19px;font-weight:bold;line-height:32px;">R</td>
<td class="rc-word" style="padding-left:10px;font-family:'IBM Plex Sans',Arial,Helvetica,sans-serif;font-size:17px;font-weight:600;color:#111111;">RenewCMS</td>
</tr>
</table>
</td>
</tr>
<tr>
<td class="rc-card" style="background-color:#ffffff;border:1px solid #e6e4e0;padding:40px;font-family:'IBM Plex Sans',Arial,Helvetica,sans-serif;">
<h1 class="rc-h1" style="margin:0 0 16px 0;font-size:24px;line-height:32px;font-weight:600;color:#111111;">Your email address change was requested</h1>
<p class="rc-text" style="margin:0 0 28px 0;font-size:16px;line-height:26px;color:#222222;">A request was made to change the email address of the RenewCMS account {{.Username}} at {{.Host}} from this address to {{.NewEmail}}. Nothing has changed yet: you keep logging in with this address until the new one is confirmed.</p>
<table role="presentation" cellpadding="0" cellspacing="0" border="0" class="rc-btn">
<tr>
<td bgcolor="#111111" style="background-color:#111111;">
<a href="{{.Host}}/profile" target="_blank" style="display:inline-block;padding:14px 24px;border:1px solid #111111;background-color:#111111;color:#ffffff;font-family:'IBM Plex Sans',Arial,Helvetica,sans-serif;font-size:16px;font-weight:600;line-height:20px;text-decoration:none;text-align:center;">Review my profile</a>
</td>
</tr>
</table>
<p class="rc-text rc-rule" style="margin:28px 0 0 0;padding-top:20px;border-top:1px solid #e6e4e0;font-size:14px;line-height:22px;color:#222222;">If this was you, no action is needed. If it wasn't, log in, cancel the change from your profile and change your password.</p>
</td>
</tr>
<tr>
<td class="rc-muted" style="padding:20px 0 0 0;font-family:'IBM Plex Sans',Arial,Helvetica,sans-serif;font-size:12px;line-height:18px;color:#666666;">RenewCMS · {{.Host}}</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
@@ -0,0 +1,72 @@
<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<title>Confirm your new email address</title>
<style>
body { margin: 0; padding: 0; width: 100%; -webkit-text-size-adjust: 100%; }
table { border-collapse: collapse; }
a { color: #111111; }
@media (max-width: 620px) {
.rc-card { padding: 28px 20px !important; }
.rc-btn a { display: block !important; }
}
@media (prefers-color-scheme: dark) {
.rc-bg { background-color: #0a0a0a !important; }
.rc-card { background-color: #111111 !important; border-color: #333333 !important; }
.rc-h1, .rc-word { color: #fafafa !important; }
.rc-text { color: #e6e4e0 !important; }
.rc-muted { color: #999999 !important; }
.rc-mark { border: 1px solid #444444 !important; }
.rc-btn td { background-color: #fafafa !important; }
.rc-btn a { background-color: #fafafa !important; color: #111111 !important; border-color: #fafafa !important; }
.rc-url { color: #e6e4e0 !important; background-color: #1a1a1a !important; }
.rc-rule { border-color: #333333 !important; }
}
</style>
</head>
<body class="rc-bg" style="margin:0;padding:0;background-color:#fafafa;">
<div style="display:none;max-height:0;overflow:hidden;opacity:0;color:transparent;">Confirm this address to finish changing your RenewCMS email. The link expires in 2 hours.</div>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" class="rc-bg" style="background-color:#fafafa;">
<tr>
<td align="center" style="padding:40px 12px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="max-width:600px;">
<tr>
<td style="padding:0 0 20px 0;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
<tr>
<td class="rc-mark" width="32" height="32" align="center" valign="middle" bgcolor="#111111" style="width:32px;height:32px;background-color:#111111;color:#ffffff;font-family:Arial,Helvetica,sans-serif;font-size:19px;font-weight:bold;line-height:32px;">R</td>
<td class="rc-word" style="padding-left:10px;font-family:'IBM Plex Sans',Arial,Helvetica,sans-serif;font-size:17px;font-weight:600;color:#111111;">RenewCMS</td>
</tr>
</table>
</td>
</tr>
<tr>
<td class="rc-card" style="background-color:#ffffff;border:1px solid #e6e4e0;padding:40px;font-family:'IBM Plex Sans',Arial,Helvetica,sans-serif;">
<h1 class="rc-h1" style="margin:0 0 16px 0;font-size:24px;line-height:32px;font-weight:600;color:#111111;">Confirm your new email address</h1>
<p class="rc-text" style="margin:0 0 28px 0;font-size:16px;line-height:26px;color:#222222;">Someone, hopefully you, asked to change the email address of the RenewCMS account {{.Username}} at {{.Host}} to this address. Confirm it to finish the change.</p>
<table role="presentation" cellpadding="0" cellspacing="0" border="0" class="rc-btn">
<tr>
<td bgcolor="#111111" style="background-color:#111111;">
<a href="{{.Host}}/account/email/confirm?c={{.VerificationCode}}" target="_blank" style="display:inline-block;padding:14px 24px;border:1px solid #111111;background-color:#111111;color:#ffffff;font-family:'IBM Plex Sans',Arial,Helvetica,sans-serif;font-size:16px;font-weight:600;line-height:20px;text-decoration:none;text-align:center;">Confirm new email address</a>
</td>
</tr>
</table>
<p class="rc-muted" style="margin:16px 0 0 0;font-size:14px;line-height:22px;color:#666666;">This link expires in 2 hours.</p>
<p class="rc-muted" style="margin:28px 0 8px 0;font-size:14px;line-height:22px;color:#666666;">If the button doesn't work, copy this address into your browser:</p>
<p class="rc-url" style="margin:0;padding:10px 12px;background-color:#f3f2f0;font-family:'IBM Plex Mono',Menlo,Consolas,monospace;font-size:13px;line-height:20px;color:#222222;word-break:break-all;">{{.Host}}/account/email/confirm?c={{.VerificationCode}}</p>
<p class="rc-text rc-rule" style="margin:28px 0 0 0;padding-top:20px;border-top:1px solid #e6e4e0;font-size:14px;line-height:22px;color:#222222;">If this wasn't you, ignore this email; nothing will change.</p>
</td>
</tr>
<tr>
<td class="rc-muted" style="padding:20px 0 0 0;font-family:'IBM Plex Sans',Arial,Helvetica,sans-serif;font-size:12px;line-height:18px;color:#666666;">RenewCMS · {{.Host}}</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
@@ -47,6 +47,9 @@ func UserToDomain(m models.User) user.User {
VerificationCode: nullableStringToDomain(m.VerificationCode), VerificationCode: nullableStringToDomain(m.VerificationCode),
VerificationExpiration: m.VerificationExpiration, VerificationExpiration: m.VerificationExpiration,
PasswordResetExpiration: m.PasswordResetExpiration, PasswordResetExpiration: m.PasswordResetExpiration,
PendingEmail: nullableStringToDomain(m.PendingEmail),
EmailChangeCode: nullableStringToDomain(m.EmailChangeCode),
EmailChangeExpiration: m.EmailChangeExpiration,
TokenVersion: m.TokenVersion, TokenVersion: m.TokenVersion,
CreatedAt: m.CreatedAt, CreatedAt: m.CreatedAt,
UpdatedAt: m.UpdatedAt, UpdatedAt: m.UpdatedAt,
@@ -66,6 +69,9 @@ func UserToModel(a user.User) models.User {
VerificationCode: DomainToNullableString(a.VerificationCode), VerificationCode: DomainToNullableString(a.VerificationCode),
VerificationExpiration: a.VerificationExpiration, VerificationExpiration: a.VerificationExpiration,
PasswordResetExpiration: a.PasswordResetExpiration, PasswordResetExpiration: a.PasswordResetExpiration,
PendingEmail: DomainToNullableString(a.PendingEmail),
EmailChangeCode: DomainToNullableString(a.EmailChangeCode),
EmailChangeExpiration: a.EmailChangeExpiration,
TokenVersion: a.TokenVersion, TokenVersion: a.TokenVersion,
CreatedAt: a.CreatedAt, CreatedAt: a.CreatedAt,
UpdatedAt: a.UpdatedAt, UpdatedAt: a.UpdatedAt,
@@ -10,8 +10,8 @@ type AuditEvent struct {
ActorType string `gorm:"not null;size:16;index:idx_audit_events_actor,priority:1"` ActorType string `gorm:"not null;size:16;index:idx_audit_events_actor,priority:1"`
ActorID *uint32 `gorm:"index:idx_audit_events_actor,priority:2"` ActorID *uint32 `gorm:"index:idx_audit_events_actor,priority:2"`
ActorName string `gorm:"not null;size:255"` ActorName string `gorm:"not null;size:255"`
TargetType string `gorm:"not null;size:32"` TargetType string `gorm:"not null;size:32;index:idx_audit_events_target,priority:1"`
TargetID string `gorm:"not null;size:64"` TargetID string `gorm:"not null;size:64;index:idx_audit_events_target,priority:2"`
TargetName string `gorm:"not null;size:255"` TargetName string `gorm:"not null;size:255"`
Outcome string `gorm:"not null;size:16"` Outcome string `gorm:"not null;size:16"`
FailureReason *string `gorm:"size:255"` FailureReason *string `gorm:"size:255"`
@@ -17,6 +17,9 @@ type User struct {
VerificationCode *string `gorm:"unique;size:255"` VerificationCode *string `gorm:"unique;size:255"`
VerificationExpiration time.Time VerificationExpiration time.Time
PasswordResetExpiration time.Time PasswordResetExpiration time.Time
PendingEmail *string `gorm:"size:255"`
EmailChangeCode *string `gorm:"size:255"`
EmailChangeExpiration time.Time
TokenVersion uint32 `gorm:"default:0;not null"` TokenVersion uint32 `gorm:"default:0;not null"`
CreatedAt time.Time `gorm:"autoCreateTime"` CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"` UpdatedAt time.Time `gorm:"autoUpdateTime"`
@@ -0,0 +1,145 @@
package persistence
import (
"RenewCMS/internal/domain/author"
domain "RenewCMS/internal/domain/user"
entity "RenewCMS/internal/infrastructure/persistence/models"
"strings"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
const (
emptySocialLinks = "[]"
sqliteDialect = "sqlite"
)
var contentionMarkers = []string{
"database is locked",
"sqlite_busy",
"deadlock",
"could not serialize",
}
type AccountEraser struct {
db *gorm.DB
}
var _ domain.Eraser = &AccountEraser{}
func NewAccountEraser(db *gorm.DB) *AccountEraser {
return &AccountEraser{db}
}
func (e *AccountEraser) Erase(erasure domain.Erasure) error {
err := e.db.Transaction(func(tx *gorm.DB) error {
if err := e.recheckHolders(tx, erasure); err != nil {
return err
}
if err := tx.Where("user_id = ?", erasure.UserID).Delete(&entity.APIKey{}).Error; err != nil {
return err
}
ownAuthorID, err := e.releaseAuthor(tx, erasure.UserID)
if err != nil {
return err
}
if erasure.AvatarImageID != nil {
if err := tx.Delete(&entity.Image{}, *erasure.AvatarImageID).Error; err != nil {
return err
}
}
if err := NewAuditEventRepository(tx).AnonymiseUser(erasure.UserID, ownAuthorID, domain.DeletedUserName); err != nil {
return err
}
result := tx.Delete(&entity.User{}, erasure.UserID)
return deleteOrNotFound(result, domain.ErrUserNotFound)
})
if err != nil && len(erasure.GuardRoleIDs) > 0 && isContention(err) {
return domain.ErrAccountNotDeletable
}
return err
}
func isContention(err error) bool {
message := strings.ToLower(err.Error())
for _, marker := range contentionMarkers {
if strings.Contains(message, marker) {
return true
}
}
return false
}
func (e *AccountEraser) recheckHolders(tx *gorm.DB, erasure domain.Erasure) error {
if len(erasure.GuardRoleIDs) == 0 {
return nil
}
if err := lockRoles(tx, erasure.GuardRoleIDs); err != nil {
return err
}
var holders []entity.User
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Select("id").
Where("role_id IN ? AND is_verified = ? AND id <> ?", erasure.GuardRoleIDs, true, erasure.UserID).
Order("id ASC").
Find(&holders).Error
if err != nil {
return err
}
if len(holders) == 0 {
return domain.ErrAccountNotDeletable
}
return nil
}
func lockRoles(tx *gorm.DB, roleIDs []uint32) error {
if tx.Dialector.Name() == sqliteDialect {
return tx.Model(&entity.Role{}).Where("id IN ?", roleIDs).UpdateColumn("id", gorm.Expr("id")).Error
}
var locked []entity.Role
return tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id IN ?", roleIDs).Order("id ASC").Find(&locked).Error
}
func (e *AccountEraser) releaseAuthor(tx *gorm.DB, userID uint32) (*uint32, error) {
var linked entity.Author
found := tx.Where("user_id = ?", userID).Limit(1).Find(&linked)
if found.Error != nil {
return nil, found.Error
}
if found.RowsAffected == 0 {
return nil, nil
}
ownID := linked.ID
var credits int64
if err := tx.Model(&entity.ArticleAuthor{}).Where("author_id = ?", linked.ID).Count(&credits).Error; err != nil {
return nil, err
}
if credits == 0 {
return &ownID, tx.Delete(&entity.Author{}, linked.ID).Error
}
slug, err := e.freeDeletedSlug(tx, linked.ID)
if err != nil {
return nil, err
}
return &ownID, tx.Model(&entity.Author{}).Where("id = ?", linked.ID).Updates(map[string]any{
"name": author.DeletedName,
"slug": slug,
"bio": "",
"avatar_id": nil,
"website": nil,
"social_links": emptySocialLinks,
"user_id": nil,
}).Error
}
func (e *AccountEraser) freeDeletedSlug(tx *gorm.DB, authorID uint32) (string, error) {
for attempt := 1; ; attempt++ {
candidate := author.DeletedSlugAttempt(authorID, attempt)
var taken int64
err := tx.Model(&entity.Author{}).Where("slug = ? AND id <> ?", candidate, authorID).Count(&taken).Error
if err != nil || taken == 0 {
return candidate, err
}
}
}
@@ -583,3 +583,18 @@ func (a *ArticleRepository) RepointImage(fromImageIDs []uint32, toImageID uint32
} }
var _ domain.Repository = &ArticleRepository{} var _ domain.Repository = &ArticleRepository{}
func (a *ArticleRepository) ListForAccount(creatorID uint32, authorID *uint32) ([]domain.Article, error) {
query := a.db.Model(&entity.Article{})
if authorID == nil {
query = query.Where("created_by = ?", creatorID)
} else {
credited := a.db.Model(&entity.ArticleAuthor{}).Select("article_id").Where("author_id = ?", *authorID)
query = query.Where("created_by = ? OR (is_online = ? AND id IN (?))", creatorID, true, credited)
}
var entityArticles []entity.Article
if err := query.Order("id asc").Find(&entityArticles).Error; err != nil {
return nil, err
}
return a.withAuthors(a.db, toDomainArticles(entityArticles))
}
@@ -5,6 +5,7 @@ import (
"RenewCMS/internal/infrastructure/persistence/mappers" "RenewCMS/internal/infrastructure/persistence/mappers"
entity "RenewCMS/internal/infrastructure/persistence/models" entity "RenewCMS/internal/infrastructure/persistence/models"
"slices" "slices"
"strconv"
"strings" "strings"
"time" "time"
@@ -233,3 +234,38 @@ func (a *AuditEventRepository) DeleteOldest(limit int, before time.Time) (int, e
} }
return removed, nil return removed, nil
} }
const (
erasedParams = "{}"
erasedChanges = "[]"
authorActions = "author.%"
)
func (a *AuditEventRepository) AnonymiseUser(userID uint32, ownAuthorID *uint32, name string) error {
userTargetID := strconv.FormatUint(uint64(userID), 10)
involved := "(actor_type = ? AND actor_id = ?) OR (target_type = ? AND target_id = ?)"
asActor := a.db.Model(&entity.AuditEvent{}).
Where("actor_type = ? AND actor_id = ?", string(audit.ActorUser), userID).
Updates(map[string]any{"actor_name": name, "ip": nil, "user_agent": nil})
if asActor.Error != nil {
return asActor.Error
}
asTarget := a.db.Model(&entity.AuditEvent{}).
Where("target_type = ? AND target_id = ?", audit.TargetUser, userTargetID).
Update("target_name", name)
if asTarget.Error != nil {
return asTarget.Error
}
if ownAuthorID != nil {
ownProfile := a.db.Model(&entity.AuditEvent{}).
Where("actor_type = ? AND actor_id = ? AND action LIKE ? AND target_type = ? AND target_id = ?",
string(audit.ActorUser), userID, authorActions, audit.TargetAuthor, strconv.FormatUint(uint64(*ownAuthorID), 10)).
Update("target_name", name)
if ownProfile.Error != nil {
return ownProfile.Error
}
}
return a.db.Model(&entity.AuditEvent{}).
Where(involved, string(audit.ActorUser), userID, audit.TargetUser, userTargetID).
Updates(map[string]any{"params": erasedParams, "changes": erasedChanges}).Error
}
@@ -154,3 +154,11 @@ func entriesToDomain(models []entity.Entry) ([]domain.Entry, error) {
} }
return items, nil return items, nil
} }
func (e *EntryRepository) ListByCreator(creatorID uint32) ([]domain.Entry, error) {
var models []entity.Entry
if err := e.db.Where("created_by = ?", creatorID).Order("id asc").Find(&models).Error; err != nil {
return nil, err
}
return entriesToDomain(models)
}
@@ -160,3 +160,11 @@ func (i ImageRepository) UpdateMetadata(id uint32, metadata domain.Metadata) err
} }
var _ domain.Repository = &ImageRepository{} var _ domain.Repository = &ImageRepository{}
func (i ImageRepository) ListByUploader(userID uint32) ([]domain.Image, error) {
var models []entity.Image
if err := i.db.Where("uploaded_by_id = ?", userID).Order("id asc").Find(&models).Error; err != nil {
return nil, err
}
return imagesToDomain(models), nil
}
@@ -242,6 +242,80 @@ func (u *UserRepository) UpdateDisplayName(userId uint32, displayName string) (d
return mappers.UserToDomain(localUser), nil return mappers.UserToDomain(localUser), nil
} }
func (u *UserRepository) IsEmailInUse(email string, exceptUserId uint32) (bool, error) {
return isEmailInUse(u.db, email, exceptUserId)
}
func isEmailInUse(db *gorm.DB, email string, exceptUserId uint32) (bool, error) {
var holders int64
err := db.Model(&entity.User{}).Where("LOWER(email) = LOWER(?) AND id <> ?", email, exceptUserId).Count(&holders).Error
return holders > 0, err
}
func (u *UserRepository) SetPendingEmailChange(userId uint32, pendingEmail string, code string, expiration time.Time) (domainUser.User, error) {
return u.updatePendingEmailChange(userId, map[string]any{
"pending_email": mappers.DomainToNullableString(pendingEmail),
"email_change_code": mappers.DomainToNullableString(code),
"email_change_expiration": expiration,
})
}
func (u *UserRepository) ClearPendingEmailChange(userId uint32) (domainUser.User, error) {
return u.updatePendingEmailChange(userId, clearedEmailChange())
}
func clearedEmailChange() map[string]any {
return map[string]any{
"pending_email": nil,
"email_change_code": nil,
"email_change_expiration": time.Time{},
}
}
func (u *UserRepository) updatePendingEmailChange(userId uint32, columns map[string]any) (domainUser.User, error) {
result := u.db.Model(&entity.User{}).Where("id = ?", userId).Updates(columns)
if result.Error != nil {
return domainUser.User{}, result.Error
}
return u.Get(userId)
}
func (u *UserRepository) ConfirmEmailChange(userId uint32, pendingEmail string, code string, now time.Time) (domainUser.User, error) {
taken, err := isEmailInUse(u.db, pendingEmail, userId)
if err != nil {
return domainUser.User{}, err
}
columns := clearedEmailChange()
if !taken {
columns["email"] = pendingEmail
}
result := pendingChangeOf(u.db, userId, pendingEmail, code).Where("email_change_expiration > ?", now).Updates(columns)
if result.Error != nil && isUniqueViolation(result.Error) {
return domainUser.User{}, u.refuseTakenAddress(userId, pendingEmail, code)
}
if result.Error != nil {
return domainUser.User{}, result.Error
}
if result.RowsAffected == 0 {
return domainUser.User{}, domainUser.ErrInvalidEmailChangeCode
}
if taken {
return domainUser.User{}, domainUser.ErrEmailUnavailable
}
return u.Get(userId)
}
func (u *UserRepository) refuseTakenAddress(userId uint32, pendingEmail string, code string) error {
if err := pendingChangeOf(u.db, userId, pendingEmail, code).Updates(clearedEmailChange()).Error; err != nil {
return err
}
return domainUser.ErrEmailUnavailable
}
func pendingChangeOf(db *gorm.DB, userId uint32, pendingEmail string, code string) *gorm.DB {
return db.Model(&entity.User{}).Where("id = ? AND pending_email = ? AND email_change_code = ?", userId, pendingEmail, code)
}
func (u *UserRepository) UpdateRole(id uint32, from uint32, to uint32, assignedBy uint32) (domainUser.User, error) { func (u *UserRepository) UpdateRole(id uint32, from uint32, to uint32, assignedBy uint32) (domainUser.User, error) {
err := u.db.Transaction(func(tx *gorm.DB) error { err := u.db.Transaction(func(tx *gorm.DB) error {
query := tx.Model(&entity.User{}).Where("id = ? AND EXISTS (SELECT 1 FROM roles WHERE id = ?)", id, to) query := tx.Model(&entity.User{}).Where("id = ? AND EXISTS (SELECT 1 FROM roles WHERE id = ?)", id, to)
@@ -0,0 +1,34 @@
package useCases
import (
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/user"
"context"
)
type CancelEmailChangeUseCase struct {
userRepository user.Repository
recorder audit.Recorder
}
func NewCancelEmailChangeUseCase(userRepository user.Repository, recorder audit.Recorder) *CancelEmailChangeUseCase {
return &CancelEmailChangeUseCase{userRepository, recorder}
}
func (u *CancelEmailChangeUseCase) CancelEmailChange(ctx context.Context, userId uint32) error {
account, err := u.userRepository.Get(userId)
if err != nil {
return err
}
if account.PendingEmail == "" {
return nil
}
event := audit.NewEvent(audit.ActionUserCancelEmail, audit.CategoryUsers)
event.Target = userTarget(account)
if _, err := u.userRepository.ClearPendingEmailChange(userId); err != nil {
recordFailure(ctx, u.recorder, event, err)
return err
}
u.recorder.Record(ctx, event)
return nil
}
@@ -0,0 +1,52 @@
package useCases
import (
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/hash"
"RenewCMS/internal/domain/user"
"context"
"time"
)
type ConfirmEmailChangeUseCase struct {
userRepository user.Repository
hasher hash.Hasher
now func() time.Time
recorder audit.Recorder
}
func NewConfirmEmailChangeUseCase(userRepository user.Repository, hasher hash.Hasher, now func() time.Time, recorder audit.Recorder) *ConfirmEmailChangeUseCase {
return &ConfirmEmailChangeUseCase{userRepository, hasher, now, recorder}
}
func (u *ConfirmEmailChangeUseCase) ConfirmEmailChange(ctx context.Context, userId uint32, code string) (string, error) {
account, err := u.userRepository.Get(userId)
if err != nil {
return "", err
}
event := audit.NewEvent(audit.ActionUserConfirmEmail, audit.CategoryUsers)
event.Target = userTarget(account)
event.Security = true
if !u.accepts(account, code) {
recordFailure(ctx, u.recorder, event, user.ErrInvalidEmailChangeCode)
return "", user.ErrInvalidEmailChangeCode
}
updated, err := u.userRepository.ConfirmEmailChange(userId, account.PendingEmail, account.EmailChangeCode, u.now())
if err != nil {
recordFailure(ctx, u.recorder, event, err)
return "", err
}
u.recorder.Record(ctx, event)
return updated.Email, nil
}
func (u *ConfirmEmailChangeUseCase) accepts(account user.User, code string) bool {
if account.PendingEmail == "" || account.EmailChangeCode == "" {
return false
}
if u.now().After(account.EmailChangeExpiration) {
return false
}
return u.hasher.Compare(account.EmailChangeCode, code) == nil
}
@@ -0,0 +1,199 @@
package useCases
import (
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/hash"
"RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/mail"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/user"
"context"
"errors"
"log"
"slices"
"time"
)
const accountDeletedTemplateName = "account-deleted"
type MailAccountDeletedData struct {
Host string
}
type sessionRevoker interface {
Revoke(id string, expiresAt time.Time) error
}
type DeleteOwnAccountUseCase struct {
users user.Repository
roles role.Repository
hasher hash.Hasher
eraser user.Eraser
sessions sessionRevoker
mailRepository mail.Repository
dispatcher mail.Dispatcher
publicURL string
recorder audit.Recorder
authors author.Repository
images image.Repository
usage *GetMediaUsageUseCase
avatars mediaRemover
}
type DeleteOwnAccountCommand struct {
UserID uint32
CurrentPassword string
SessionID string
SessionExpiresAt time.Time
}
func NewDeleteOwnAccountUseCase(users user.Repository, roles role.Repository, hasher hash.Hasher, eraser user.Eraser, sessions sessionRevoker, mailRepository mail.Repository, dispatcher mail.Dispatcher, publicURL string, recorder audit.Recorder, authors author.Repository, images image.Repository, usage *GetMediaUsageUseCase, storage image.Storage, variantFiles image.VariantFileRepository) *DeleteOwnAccountUseCase {
return &DeleteOwnAccountUseCase{users, roles, hasher, eraser, sessions, mailRepository, dispatcher, publicURL, recorder, authors, images, usage, newMediaRemover(images, storage, variantFiles)}
}
func (u *DeleteOwnAccountUseCase) Delete(ctx context.Context, command DeleteOwnAccountCommand) error {
account, err := u.users.Get(command.UserID)
if err != nil {
return err
}
event := audit.NewEvent(audit.ActionUserDeleteAccount, audit.CategoryUsers)
event.Target = userTarget(account)
event.Security = true
guard, err := u.check(account, command)
if err != nil {
recordFailure(ctx, u.recorder, event, err)
return err
}
avatar, err := u.removableAvatar(account)
if err != nil {
recordFailure(ctx, u.recorder, event, err)
return err
}
erasure := user.Erasure{UserID: account.ID, GuardRoleIDs: guard}
if avatar != nil {
erasure.AvatarImageID = &avatar.ID
}
if err := u.eraser.Erase(erasure); err != nil {
recordFailure(ctx, u.recorder, event, err)
return err
}
u.discardAvatar(avatar)
u.revoke(command)
u.notify(account)
u.recordSuccess(ctx, account)
return nil
}
func (u *DeleteOwnAccountUseCase) check(account user.User, command DeleteOwnAccountCommand) ([]uint32, error) {
if u.hasher.Compare(account.Password, command.CurrentPassword) != nil {
return nil, user.ErrWrongPassword
}
guard, label, err := u.guardedRoles(account)
if err != nil || len(guard) == 0 {
return nil, err
}
hasOther, err := u.hasOtherHolder(account.ID, guard)
if err != nil {
return nil, err
}
if !hasOther {
return nil, user.LastAdminError{Role: label}
}
return guard, nil
}
func (u *DeleteOwnAccountUseCase) guardedRoles(account user.User) ([]uint32, string, error) {
owner, err := builtInRole(u.roles, role.OwnerName)
if err != nil {
return nil, "", err
}
if account.RoleID == owner.ID {
return []uint32{owner.ID}, user.LastRoleOwner, nil
}
admin, err := builtInRole(u.roles, role.AdminName)
if err != nil {
return nil, "", err
}
if account.RoleID == admin.ID {
return []uint32{owner.ID, admin.ID}, user.LastRoleAdmin, nil
}
return nil, "", nil
}
func (u *DeleteOwnAccountUseCase) hasOtherHolder(exceptUserID uint32, roleIDs []uint32) (bool, error) {
everyone, err := u.users.GetAll()
if err != nil {
return false, err
}
for _, candidate := range everyone {
if candidate.ID != exceptUserID && candidate.IsVerified && slices.Contains(roleIDs, candidate.RoleID) {
return true, nil
}
}
return false, nil
}
func (u *DeleteOwnAccountUseCase) removableAvatar(account user.User) (*image.Image, error) {
own, err := u.authors.GetByUserID(account.ID)
if errors.Is(err, author.ErrAuthorNotFound) || err == nil && own.AvatarID == nil {
return nil, nil
}
if err != nil {
return nil, err
}
avatar, err := u.images.Get(*own.AvatarID)
if errors.Is(err, image.ErrImageNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
usages, err := u.usage.Usage(avatar.ID)
if err != nil {
return nil, err
}
for _, found := range usages {
if found.Type != UsageTypeAuthor || found.ID != own.ID {
return nil, nil
}
}
return &avatar, nil
}
func (u *DeleteOwnAccountUseCase) discardAvatar(avatar *image.Image) {
if avatar == nil {
return
}
if err := u.avatars.discardFiles(*avatar); err != nil {
log.Println("error when deleting the stored avatar of a deleted account:", err)
}
}
func (u *DeleteOwnAccountUseCase) revoke(command DeleteOwnAccountCommand) {
if command.SessionID == "" {
return
}
if err := u.sessions.Revoke(command.SessionID, command.SessionExpiresAt); err != nil {
log.Println("error when revoking the session of a deleted account:", err)
}
}
func (u *DeleteOwnAccountUseCase) notify(account user.User) {
address := account.Email
u.dispatcher.Dispatch(func() {
if err := u.mailRepository.Send(address, accountDeletedTemplateName, MailAccountDeletedData{Host: u.publicURL}); err != nil {
log.Println("error when Send-ing the account deleted mail:", err)
}
})
}
func (u *DeleteOwnAccountUseCase) recordSuccess(ctx context.Context, account user.User) {
event := audit.NewEvent(audit.ActionUserDeleteAccount, audit.CategoryUsers)
id := account.ID
event.Actor = audit.Actor{Type: audit.ActorUser, ID: &id, Name: user.DeletedUserName}
event.Target = audit.NewTarget(audit.TargetUser, account.ID, user.DeletedUserName)
event.Security = true
u.recorder.Record(ctx, event)
}
@@ -0,0 +1,356 @@
package useCases
import (
"RenewCMS/internal/domain/apikey"
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/contenttype"
"RenewCMS/internal/domain/entry"
"RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/user"
"archive/zip"
"bytes"
"context"
"encoding/json"
"errors"
"strconv"
"strings"
"time"
)
const (
exportProfileFile = "profile.json"
exportAuthoredFile = "authored-content.json"
exportMediaFile = "uploaded-media.json"
exportActivityFile = "account-activity.json"
exportIndent = " "
exportFilenameDate = "2006-01-02"
exportFallbackName = "user"
)
type ExportUserDataUseCase struct {
users user.Repository
roles role.Repository
authors author.Repository
keys apikey.Repository
articles article.Repository
entries entry.Repository
contentTypes contenttype.Repository
images image.Repository
events audit.Repository
now func() time.Time
recorder audit.Recorder
slots chan struct{}
}
type UserDataArchive struct {
Filename string
Content []byte
}
type exportRole struct {
ID uint32 `json:"id"`
Name string `json:"name"`
}
type exportAPIKey struct {
ID uint32 `json:"id"`
Name string `json:"name"`
Prefix string `json:"prefix"`
CreatedAt time.Time `json:"created_at"`
LastUsedAt *time.Time `json:"last_used_at"`
RevokedAt *time.Time `json:"revoked_at"`
}
type exportProfile struct {
ID uint32 `json:"id"`
Username string `json:"username"`
DisplayName string `json:"display_name"`
Email string `json:"email"`
PendingEmail string `json:"pending_email"`
Role exportRole `json:"role"`
IsVerified bool `json:"is_verified"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Author *author.Byline `json:"author"`
APIKeys []exportAPIKey `json:"api_keys"`
}
type exportArticle struct {
ID uint32 `json:"id"`
Title string `json:"title"`
Body string `json:"body"`
Slug string `json:"slug"`
SiteID uint32 `json:"site_id"`
IsOnline bool `json:"is_online"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Authors []exportCredit `json:"authors"`
}
type exportCredit struct {
ID uint32 `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
}
type exportEntry struct {
ID uint32 `json:"id"`
ContentType string `json:"content_type"`
Data map[string]any `json:"data"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type exportAuthored struct {
Articles []exportArticle `json:"articles"`
Entries []exportEntry `json:"entries"`
}
type exportImage struct {
ID uint32 `json:"id"`
Filename string `json:"filename"`
Mime string `json:"mime"`
Size int64 `json:"size"`
Hash string `json:"hash"`
UploadedAt time.Time `json:"uploaded_at"`
}
type exportMedia struct {
Images []exportImage `json:"images"`
}
type exportEvent struct {
ID uint64 `json:"id"`
CreatedAt time.Time `json:"created_at"`
Action string `json:"action"`
Category audit.Category `json:"category"`
Target audit.Target `json:"target"`
Outcome audit.Outcome `json:"outcome"`
Security bool `json:"security"`
Source audit.Source `json:"source"`
IP *string `json:"ip"`
UserAgent *string `json:"user_agent"`
Params map[string]any `json:"params"`
}
type exportActivity struct {
Events []exportEvent `json:"events"`
}
func NewExportUserDataUseCase(users user.Repository, roles role.Repository, authors author.Repository, keys apikey.Repository, articles article.Repository, entries entry.Repository, contentTypes contenttype.Repository, images image.Repository, events audit.Repository, now func() time.Time, recorder audit.Recorder, maxConcurrent int) *ExportUserDataUseCase {
return &ExportUserDataUseCase{users, roles, authors, keys, articles, entries, contentTypes, images, events, now, recorder, make(chan struct{}, max(1, maxConcurrent))}
}
func (u *ExportUserDataUseCase) Export(ctx context.Context, userID uint32) (UserDataArchive, error) {
select {
case u.slots <- struct{}{}:
defer func() { <-u.slots }()
case <-ctx.Done():
return UserDataArchive{}, ctx.Err()
}
account, err := u.users.Get(userID)
if err != nil {
return UserDataArchive{}, err
}
profile, linked, err := u.profile(account)
if err != nil {
return UserDataArchive{}, err
}
authored, err := u.authored(account, linked)
if err != nil {
return UserDataArchive{}, err
}
media, err := u.media(account)
if err != nil {
return UserDataArchive{}, err
}
activity, err := u.activity(account, linked)
if err != nil {
return UserDataArchive{}, err
}
content, err := zipJSON([]namedDocument{
{exportProfileFile, profile},
{exportAuthoredFile, authored},
{exportMediaFile, media},
{exportActivityFile, activity},
})
if err != nil {
return UserDataArchive{}, err
}
event := audit.NewEvent(audit.ActionUserExportData, audit.CategoryUsers)
event.Target = userTarget(account)
u.recorder.Record(ctx, event)
return UserDataArchive{Filename: u.filename(account), Content: content}, nil
}
func (u *ExportUserDataUseCase) filename(account user.User) string {
return "renewcms-export-" + exportUsername(account.Username) + "-" + u.now().UTC().Format(exportFilenameDate) + ".zip"
}
func exportUsername(username string) string {
var reduced strings.Builder
for _, char := range strings.ToLower(username) {
if char >= 'a' && char <= 'z' || char >= '0' && char <= '9' || char == '-' {
reduced.WriteRune(char)
} else if !strings.HasSuffix(reduced.String(), "-") {
reduced.WriteRune('-')
}
}
name := strings.Trim(reduced.String(), "-")
if name == "" {
return exportFallbackName
}
return name
}
func (u *ExportUserDataUseCase) profile(account user.User) (exportProfile, *author.Author, error) {
assigned, err := u.roles.Get(account.RoleID)
if err != nil {
return exportProfile{}, nil, err
}
linked, err := u.linkedAuthor(account.ID)
if err != nil {
return exportProfile{}, nil, err
}
keys, err := u.keys.ListByUser(account.ID)
if err != nil {
return exportProfile{}, nil, err
}
profile := exportProfile{
ID: account.ID, Username: account.Username, DisplayName: account.DisplayName, Email: account.Email,
PendingEmail: account.PendingEmail, Role: exportRole{ID: assigned.ID, Name: assigned.Name},
IsVerified: account.IsVerified, CreatedAt: account.CreatedAt, UpdatedAt: account.UpdatedAt,
APIKeys: make([]exportAPIKey, 0, len(keys)),
}
if linked != nil {
byline := linked.Byline()
profile.Author = &byline
}
for _, key := range keys {
profile.APIKeys = append(profile.APIKeys, exportAPIKey{ID: key.ID, Name: key.Name, Prefix: key.Prefix, CreatedAt: key.CreatedAt, LastUsedAt: key.LastUsedAt, RevokedAt: key.RevokedAt})
}
return profile, linked, nil
}
func (u *ExportUserDataUseCase) linkedAuthor(userID uint32) (*author.Author, error) {
linked, err := u.authors.GetByUserID(userID)
if errors.Is(err, author.ErrAuthorNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &linked, nil
}
func (u *ExportUserDataUseCase) authored(account user.User, linked *author.Author) (exportAuthored, error) {
var authorID *uint32
if linked != nil {
authorID = &linked.ID
}
articles, err := u.articles.ListForAccount(account.ID, authorID)
if err != nil {
return exportAuthored{}, err
}
entries, err := u.entries.ListByCreator(account.ID)
if err != nil {
return exportAuthored{}, err
}
keys, err := u.contentTypeKeys()
if err != nil {
return exportAuthored{}, err
}
authored := exportAuthored{Articles: make([]exportArticle, 0, len(articles)), Entries: make([]exportEntry, 0, len(entries))}
for _, item := range articles {
credits := make([]exportCredit, 0, len(item.Authors))
for _, byline := range item.Authors {
credits = append(credits, exportCredit{byline.ID, byline.Name, byline.Slug})
}
authored.Articles = append(authored.Articles, exportArticle{item.ID, item.Title, item.Body, item.Slug, item.SiteID, item.IsOnline, item.CreatedAt, item.UpdatedAt, credits})
}
for _, item := range entries {
key, known := keys[item.ContentTypeID]
if !known {
key = strconv.FormatUint(uint64(item.ContentTypeID), 10)
}
authored.Entries = append(authored.Entries, exportEntry{item.ID, key, item.Data, item.CreatedAt, item.UpdatedAt})
}
return authored, nil
}
func (u *ExportUserDataUseCase) contentTypeKeys() (map[uint32]string, error) {
types, err := u.contentTypes.List()
if err != nil {
return nil, err
}
keys := make(map[uint32]string, len(types))
for _, contentType := range types {
keys[contentType.ID] = contentType.Key
}
return keys, nil
}
func (u *ExportUserDataUseCase) media(account user.User) (exportMedia, error) {
images, err := u.images.ListByUploader(account.ID)
if err != nil {
return exportMedia{}, err
}
media := exportMedia{Images: make([]exportImage, 0, len(images))}
for _, item := range images {
media.Images = append(media.Images, exportImage{item.ID, item.Filename, item.ContentType, item.Size, item.SHA256, item.CreatedAt})
}
return media, nil
}
func (u *ExportUserDataUseCase) activity(account user.User, linked *author.Author) (exportActivity, error) {
id := account.ID
var ownAuthorID *uint32
if linked != nil {
ownAuthorID = &linked.ID
}
page, err := u.events.List(audit.Filter{Actor: &audit.ActorRef{Type: audit.ActorUser, ID: &id}})
if err != nil {
return exportActivity{}, err
}
activity := exportActivity{Events: make([]exportEvent, 0, len(page.Items))}
for _, item := range page.Items {
if item.ConcernsOtherPerson(account.ID, ownAuthorID) {
continue
}
params := item.Params
if params == nil {
params = map[string]any{}
}
activity.Events = append(activity.Events, exportEvent{item.ID, item.CreatedAt, item.Action, item.Category, item.Target, item.Outcome, item.Security, item.Source, item.IP, item.UserAgent, params})
}
return activity, nil
}
type namedDocument struct {
name string
value any
}
func zipJSON(documents []namedDocument) ([]byte, error) {
var buffer bytes.Buffer
archive := zip.NewWriter(&buffer)
for _, document := range documents {
file, err := archive.Create(document.name)
if err != nil {
return nil, err
}
encoder := json.NewEncoder(file)
encoder.SetIndent("", exportIndent)
if err := encoder.Encode(document.value); err != nil {
return nil, err
}
}
if err := archive.Close(); err != nil {
return nil, err
}
return buffer.Bytes(), nil
}
@@ -0,0 +1,60 @@
package useCases
import (
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/hash"
"RenewCMS/internal/domain/mail"
"RenewCMS/internal/domain/user"
"context"
"strings"
"time"
)
type RequestEmailChangeUseCase struct {
userRepository user.Repository
hasher hash.Hasher
issuer emailChangeIssuer
recorder audit.Recorder
}
type RequestEmailChangeCommand struct {
UserID uint32
NewEmail string
CurrentPassword string
}
func NewRequestEmailChangeUseCase(userRepository user.Repository, hasher hash.Hasher, mailRepository mail.Repository, dispatcher mail.Dispatcher, publicURL string, now func() time.Time, recorder audit.Recorder) *RequestEmailChangeUseCase {
return &RequestEmailChangeUseCase{userRepository, hasher, emailChangeIssuer{userRepository, hasher, mailRepository, dispatcher, publicURL, now}, recorder}
}
func (u *RequestEmailChangeUseCase) RequestEmailChange(ctx context.Context, command RequestEmailChangeCommand) (string, error) {
account, err := u.userRepository.Get(command.UserID)
if err != nil {
return "", err
}
event := audit.NewEvent(audit.ActionUserRequestEmail, audit.CategoryUsers)
event.Target = userTarget(account)
event.Security = true
if err := u.refuse(account, command); err != nil {
recordFailure(ctx, u.recorder, event, err)
return "", err
}
if _, err := u.issuer.issue(account, command.NewEmail); err != nil {
recordFailure(ctx, u.recorder, event, err)
return "", err
}
u.issuer.sendNotice(account, command.NewEmail)
u.recorder.Record(ctx, event)
return command.NewEmail, nil
}
func (u *RequestEmailChangeUseCase) refuse(account user.User, command RequestEmailChangeCommand) error {
if u.hasher.Compare(account.Password, command.CurrentPassword) != nil {
return user.ErrWrongPassword
}
if strings.EqualFold(strings.TrimSpace(command.NewEmail), account.Email) {
return user.ErrSameEmail
}
return nil
}
@@ -0,0 +1,41 @@
package useCases
import (
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/hash"
"RenewCMS/internal/domain/mail"
"RenewCMS/internal/domain/user"
"context"
"time"
)
type ResendEmailChangeUseCase struct {
userRepository user.Repository
issuer emailChangeIssuer
recorder audit.Recorder
}
func NewResendEmailChangeUseCase(userRepository user.Repository, hasher hash.Hasher, mailRepository mail.Repository, dispatcher mail.Dispatcher, publicURL string, now func() time.Time, recorder audit.Recorder) *ResendEmailChangeUseCase {
return &ResendEmailChangeUseCase{userRepository, emailChangeIssuer{userRepository, hasher, mailRepository, dispatcher, publicURL, now}, recorder}
}
func (u *ResendEmailChangeUseCase) ResendEmailChange(ctx context.Context, userId uint32) (string, error) {
account, err := u.userRepository.Get(userId)
if err != nil {
return "", err
}
event := audit.NewEvent(audit.ActionUserResendEmail, audit.CategoryUsers)
event.Target = userTarget(account)
event.Security = true
if account.PendingEmail == "" {
recordFailure(ctx, u.recorder, event, user.ErrNoPendingEmailChange)
return "", user.ErrNoPendingEmailChange
}
if _, err := u.issuer.issue(account, account.PendingEmail); err != nil {
recordFailure(ctx, u.recorder, event, err)
return "", err
}
u.recorder.Record(ctx, event)
return account.PendingEmail, nil
}
@@ -44,6 +44,14 @@ var failureReasons = []failureReason{
{user.ErrVerificationCodeExpired, "The verification code has expired."}, {user.ErrVerificationCodeExpired, "The verification code has expired."},
{user.ErrInvalidDisplayName, "That display name is too long."}, {user.ErrInvalidDisplayName, "That display name is too long."},
{user.ErrUsernameTaken, "That username is already taken."}, {user.ErrUsernameTaken, "That username is already taken."},
{user.ErrWrongPassword, audit.ReasonWrongPassword},
{user.ErrLastOwner, "Last Owner."},
{user.ErrLastAdmin, "Last administrator."},
{user.ErrAccountNotDeletable, "The account can't be deleted right now."},
{user.ErrSameEmail, "The new address is already the account's email address."},
{user.ErrNoPendingEmailChange, "No email change is pending."},
{user.ErrInvalidEmailChangeCode, "The email change link is not valid."},
{user.ErrEmailUnavailable, "The new address can't be used for this account."},
{user.ErrPasswordTooLong, "The password is longer than 72 bytes."}, {user.ErrPasswordTooLong, "The password is longer than 72 bytes."},
{invite.ErrEmailHasAccount, "That email already has an account."}, {invite.ErrEmailHasAccount, "That email already has an account."},
{invite.ErrPendingInvite, "An invitation is already pending for that email."}, {invite.ErrPendingInvite, "An invitation is already pending for that email."},
@@ -0,0 +1,81 @@
package useCases
import (
"RenewCMS/internal/domain/hash"
"RenewCMS/internal/domain/mail"
"RenewCMS/internal/domain/user"
"log"
"time"
"github.com/google/uuid"
)
const (
verifyNewEmailTemplateName = "verify-new-email"
emailChangeNoticeTemplateName = "email-change-notice"
)
type MailVerifyNewEmailData struct {
Host string
Username string
VerificationCode string
}
type MailEmailChangeNoticeData struct {
Host string
Username string
NewEmail string
}
type emailChangeIssuer struct {
userRepository user.Repository
hasher hash.Hasher
mailRepository mail.Repository
dispatcher mail.Dispatcher
publicURL string
now func() time.Time
}
func (i emailChangeIssuer) issue(account user.User, pendingEmail string) (user.User, error) {
rawCode := uuid.NewString()
hashedCode, err := i.hasher.Hash(rawCode)
if err != nil {
return user.User{}, err
}
updated, err := i.userRepository.SetPendingEmailChange(account.ID, pendingEmail, hashedCode, i.now().Add(user.EmailChangeLifetime))
if err != nil {
return user.User{}, err
}
i.dispatcher.Dispatch(func() { i.sendVerification(account, pendingEmail, rawCode) })
return updated, nil
}
func (i emailChangeIssuer) sendVerification(account user.User, pendingEmail string, rawCode string) {
taken, err := i.userRepository.IsEmailInUse(pendingEmail, account.ID)
if err != nil {
log.Println("error when checking whether the new email address is in use:", err)
return
}
if taken {
return
}
if err := i.mailRepository.Send(pendingEmail, verifyNewEmailTemplateName, MailVerifyNewEmailData{
Host: i.publicURL,
Username: account.Username,
VerificationCode: rawCode,
}); err != nil {
log.Println("error when Send-ing the new email verification mail:", err)
}
}
func (i emailChangeIssuer) sendNotice(account user.User, pendingEmail string) {
i.dispatcher.Dispatch(func() {
if err := i.mailRepository.Send(account.Email, emailChangeNoticeTemplateName, MailEmailChangeNoticeData{
Host: i.publicURL,
Username: account.Username,
NewEmail: pendingEmail,
}); err != nil {
log.Println("error when Send-ing the email change notice mail:", err)
}
})
}
@@ -0,0 +1,84 @@
package test
import (
"RenewCMS/internal/infrastructure/mailer"
"bytes"
"log"
"strings"
"sync"
"testing"
"time"
)
type dispatcherLogBuffer struct {
mu sync.Mutex
buffer bytes.Buffer
}
func (b *dispatcherLogBuffer) Write(p []byte) (int, error) {
b.mu.Lock()
defer b.mu.Unlock()
return b.buffer.Write(p)
}
func (b *dispatcherLogBuffer) String() string {
b.mu.Lock()
defer b.mu.Unlock()
return b.buffer.String()
}
func TestAccountChangeEmailAsyncDispatcherRunsJobInBackground(t *testing.T) {
dispatcher := mailer.NewAsyncDispatcher()
release := make(chan struct{})
done := make(chan struct{})
dispatcher.Dispatch(func() {
<-release
close(done)
})
select {
case <-done:
t.Fatal("job completed before it was released, so Dispatch did not return independently")
default:
}
close(release)
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("dispatched job never ran")
}
}
func TestAccountChangeEmailAsyncDispatcherRecoversAndLogsPanic(t *testing.T) {
buffer := &dispatcherLogBuffer{}
previous := log.Writer()
log.SetOutput(buffer)
defer log.SetOutput(previous)
dispatcher := mailer.NewAsyncDispatcher()
finished := make(chan struct{})
dispatcher.Dispatch(func() {
defer close(finished)
panic("boom")
})
select {
case <-finished:
case <-time.After(2 * time.Second):
t.Fatal("panicking job never ran")
}
deadline := time.Now().Add(2 * time.Second)
for !strings.Contains(buffer.String(), "boom") && time.Now().Before(deadline) {
time.Sleep(5 * time.Millisecond)
}
if !strings.Contains(buffer.String(), "panic in a background mail job: boom") {
t.Fatalf("panic was not logged, got %q", buffer.String())
}
followUp := make(chan struct{})
dispatcher.Dispatch(func() { close(followUp) })
select {
case <-followUp:
case <-time.After(2 * time.Second):
t.Fatal("dispatcher unusable after a recovered panic")
}
}
+698
View File
@@ -0,0 +1,698 @@
package test
import (
"RenewCMS/api/handlers/auth"
"RenewCMS/api/middleware"
"RenewCMS/api/pkg/authtoken"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/mail"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"bytes"
"context"
"errors"
"log"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"github.com/go-chi/chi/v5"
)
const (
changeEmailHost = "https://cms.example.com"
changeEmailOld = "marta@renew.example"
changeEmailNew = "marta.alves@atelier-renew.example"
changeEmailPassword = "limewash2026"
)
type changeEmailDispatcher struct {
mu sync.Mutex
jobs []func()
}
func (d *changeEmailDispatcher) Dispatch(job func()) {
d.mu.Lock()
defer d.mu.Unlock()
d.jobs = append(d.jobs, job)
}
func (d *changeEmailDispatcher) Drain() {
d.mu.Lock()
jobs := d.jobs
d.jobs = nil
d.mu.Unlock()
for _, job := range jobs {
job()
}
}
func (d *changeEmailDispatcher) pending() int {
d.mu.Lock()
defer d.mu.Unlock()
return len(d.jobs)
}
var _ mail.Dispatcher = &changeEmailDispatcher{}
type changeEmailFixture struct {
dispatcher *changeEmailDispatcher
users *mockUserRepo
mail *mockMailRepo
recorder *recordingRecorder
token *authtoken.Auth
router *chi.Mux
}
func newChangeEmailFixture(t *testing.T) *changeEmailFixture {
t.Helper()
validator.InitValidator()
f := &changeEmailFixture{
dispatcher: &changeEmailDispatcher{},
users: newMockUserRepo(),
mail: newMockMailRepo(),
recorder: &recordingRecorder{},
token: authtoken.New([]byte("test-secret")),
}
f.users.users[7] = user.User{ID: 7, Username: "marta", Email: changeEmailOld, Password: "hashed:" + changeEmailPassword, IsVerified: true}
f.users.users[8] = user.User{ID: 8, Username: "ines", Email: "ines@renew.example", Password: "hashed:other", IsVerified: true}
f.users.nextID = 8
hasher := &stubHasher{}
getUser := useCases.NewGetUserUseCase(f.users)
handler := auth.NewEmailChangeHandler(
useCases.NewRequestEmailChangeUseCase(f.users, hasher, f.mail, f.dispatcher, changeEmailHost, time.Now, f.recorder),
useCases.NewResendEmailChangeUseCase(f.users, hasher, f.mail, f.dispatcher, changeEmailHost, time.Now, f.recorder),
useCases.NewCancelEmailChangeUseCase(f.users, f.recorder),
useCases.NewConfirmEmailChangeUseCase(f.users, hasher, time.Now, f.recorder),
)
f.router = chi.NewRouter()
f.router.Route("/v1/auth", func(r chi.Router) {
r.Use(middleware.RequireSession)
r.Use(authtoken.Verifier(f.token))
r.Use(middleware.RequireSignedIn)
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(getUser))
r.Use(middleware.RequireVerifiedUser(getUser))
r.Delete("/email/change", handler.Cancel)
r.Post("/email/confirm", handler.Confirm)
r.Group(func(r chi.Router) {
r.Use(middleware.NewUserRateLimiter(5, 15*time.Minute))
r.Post("/email/change", handler.Request)
r.Post("/email/resend", handler.Resend)
})
})
return f
}
func (f *changeEmailFixture) as(t *testing.T, userId uint32, method, target, body string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(method, target, bytes.NewBufferString(body))
req.AddCookie(displayNameCookie(t, f.token, userId, 0))
rec := httptest.NewRecorder()
f.router.ServeHTTP(rec, req)
f.dispatcher.Drain()
return rec
}
func (f *changeEmailFixture) request(t *testing.T, body string) *httptest.ResponseRecorder {
t.Helper()
return f.as(t, 7, http.MethodPost, "/v1/auth/email/change", body)
}
func (f *changeEmailFixture) requestFor(t *testing.T, newEmail string) *httptest.ResponseRecorder {
t.Helper()
return f.request(t, `{"new_email": "`+newEmail+`", "current_password": "`+changeEmailPassword+`"}`)
}
func (f *changeEmailFixture) resend(t *testing.T) *httptest.ResponseRecorder {
t.Helper()
return f.as(t, 7, http.MethodPost, "/v1/auth/email/resend", "")
}
func (f *changeEmailFixture) cancel(t *testing.T) *httptest.ResponseRecorder {
t.Helper()
return f.as(t, 7, http.MethodDelete, "/v1/auth/email/change", "")
}
func (f *changeEmailFixture) confirm(t *testing.T, userId uint32, code string) *httptest.ResponseRecorder {
t.Helper()
return f.as(t, userId, http.MethodPost, "/v1/auth/email/confirm", `{"code": "`+code+`"}`)
}
func (f *changeEmailFixture) lastVerificationCode(t *testing.T) string {
t.Helper()
for i := len(f.mail.SentEmails) - 1; i >= 0; i-- {
if data, ok := f.mail.SentEmails[i].Data.(useCases.MailVerifyNewEmailData); ok {
return data.VerificationCode
}
}
t.Fatal("no verify-new-email mail was sent")
return ""
}
func (f *changeEmailFixture) mailsTo(address string) []recordedMail {
var sent []recordedMail
for _, m := range f.mail.SentEmails {
if m.Address == address {
sent = append(sent, m)
}
}
return sent
}
func (f *changeEmailFixture) marta() user.User {
return f.users.users[7]
}
func assertChangeEmailResponse(t *testing.T, rec *httptest.ResponseRecorder, status int, body string) {
t.Helper()
if rec.Code != status {
t.Fatalf("expected status %d, got %d: %s", status, rec.Code, rec.Body.String())
}
if strings.TrimSpace(rec.Body.String()) != body {
t.Fatalf("expected body %q, got %q", body, rec.Body.String())
}
}
func assertNothingPending(t *testing.T, f *changeEmailFixture) {
t.Helper()
if marta := f.marta(); marta.PendingEmail != "" || marta.EmailChangeCode != "" || !marta.EmailChangeExpiration.IsZero() {
t.Errorf("expected no pending change, got %+v", marta)
}
}
func TestAccountChangeEmailRequestHTTP(t *testing.T) {
t.Run("A valid request stores a pending change and sends two mails", func(t *testing.T) {
f := newChangeEmailFixture(t)
rec := f.requestFor(t, changeEmailNew)
assertChangeEmailResponse(t, rec, http.StatusOK, `{"pending_email":"`+changeEmailNew+`"}`)
marta := f.marta()
if marta.Email != changeEmailOld {
t.Errorf("users.email must be untouched, got %q", marta.Email)
}
rawCode := f.lastVerificationCode(t)
if marta.PendingEmail != changeEmailNew || marta.EmailChangeCode == "" || marta.EmailChangeCode == rawCode {
t.Errorf("expected the pending email and a hashed code, got %+v", marta)
}
if delta := time.Until(marta.EmailChangeExpiration.Add(-2 * time.Hour)); delta > 5*time.Second || delta < -5*time.Second {
t.Errorf("expected the expiration within 5 seconds of now plus 2 hours, got %v", marta.EmailChangeExpiration)
}
if len(f.mail.SentEmails) != 2 {
t.Fatalf("expected two mails, got %d", len(f.mail.SentEmails))
}
verify := f.mailsTo(changeEmailNew)
if len(verify) != 1 || verify[0].Template != "verify-new-email" {
t.Fatalf("expected one verify-new-email mail to the new address, got %+v", verify)
}
if data := verify[0].Data.(useCases.MailVerifyNewEmailData); data.Host != changeEmailHost || data.Username != "marta" || data.VerificationCode != rawCode {
t.Errorf("unexpected verify data %+v", data)
}
notice := f.mailsTo(changeEmailOld)
if len(notice) != 1 || notice[0].Template != "email-change-notice" {
t.Fatalf("expected one email-change-notice to the old address, got %+v", notice)
}
if data := notice[0].Data.(useCases.MailEmailChangeNoticeData); data.Host != changeEmailHost || data.Username != "marta" || data.NewEmail != changeEmailNew {
t.Errorf("unexpected notice data %+v", data)
}
if len(f.recorder.events) != 1 {
t.Fatalf("expected one audit event, got %d", len(f.recorder.events))
}
event := f.recorder.events[0]
if event.Action != "user.request_email_change" || event.Category != audit.CategoryUsers || event.Outcome != audit.OutcomeSuccess || !event.Security || event.Target.ID != "7" || len(event.Params) != 0 {
t.Errorf("unexpected audit event %+v", event)
}
})
refused := []struct {
name string
body string
status int
text string
}{
{"an invalid address", `{"new_email": "marta.alves@atelier", "current_password": "limewash2026"}`, http.StatusBadRequest, "NewEmail"},
{"an empty address", `{"new_email": "", "current_password": "limewash2026"}`, http.StatusBadRequest, "NewEmail"},
{"an empty password", `{"new_email": "marta.alves@atelier-renew.example", "current_password": ""}`, http.StatusBadRequest, "CurrentPassword"},
{"a body that is not json", `not-json`, http.StatusBadRequest, "The request cannot be processed due to a mismatch in the format of the body."},
{"the current address in another case", `{"new_email": "MARTA@renew.example", "current_password": "limewash2026"}`, http.StatusBadRequest, "same_email"},
{"a wrong password", `{"new_email": "marta.alves@atelier-renew.example", "current_password": "nope"}`, http.StatusForbidden, "wrong_password"},
}
for _, tc := range refused {
t.Run("A refused request changes nothing and sends nothing: "+tc.name, func(t *testing.T) {
f := newChangeEmailFixture(t)
rec := f.request(t, tc.body)
if rec.Code != tc.status || !strings.Contains(rec.Body.String(), tc.text) {
t.Errorf("expected %d containing %q, got %d %q", tc.status, tc.text, rec.Code, rec.Body.String())
}
assertNothingPending(t, f)
if len(f.mail.SentEmails) != 0 {
t.Errorf("expected no mail, got %d", len(f.mail.SentEmails))
}
})
}
t.Run("A wrong password records a failed security event", func(t *testing.T) {
f := newChangeEmailFixture(t)
f.request(t, `{"new_email": "`+changeEmailNew+`", "current_password": "nope"}`)
if len(f.recorder.events) != 1 {
t.Fatalf("expected one audit event, got %d", len(f.recorder.events))
}
event := f.recorder.events[0]
if event.Action != "user.request_email_change" || event.Outcome != audit.OutcomeFailed || !event.Security || event.FailureReason == nil || *event.FailureReason != "Wrong password." {
t.Errorf("unexpected audit event %+v", event)
}
})
t.Run("An address already in use answers like success and sends no verification mail", func(t *testing.T) {
f := newChangeEmailFixture(t)
fresh := f.requestFor(t, changeEmailNew)
freshBody := fresh.Body.String()
g := newChangeEmailFixture(t)
taken := g.requestFor(t, "Ines@Renew.example")
if taken.Code != fresh.Code || strings.ReplaceAll(taken.Body.String(), "Ines@Renew.example", changeEmailNew) != freshBody {
t.Errorf("expected the same response as a free address, got %d %q", taken.Code, taken.Body.String())
}
for name, values := range fresh.Header() {
if name != "Date" && strings.Join(taken.Header()[name], ",") != strings.Join(values, ",") {
t.Errorf("header %s differs", name)
}
}
if g.marta().PendingEmail != "Ines@Renew.example" {
t.Errorf("expected the pending change to be stored, got %q", g.marta().PendingEmail)
}
if len(g.mailsTo("Ines@Renew.example"))+len(g.mailsTo("ines@renew.example")) != 0 {
t.Error("no mail may reach the taken address")
}
if notice := g.mailsTo(changeEmailOld); len(notice) != 1 || notice[0].Template != "email-change-notice" {
t.Errorf("expected the notice to the old address, got %+v", notice)
}
})
t.Run("A new request replaces the previous link", func(t *testing.T) {
f := newChangeEmailFixture(t)
f.requestFor(t, "a@renew.example")
first := f.lastVerificationCode(t)
f.requestFor(t, "b@renew.example")
second := f.lastVerificationCode(t)
old := f.confirm(t, 7, first)
current := f.confirm(t, 7, second)
assertChangeEmailResponse(t, old, http.StatusForbidden, "invalid_email_change_code")
assertChangeEmailResponse(t, current, http.StatusOK, `{"email":"b@renew.example"}`)
})
t.Run("A failed send is logged and the response is unchanged", func(t *testing.T) {
f := newChangeEmailFixture(t)
f.mail.fail.failNextWith("Send", errors.New("smtp: connection refused"))
var logBuf bytes.Buffer
previous := log.Writer()
log.SetOutput(&logBuf)
defer log.SetOutput(previous)
rec := f.requestFor(t, changeEmailNew)
assertChangeEmailResponse(t, rec, http.StatusOK, `{"pending_email":"`+changeEmailNew+`"}`)
if f.marta().PendingEmail != changeEmailNew {
t.Error("the pending change must be stored")
}
logged := logBuf.String()
if !strings.Contains(logged, "smtp: connection refused") || !strings.Contains(logged, "verification") {
t.Errorf("expected the log to name the failed send and the error, got %q", logged)
}
if f.marta().EmailChangeCode != "" && strings.Contains(logged, strings.TrimPrefix(f.marta().EmailChangeCode, "hashed:")) {
t.Error("the log must not contain the raw code")
}
})
t.Run("A failed notice send is logged and the response is unchanged", func(t *testing.T) {
f := newChangeEmailFixture(t)
f.mail.fail.failNextWith("Send", errors.New("smtp: notice refused"))
var logBuf bytes.Buffer
previous := log.Writer()
log.SetOutput(&logBuf)
defer log.SetOutput(previous)
rec := f.requestFor(t, "ines@renew.example")
assertChangeEmailResponse(t, rec, http.StatusOK, `{"pending_email":"ines@renew.example"}`)
logged := logBuf.String()
if !strings.Contains(logged, "smtp: notice refused") || !strings.Contains(logged, "notice") {
t.Errorf("expected the log to name the failed notice send, got %q", logged)
}
})
t.Run("A successful send logs nothing", func(t *testing.T) {
f := newChangeEmailFixture(t)
var logBuf bytes.Buffer
previous := log.Writer()
log.SetOutput(&logBuf)
defer log.SetOutput(previous)
f.requestFor(t, changeEmailNew)
if logBuf.Len() != 0 {
t.Errorf("expected no log output, got %q", logBuf.String())
}
})
t.Run("The sixth request in 15 minutes is rate limited", func(t *testing.T) {
f := newChangeEmailFixture(t)
for i := 0; i < 3; i++ {
f.requestFor(t, changeEmailNew)
}
f.resend(t)
f.resend(t)
sentBefore := len(f.mail.SentEmails)
eventsBefore := len(f.recorder.events)
codeBefore := f.marta().EmailChangeCode
rec := f.requestFor(t, "other@renew.example")
assertChangeEmailResponse(t, rec, http.StatusTooManyRequests, "Too many requests. Please try again later.")
if rec.Header().Get("Retry-After") == "" {
t.Error("expected a Retry-After header")
}
if len(f.mail.SentEmails) != sentBefore || len(f.recorder.events) != eventsBefore || f.marta().EmailChangeCode != codeBefore || f.marta().PendingEmail != changeEmailNew {
t.Error("nothing may be sent or stored once limited")
}
})
t.Run("The endpoints refuse a missing session", func(t *testing.T) {
f := newChangeEmailFixture(t)
req := httptest.NewRequest(http.MethodPost, "/v1/auth/email/change", strings.NewReader("{}"))
rec := httptest.NewRecorder()
f.router.ServeHTTP(rec, req)
assertChangeEmailResponse(t, rec, http.StatusUnauthorized, "You must be logged in to access this resource.")
})
}
func TestAccountChangeEmailResendAndCancelHTTP(t *testing.T) {
t.Run("Resend issues a new code and invalidates the old one", func(t *testing.T) {
f := newChangeEmailFixture(t)
f.requestFor(t, changeEmailNew)
first := f.lastVerificationCode(t)
f.mail.SentEmails = nil
f.recorder.events = nil
rec := f.resend(t)
assertChangeEmailResponse(t, rec, http.StatusOK, `{"pending_email":"`+changeEmailNew+`"}`)
if len(f.mail.SentEmails) != 1 || f.mail.SentEmails[0].Template != "verify-new-email" || f.mail.SentEmails[0].Address != changeEmailNew {
t.Fatalf("expected one verify-new-email mail only, got %+v", f.mail.SentEmails)
}
second := f.lastVerificationCode(t)
if second == first {
t.Error("expected a new code")
}
if delta := time.Until(f.marta().EmailChangeExpiration.Add(-2 * time.Hour)); delta > 5*time.Second || delta < -5*time.Second {
t.Errorf("expected the expiration reset to now plus 2 hours, got %v", f.marta().EmailChangeExpiration)
}
resendEvents := f.recorder.events
assertChangeEmailResponse(t, f.confirm(t, 7, first), http.StatusForbidden, "invalid_email_change_code")
if len(resendEvents) != 1 || resendEvents[0].Action != "user.resend_email_change" {
t.Errorf("expected one user.resend_email_change event, got %+v", resendEvents)
}
})
t.Run("Resend for a taken address sends nothing", func(t *testing.T) {
f := newChangeEmailFixture(t)
f.users.users[7] = func() user.User {
marta := f.users.users[7]
marta.PendingEmail = "ines@renew.example"
marta.EmailChangeCode = "hashed:old"
return marta
}()
rec := f.resend(t)
assertChangeEmailResponse(t, rec, http.StatusOK, `{"pending_email":"ines@renew.example"}`)
if len(f.mail.SentEmails) != 0 {
t.Errorf("expected no mail, got %d", len(f.mail.SentEmails))
}
})
t.Run("Resend with nothing pending", func(t *testing.T) {
f := newChangeEmailFixture(t)
assertChangeEmailResponse(t, f.resend(t), http.StatusNotFound, "no_pending_email_change")
})
t.Run("Cancel clears the pending change", func(t *testing.T) {
f := newChangeEmailFixture(t)
f.requestFor(t, changeEmailNew)
code := f.lastVerificationCode(t)
f.recorder.events = nil
rec := f.cancel(t)
if rec.Code != http.StatusNoContent || rec.Body.Len() != 0 {
t.Errorf("expected an empty 204, got %d %q", rec.Code, rec.Body.String())
}
assertNothingPending(t, f)
cancelEvents := f.recorder.events
assertChangeEmailResponse(t, f.confirm(t, 7, code), http.StatusForbidden, "invalid_email_change_code")
if len(cancelEvents) != 1 || cancelEvents[0].Action != "user.cancel_email_change" {
t.Errorf("expected one user.cancel_email_change event, got %+v", cancelEvents)
}
})
t.Run("Cancel keeps the pending change and records nothing when the store fails", func(t *testing.T) {
f := newChangeEmailFixture(t)
f.requestFor(t, changeEmailNew)
f.recorder.events = nil
f.users.fail.failNextWith("ClearPendingEmailChange", errors.New("db down"))
rec := f.cancel(t)
if rec.Code != http.StatusInternalServerError {
t.Errorf("expected 500, got %d %q", rec.Code, rec.Body.String())
}
if f.marta().PendingEmail != changeEmailNew {
t.Error("the pending change must remain")
}
if len(f.recorder.events) != 1 || f.recorder.events[0].Action != "user.cancel_email_change" || f.recorder.events[0].Outcome != audit.OutcomeFailed {
t.Errorf("expected one failed user.cancel_email_change event, got %+v", f.recorder.events)
}
})
t.Run("Cancel with nothing pending is harmless", func(t *testing.T) {
f := newChangeEmailFixture(t)
rec := f.cancel(t)
if rec.Code != http.StatusNoContent || len(f.recorder.events) != 0 {
t.Errorf("expected 204 and no event, got %d and %d events", rec.Code, len(f.recorder.events))
}
})
}
func TestAccountChangeEmailConfirmHTTP(t *testing.T) {
t.Run("A valid code changes the email and is single use", func(t *testing.T) {
f := newChangeEmailFixture(t)
f.requestFor(t, changeEmailNew)
code := f.lastVerificationCode(t)
f.recorder.events = nil
rec := f.confirm(t, 7, code)
assertChangeEmailResponse(t, rec, http.StatusOK, `{"email":"`+changeEmailNew+`"}`)
if f.marta().Email != changeEmailNew || f.marta().TokenVersion != 0 {
t.Errorf("expected the new email and an unchanged token version, got %+v", f.marta())
}
assertNothingPending(t, f)
if len(f.recorder.events) != 1 || f.recorder.events[0].Action != "user.confirm_email_change" || !f.recorder.events[0].Security {
t.Errorf("expected one security user.confirm_email_change event, got %+v", f.recorder.events)
}
assertChangeEmailResponse(t, f.confirm(t, 7, code), http.StatusForbidden, "invalid_email_change_code")
})
t.Run("A code that does not work answers one 403: nothing is pending", func(t *testing.T) {
f := newChangeEmailFixture(t)
assertChangeEmailResponse(t, f.confirm(t, 7, "any"), http.StatusForbidden, "invalid_email_change_code")
if f.marta().Email != changeEmailOld {
t.Error("users.email must be unchanged")
}
})
t.Run("A code that does not work answers one 403: the code expired 1 second ago", func(t *testing.T) {
f := newChangeEmailFixture(t)
f.requestFor(t, changeEmailNew)
code := f.lastVerificationCode(t)
marta := f.marta()
marta.EmailChangeExpiration = time.Now().Add(-time.Second)
f.users.users[7] = marta
assertChangeEmailResponse(t, f.confirm(t, 7, code), http.StatusForbidden, "invalid_email_change_code")
if f.marta().Email != changeEmailOld {
t.Error("users.email must be unchanged")
}
})
t.Run("A code that does not work answers one 403: a wrong code", func(t *testing.T) {
f := newChangeEmailFixture(t)
f.requestFor(t, changeEmailNew)
assertChangeEmailResponse(t, f.confirm(t, 7, "wrong"), http.StatusForbidden, "invalid_email_change_code")
if f.marta().Email != changeEmailOld {
t.Error("users.email must be unchanged")
}
})
t.Run("A code that does not work answers one 403: the code was issued to another user", func(t *testing.T) {
f := newChangeEmailFixture(t)
f.requestFor(t, changeEmailNew)
code := f.lastVerificationCode(t)
assertChangeEmailResponse(t, f.confirm(t, 8, code), http.StatusForbidden, "invalid_email_change_code")
if f.marta().Email != changeEmailOld || f.users.users[8].Email != "ines@renew.example" {
t.Error("no email may change")
}
})
t.Run("A code that does not work answers one 403: the change was cancelled", func(t *testing.T) {
f := newChangeEmailFixture(t)
f.requestFor(t, changeEmailNew)
code := f.lastVerificationCode(t)
f.cancel(t)
assertChangeEmailResponse(t, f.confirm(t, 7, code), http.StatusForbidden, "invalid_email_change_code")
})
t.Run("The address was taken in the meantime", func(t *testing.T) {
f := newChangeEmailFixture(t)
f.requestFor(t, "later@renew.example")
code := f.lastVerificationCode(t)
ines := f.users.users[8]
ines.Email = "later@renew.example"
f.users.users[8] = ines
rec := f.confirm(t, 7, code)
assertChangeEmailResponse(t, rec, http.StatusConflict, "email_unavailable")
if f.marta().Email != changeEmailOld {
t.Error("users.email must be unchanged")
}
assertNothingPending(t, f)
})
t.Run("An empty code is refused before any lookup", func(t *testing.T) {
f := newChangeEmailFixture(t)
f.requestFor(t, changeEmailNew)
f.users.getCalls = 0
f.confirm(t, 7, "")
middlewareLookups := f.users.getCalls
f.users.getCalls = 0
rec := f.confirm(t, 7, "")
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "Code") {
t.Errorf("expected a 400 with the validator message, got %d %q", rec.Code, rec.Body.String())
}
if f.users.getCalls != middlewareLookups {
t.Error("the use case must not look the user up")
}
if f.marta().PendingEmail != changeEmailNew {
t.Error("nothing may change")
}
})
}
func TestAccountChangeEmailTimingAndFailuresHTTP(t *testing.T) {
t.Run("No mail is sent on the request path, free or taken address alike", func(t *testing.T) {
for _, address := range []string{changeEmailNew, "ines@renew.example"} {
f := newChangeEmailFixture(t)
req := httptest.NewRequest(http.MethodPost, "/v1/auth/email/change", strings.NewReader(`{"new_email": "`+address+`", "current_password": "`+changeEmailPassword+`"}`))
req.AddCookie(displayNameCookie(t, f.token, 7, 0))
rec := httptest.NewRecorder()
f.router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK || len(f.mail.SentEmails) != 0 || f.dispatcher.pending() != 2 {
t.Errorf("%s: status %d, sent %d, queued %d", address, rec.Code, len(f.mail.SentEmails), f.dispatcher.pending())
}
f.dispatcher.Drain()
}
})
t.Run("Every email-change refusal records a specific failure reason", func(t *testing.T) {
cases := []struct {
name string
run func(f *changeEmailFixture)
}{
{"same address", func(f *changeEmailFixture) { f.requestFor(t, changeEmailOld) }},
{"nothing pending on resend", func(f *changeEmailFixture) { f.resend(t) }},
{"invalid code", func(f *changeEmailFixture) { f.confirm(t, 7, "nope") }},
{"address unavailable", func(f *changeEmailFixture) {
f.requestFor(t, "later@renew.example")
code := f.lastVerificationCode(t)
ines := f.users.users[8]
ines.Email = "later@renew.example"
f.users.users[8] = ines
f.recorder.events = nil
f.confirm(t, 7, code)
}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
f := newChangeEmailFixture(t)
tc.run(f)
last := f.recorder.events[len(f.recorder.events)-1]
if last.Outcome != audit.OutcomeFailed || !last.Security || last.FailureReason == nil || *last.FailureReason == useCases.GenericFailureReason {
t.Errorf("expected a specific failed security event, got %+v", last)
}
})
}
})
t.Run("Concurrent confirms of one address let exactly one user win", func(t *testing.T) {
f := newChangeEmailFixture(t)
const contenders = 8
codes := map[uint32]string{}
for i := uint32(0); i < contenders; i++ {
id := 20 + i
f.users.users[id] = user.User{ID: id, Username: "u", Email: "u@renew.example", IsVerified: true, PendingEmail: "shared@renew.example", EmailChangeCode: "hashed:code", EmailChangeExpiration: time.Now().Add(time.Hour)}
codes[id] = "code"
}
confirm := useCases.NewConfirmEmailChangeUseCase(f.users, atomicPlainHasher{}, time.Now, discardRecorder)
var mu sync.Mutex
var group sync.WaitGroup
wins, unavailable := 0, 0
for id, code := range codes {
group.Add(1)
go func() {
defer group.Done()
_, err := confirm.ConfirmEmailChange(context.Background(), id, code)
mu.Lock()
defer mu.Unlock()
if err == nil {
wins++
} else if errors.Is(err, user.ErrEmailUnavailable) {
unavailable++
}
}()
}
group.Wait()
if wins != 1 || unavailable != contenders-1 {
t.Errorf("expected 1 win and %d unavailable, got %d and %d", contenders-1, wins, unavailable)
}
})
}
@@ -0,0 +1,228 @@
package test
import (
domainUser "RenewCMS/internal/domain/user"
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
"errors"
"fmt"
"sync"
"testing"
"time"
"gorm.io/gorm"
)
func TestUserRepositoryEmailChangeContract(t *testing.T) {
backends := []contractBackend{
{name: "sqlite", open: openContractSQLite},
{name: "mysql", open: openContractMySQL},
{name: "postgres", open: openContractPostgres},
}
expiration := time.Now().Add(2 * time.Hour).UTC().Truncate(time.Second)
cases := []struct {
name string
run func(t *testing.T, users *persistence.UserRepository, marta domainUser.User, ines domainUser.User)
}{
{"setting a pending change keeps the email and clearing empties the three fields", func(t *testing.T, users *persistence.UserRepository, marta domainUser.User, ines domainUser.User) {
set, err := users.SetPendingEmailChange(marta.ID, "new@renew.example", "hash", expiration)
if err != nil || set.PendingEmail != "new@renew.example" || set.EmailChangeCode != "hash" || !set.EmailChangeExpiration.Equal(expiration) || set.Email != marta.Email {
t.Fatalf("set %+v %v", set, err)
}
cleared, err := users.ClearPendingEmailChange(marta.ID)
if err != nil || cleared.PendingEmail != "" || cleared.EmailChangeCode != "" || !cleared.EmailChangeExpiration.IsZero() {
t.Errorf("cleared %+v %v", cleared, err)
}
}},
{"an address is in use whatever its case, except for the asking user", func(t *testing.T, users *persistence.UserRepository, marta domainUser.User, ines domainUser.User) {
taken, err := users.IsEmailInUse("INES@renew.example", marta.ID)
own, ownErr := users.IsEmailInUse(marta.Email, marta.ID)
free, freeErr := users.IsEmailInUse("free@renew.example", marta.ID)
if err != nil || !taken || ownErr != nil || own || freeErr != nil || free {
t.Errorf("taken %v %v own %v %v free %v %v", taken, err, own, ownErr, free, freeErr)
}
}},
{"confirming moves the pending address to the email once", func(t *testing.T, users *persistence.UserRepository, marta domainUser.User, ines domainUser.User) {
_, _ = users.SetPendingEmailChange(marta.ID, "new@renew.example", "hash", expiration)
confirmed, err := users.ConfirmEmailChange(marta.ID, "new@renew.example", "hash", time.Now())
_, again := users.ConfirmEmailChange(marta.ID, "new@renew.example", "hash", time.Now())
if err != nil || confirmed.Email != "new@renew.example" || confirmed.PendingEmail != "" || confirmed.EmailChangeCode != "" {
t.Errorf("confirmed %+v %v", confirmed, err)
}
if !errors.Is(again, domainUser.ErrInvalidEmailChangeCode) {
t.Errorf("second confirm %v", again)
}
}},
{"confirming after the expiry changes nothing", func(t *testing.T, users *persistence.UserRepository, marta domainUser.User, ines domainUser.User) {
_, _ = users.SetPendingEmailChange(marta.ID, "new@renew.example", "hash", expiration)
_, err := users.ConfirmEmailChange(marta.ID, "new@renew.example", "hash", expiration.Add(time.Second))
stored, _ := users.Get(marta.ID)
if !errors.Is(err, domainUser.ErrInvalidEmailChangeCode) || stored.Email != marta.Email {
t.Errorf("err %v stored %+v", err, stored)
}
}},
{"confirming with another code changes nothing", func(t *testing.T, users *persistence.UserRepository, marta domainUser.User, ines domainUser.User) {
_, _ = users.SetPendingEmailChange(marta.ID, "new@renew.example", "hash", expiration)
_, err := users.ConfirmEmailChange(marta.ID, "new@renew.example", "other", time.Now())
stored, _ := users.Get(marta.ID)
if !errors.Is(err, domainUser.ErrInvalidEmailChangeCode) || stored.Email != marta.Email || stored.PendingEmail != "new@renew.example" {
t.Errorf("err %v stored %+v", err, stored)
}
}},
{"confirming a taken address clears the pending change and keeps the email", func(t *testing.T, users *persistence.UserRepository, marta domainUser.User, ines domainUser.User) {
_, _ = users.SetPendingEmailChange(marta.ID, "Ines@Renew.example", "hash", expiration)
_, err := users.ConfirmEmailChange(marta.ID, "Ines@Renew.example", "hash", time.Now())
stored, _ := users.Get(marta.ID)
if !errors.Is(err, domainUser.ErrEmailUnavailable) || stored.Email != marta.Email || stored.PendingEmail != "" || stored.EmailChangeCode != "" {
t.Errorf("err %v stored %+v", err, stored)
}
}},
}
for _, backend := range backends {
t.Run(backend.name, func(t *testing.T) {
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
db := backend.open(t)
users := persistence.NewUserRepository(db)
marta := mustCreateEmailChangeUser(t, users, "marta", "marta@renew.example")
ines := mustCreateEmailChangeUser(t, users, "ines", "ines@renew.example")
c.run(t, users, marta, ines)
})
}
})
}
}
func mustCreateEmailChangeUser(t *testing.T, users *persistence.UserRepository, username string, email string) domainUser.User {
t.Helper()
created, err := users.Create(domainUser.User{Username: username, Email: email, Password: "hash", IsVerified: true})
if err != nil {
t.Fatalf("create %s: %v", username, err)
}
return created
}
func TestUserRepositoryConcurrentEmailChangeOnSQLite(t *testing.T) {
const contenders = 8
expiration := time.Now().Add(2 * time.Hour)
users := persistence.NewUserRepository(openContractSQLite(t))
ids := make([]uint32, 0, contenders)
for i := 0; i < contenders; i++ {
created := mustCreateEmailChangeUser(t, users, fmt.Sprintf("user%d", i), fmt.Sprintf("user%d@renew.example", i))
if _, err := users.SetPendingEmailChange(created.ID, "shared@renew.example", "hash", expiration); err != nil {
t.Fatal(err)
}
ids = append(ids, created.ID)
}
results := make([]error, contenders)
var group sync.WaitGroup
for i, id := range ids {
group.Add(1)
go func() {
defer group.Done()
_, results[i] = users.ConfirmEmailChange(id, "shared@renew.example", "hash", time.Now())
}()
}
group.Wait()
succeeded := 0
for _, err := range results {
switch {
case err == nil:
succeeded++
case !errors.Is(err, domainUser.ErrEmailUnavailable):
t.Errorf("a losing confirm must answer ErrEmailUnavailable, got %v", err)
}
}
if succeeded != 1 {
t.Errorf("expected exactly one success, got %d", succeeded)
}
for _, id := range ids {
stored, _ := users.Get(id)
if stored.PendingEmail != "" {
t.Errorf("user %d still has a pending change", id)
}
}
}
func failUserUpdates(t *testing.T, db *gorm.DB, failures ...error) {
t.Helper()
var mu sync.Mutex
remaining := failures
err := db.Callback().Update().Before("gorm:update").Register("test:fail_user_updates", func(tx *gorm.DB) {
mu.Lock()
defer mu.Unlock()
if len(remaining) == 0 || tx.Statement.Table != "users" {
return
}
failure := remaining[0]
remaining = remaining[1:]
if failure != nil {
_ = tx.AddError(failure)
}
})
if err != nil {
t.Fatal(err)
}
}
func TestUserRepositoryConfirmEmailChangeUniqueViolationRace(t *testing.T) {
expiration := time.Now().Add(2 * time.Hour)
setup := func(t *testing.T, failures ...error) (*persistence.UserRepository, domainUser.User) {
db := openContractSQLite(t)
users := persistence.NewUserRepository(db)
account := mustCreateEmailChangeUser(t, users, "racer", "racer@renew.example")
if _, err := users.SetPendingEmailChange(account.ID, "raced@renew.example", "hash", expiration); err != nil {
t.Fatal(err)
}
failUserUpdates(t, db, failures...)
return users, account
}
t.Run("a unique violation clears the pending change and answers ErrEmailUnavailable", func(t *testing.T) {
users, account := setup(t, gorm.ErrDuplicatedKey)
updated, err := users.ConfirmEmailChange(account.ID, "raced@renew.example", "hash", time.Now())
if !errors.Is(err, domainUser.ErrEmailUnavailable) {
t.Fatalf("expected ErrEmailUnavailable, got %v", err)
}
if updated.ID != 0 {
t.Errorf("no user must be returned, got %+v", updated)
}
stored, _ := users.Get(account.ID)
if stored.PendingEmail != "" || stored.Email != "racer@renew.example" {
t.Errorf("pending change must be cleared and email kept, got %+v", stored)
}
})
t.Run("a failing cleanup after a unique violation surfaces its own error", func(t *testing.T) {
cleanupFailure := errors.New("cleanup exploded")
users, account := setup(t, gorm.ErrDuplicatedKey, cleanupFailure)
_, err := users.ConfirmEmailChange(account.ID, "raced@renew.example", "hash", time.Now())
if !errors.Is(err, cleanupFailure) {
t.Fatalf("expected the cleanup error, got %v", err)
}
if errors.Is(err, domainUser.ErrEmailUnavailable) {
t.Error("a failed cleanup must not be reported as ErrEmailUnavailable")
}
})
t.Run("a non-unique database error is returned untouched", func(t *testing.T) {
failure := errors.New("disk on fire")
users, account := setup(t, failure)
_, err := users.ConfirmEmailChange(account.ID, "raced@renew.example", "hash", time.Now())
if !errors.Is(err, failure) {
t.Fatalf("expected the database error, got %v", err)
}
})
}
+81
View File
@@ -0,0 +1,81 @@
package test
import (
"RenewCMS/internal/domain/user"
entity "RenewCMS/internal/infrastructure/persistence/models"
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
"errors"
"testing"
"gorm.io/gorm"
)
func failAccountDeleteKeyRemoval(t *testing.T, db *gorm.DB, name string, failure error) {
t.Helper()
err := db.Callback().Delete().Before("gorm:delete").Register(name, func(tx *gorm.DB) {
if tx.Statement.Table == "api_keys" {
_ = tx.AddError(failure)
}
})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Callback().Delete().Remove(name) })
}
func TestAccountDeleteContentionMapping(t *testing.T) {
cases := []struct {
name string
failure string
guards []uint32
notDeletable bool
}{
{"locked database with a guard role", "database is locked", []uint32{8}, true},
{"sqlite busy with a guard role", "SQLITE_BUSY: x", []uint32{8}, true},
{"deadlock with a guard role", "Deadlock found when trying to get lock", []uint32{8}, true},
{"serialization failure with a guard role", "could not serialize access", []uint32{8}, true},
{"locked database without a guard role", "database is locked", nil, false},
{"unrelated failure with a guard role", "disk exploded", []uint32{8}, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
db := openContractSQLite(t)
seedDeleteAccountRows(t, db, true)
failure := errors.New(tc.failure)
failAccountDeleteKeyRemoval(t, db, "test:account_delete_contention", failure)
err := persistence.NewAccountEraser(db).Erase(user.Erasure{UserID: 7, GuardRoleIDs: tc.guards})
if errors.Is(err, user.ErrAccountNotDeletable) != tc.notDeletable {
t.Errorf("ErrAccountNotDeletable = %v, want %v (err %v)", !tc.notDeletable, tc.notDeletable, err)
}
if !tc.notDeletable && !errors.Is(err, failure) {
t.Errorf("expected the original failure, got %v", err)
}
if deleteAccountRowCount(t, db, &entity.User{}, "id = ?", 7) != 1 {
t.Error("the user must still exist after a failed erasure")
}
})
}
}
func TestAccountDeleteSQLiteTakesTheWriteLockBeforeCounting(t *testing.T) {
db := openContractSQLite(t)
seedDeleteAccountRows(t, db, true)
roleWrites := 0
err := db.Callback().Update().Before("gorm:update").Register("test:account_delete_role_writes", func(tx *gorm.DB) {
if tx.Statement.Table == "roles" {
roleWrites++
}
})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Callback().Update().Remove("test:account_delete_role_writes") })
err = persistence.NewAccountEraser(db).Erase(user.Erasure{UserID: 7, GuardRoleIDs: []uint32{8}})
if err != nil || roleWrites != 1 {
t.Errorf("expected one lock write on the guard roles and no error, got %d writes and %v", roleWrites, err)
}
}
+775
View File
@@ -0,0 +1,775 @@
package test
import (
"RenewCMS/api/handlers/user"
"RenewCMS/api/middleware"
"RenewCMS/api/pkg/authtoken"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/apikey"
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/entry"
"RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/role"
domain "RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"bytes"
"errors"
"log"
"maps"
"net/http"
"net/http/httptest"
"reflect"
"slices"
"strings"
"testing"
"time"
"github.com/go-chi/chi/v5"
)
const (
deleteAccountHost = "https://cms.example.com"
deleteAccountEmail = "marta@renew.example"
deleteAccountPassword = "limewash2026"
deleteAccountBody = `{"current_password": "limewash2026", "confirmation": "DELETE"}`
deleteAccountAuthorID = uint32(10)
deleteAccountAvatarID = uint32(5)
deleteAccountAvatar = "avatar.png"
)
type deleteAccountRevoker struct {
revoked []string
failure error
}
func (r *deleteAccountRevoker) Revoke(id string, _ time.Time) error {
if r.failure != nil {
return r.failure
}
r.revoked = append(r.revoked, id)
return nil
}
type deleteAccountEraserFake struct {
users *mockUserRepo
keys *mockAPIKeyRepo
authors *mockAuthorRepo
images *mockImageRepo
events *exportAuditFake
fail failSwitch
beforeErase func()
erasures []domain.Erasure
}
var _ domain.Eraser = &deleteAccountEraserFake{}
func (f *deleteAccountEraserFake) Erase(erasure domain.Erasure) error {
f.erasures = append(f.erasures, erasure)
if f.beforeErase != nil {
hook := f.beforeErase
f.beforeErase = nil
hook()
}
users, keys, authors, events, images := maps.Clone(f.users.users), maps.Clone(f.keys.keys), maps.Clone(f.authors.authors), slices.Clone(f.events.events), maps.Clone(f.images.images)
err := f.run(erasure)
if err != nil {
f.users.users, f.keys.keys, f.authors.authors, f.events.events, f.images.images = users, keys, authors, events, images
}
return err
}
func (f *deleteAccountEraserFake) run(erasure domain.Erasure) error {
if len(erasure.GuardRoleIDs) > 0 && f.verifiedHolders(erasure) == 0 {
return domain.ErrAccountNotDeletable
}
if err := f.fail.trigger("DeleteAPIKeys"); err != nil {
return err
}
for id, key := range f.keys.keys {
if key.UserID == erasure.UserID {
delete(f.keys.keys, id)
}
}
if err := f.fail.trigger("ScrubAuthor"); err != nil {
return err
}
f.releaseAuthor(erasure.UserID)
if erasure.AvatarImageID != nil {
delete(f.images.images, *erasure.AvatarImageID)
}
if err := f.fail.trigger("AnonymiseEvents"); err != nil {
return err
}
_ = f.events.AnonymiseUser(erasure.UserID, nil, domain.DeletedUserName)
if err := f.fail.trigger("DeleteUser"); err != nil {
return err
}
return f.users.Delete(erasure.UserID)
}
func (f *deleteAccountEraserFake) verifiedHolders(erasure domain.Erasure) int {
holders := 0
for id, candidate := range f.users.users {
if id != erasure.UserID && candidate.IsVerified && slices.Contains(erasure.GuardRoleIDs, candidate.RoleID) {
holders++
}
}
return holders
}
func (f *deleteAccountEraserFake) releaseAuthor(userID uint32) {
for id, found := range f.authors.authors {
if found.UserID == nil || *found.UserID != userID {
continue
}
if f.authors.withCount(found).ArticleCount == 0 {
delete(f.authors.authors, id)
continue
}
f.authors.authors[id] = author.Author{ID: id, Name: author.DeletedName, Slug: author.DeletedSlug(id), SocialLinks: []author.SocialLink{}}
}
}
type deleteAccountFixture struct {
dispatcher *changeEmailDispatcher
users *mockUserRepo
roles *mockRoleRepo
keys *mockAPIKeyRepo
authors *mockAuthorRepo
articles *mockArticleRepo
entries *mockEntryRepo
images *mockImageRepo
events *exportAuditFake
recorder *recordingRecorder
mail *mockMailRepo
revoker *deleteAccountRevoker
eraser *deleteAccountEraserFake
storage *mockImageStorage
variants *mockVariantFileRepo
sites *mockSiteRepo
token *authtoken.Auth
router *chi.Mux
removeHits int
}
func newDeleteAccountFixture(t *testing.T) *deleteAccountFixture {
t.Helper()
validator.InitValidator()
f := &deleteAccountFixture{
dispatcher: &changeEmailDispatcher{},
users: newMockUserRepo(),
roles: newSeededMockRoleRepo(),
keys: newMockAPIKeyRepo(),
articles: newMockArticleRepo(),
entries: &mockEntryRepo{entries: map[uint32]entry.Entry{}},
images: &mockImageRepo{images: map[uint32]image.Image{}},
events: &exportAuditFake{},
recorder: &recordingRecorder{},
mail: newMockMailRepo(),
revoker: &deleteAccountRevoker{},
storage: newMockImageStorage(),
variants: newMockVariantFileRepo(),
sites: plainSites(),
token: authtoken.New([]byte("test-secret")),
}
f.authors = newMockAuthorRepo(f.articles)
f.seed()
f.eraser = &deleteAccountEraserFake{users: f.users, keys: f.keys, authors: f.authors, images: f.images, events: f.events}
f.mountRouter()
return f
}
func (f *deleteAccountFixture) seed() {
password := "hashed:" + deleteAccountPassword
f.users.users[7] = domain.User{ID: 7, Username: "marta", Email: deleteAccountEmail, Password: password, IsVerified: true, RoleID: writerRoleID}
f.users.users[2] = domain.User{ID: 2, Username: "jonas", Email: "jonas@renew.example", Password: password, IsVerified: true, RoleID: adminRoleID}
f.users.users[3] = domain.User{ID: 3, Username: "ines", Email: "ines@renew.example", Password: password, IsVerified: true, RoleID: ownerRoleID}
f.users.users[9] = domain.User{ID: 9, Username: "paul", Email: "paul@renew.example", Password: password, IsVerified: false, RoleID: writerRoleID}
f.users.nextID = 9
f.keys.keys[1] = apikey.APIKey{ID: 1, UserID: 7, Name: "ci"}
f.keys.keys[2] = apikey.APIKey{ID: 2, UserID: 7, Name: "deploy"}
f.keys.keys[3] = apikey.APIKey{ID: 3, UserID: 2, Name: "jonas key"}
f.authors.seedLinked(deleteAccountAuthorID, "Marta Alves", "marta-alves", 7)
f.authors.update(deleteAccountAuthorID, func(a *author.Author) {
a.Bio = "Writes about lime."
website := "https://marta.example"
a.Website = &website
a.AvatarID = idPointer(deleteAccountAvatarID)
a.SocialLinks = []author.SocialLink{{Type: author.SocialGitHub, URL: "https://github.com/marta"}}
})
for id := uint32(1); id <= 3; id++ {
f.articles.articles[id] = article.Article{ID: id, Title: "Article", CreatedBy: 7}
f.articles.credits[id] = []uint32{deleteAccountAuthorID}
}
for id := uint32(1); id <= 2; id++ {
f.entries.entries[id] = entry.Entry{ID: id, CreatedBy: 7, UpdatedBy: 7}
}
for id := uint32(1); id <= 4; id++ {
f.images.images[id] = image.Image{ID: id, Filename: "a.png", UploadedByID: 7}
}
f.images.images[deleteAccountAvatarID] = image.Image{ID: deleteAccountAvatarID, Path: image.PublicPath(deleteAccountAvatar), Filename: deleteAccountAvatar, UploadedByID: 2}
f.storage.blobs[deleteAccountAvatar] = []byte("avatar")
for i := 0; i < 12; i++ {
ip, agent, martaID := "203.0.113.7", "Firefox", uint32(7)
f.events.events = append(f.events.events, audit.Event{
ID: uint64(i + 1),
Action: "article.edit",
Actor: audit.Actor{Type: audit.ActorUser, ID: &martaID, Name: "marta"},
Target: audit.Target{Type: audit.TargetArticle, ID: "1", Name: "Article"},
IP: &ip,
UserAgent: &agent,
})
}
jonasID := uint32(2)
f.events.events = append(f.events.events, audit.Event{
ID: 13,
Action: "user.change_role",
Actor: audit.Actor{Type: audit.ActorUser, ID: &jonasID, Name: "jonas"},
Target: audit.Target{Type: audit.TargetUser, ID: "7", Name: "marta"},
})
}
func (f *deleteAccountFixture) mountRouter() {
getUser := useCases.NewGetUserUseCase(f.users)
usage := useCases.NewGetMediaUsageUseCase(f.images, f.articles, f.sites, f.authors, newMockContentTypeRepo(), f.entries)
deleteUseCase := useCases.NewDeleteOwnAccountUseCase(f.users, f.roles, &stubHasher{}, f.eraser, f.revoker, f.mail, f.dispatcher, deleteAccountHost, f.recorder, f.authors, f.images, usage, f.storage, f.variants)
handler := user.NewDeleteAccountHandler(deleteUseCase, false)
f.router = chi.NewRouter()
f.router.Route("/v1/user", func(r chi.Router) {
r.Group(func(r chi.Router) {
r.Use(middleware.RequireSession)
r.Use(authtoken.Verifier(f.token))
r.Use(middleware.RequireSignedIn)
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(getUser))
r.Use(middleware.RequireVerifiedUser(getUser))
r.Use(middleware.NewUserRateLimiter(5, 15*time.Minute))
r.Delete("/me", handler.Delete)
})
r.Group(func(r chi.Router) {
r.Delete("/{id}", func(w http.ResponseWriter, _ *http.Request) {
f.removeHits++
w.WriteHeader(http.StatusNoContent)
})
})
})
f.router.Route("/v1/auth", func(r chi.Router) {
r.Use(authtoken.Verifier(f.token))
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(getUser))
r.Get("/me", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) })
})
}
func (f *deleteAccountFixture) request(t *testing.T, userID uint32, body string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodDelete, "/v1/user/me", bytes.NewBufferString(body))
req.AddCookie(displayNameCookie(t, f.token, userID, 0))
return f.serve(req)
}
func (f *deleteAccountFixture) serve(req *http.Request) *httptest.ResponseRecorder {
rec := httptest.NewRecorder()
f.router.ServeHTTP(rec, req)
f.dispatcher.Drain()
return rec
}
type deleteAccountState struct {
users map[uint32]domain.User
keys map[uint32]apikey.APIKey
authors map[uint32]author.Author
events []audit.Event
articles map[uint32]article.Article
entries map[uint32]entry.Entry
images map[uint32]image.Image
}
func (f *deleteAccountFixture) state() deleteAccountState {
return deleteAccountState{
users: maps.Clone(f.users.users),
keys: maps.Clone(f.keys.keys),
authors: f.authors.snapshot(),
events: slices.Clone(f.events.events),
articles: maps.Clone(f.articles.articles),
entries: maps.Clone(f.entries.entries),
images: maps.Clone(f.images.images),
}
}
func (f *deleteAccountFixture) assertUntouchedSince(t *testing.T, before deleteAccountState) {
t.Helper()
if !reflect.DeepEqual(before, f.state()) {
t.Error("expected the users, keys, authors, content and events to be unchanged")
}
if len(f.mail.SentEmails) != 0 {
t.Errorf("expected no mail, got %d", len(f.mail.SentEmails))
}
if len(f.revoker.revoked) != 0 {
t.Errorf("expected no revoked session, got %v", f.revoker.revoked)
}
if len(f.storage.deleted) != 0 {
t.Errorf("expected no deleted file, got %v", f.storage.deleted)
}
}
func (f *deleteAccountFixture) lastEvent(t *testing.T) audit.Event {
t.Helper()
if len(f.recorder.events) != 1 {
t.Fatalf("expected one audit event, got %d", len(f.recorder.events))
}
return f.recorder.events[0]
}
func assertDeleteAccountResponse(t *testing.T, rec *httptest.ResponseRecorder, status int, body string) {
t.Helper()
if rec.Code != status || strings.TrimSpace(rec.Body.String()) != body {
t.Fatalf("expected %d %q, got %d %q", status, body, rec.Code, rec.Body.String())
}
}
func TestAccountDeleteEndpointHTTP(t *testing.T) {
t.Run("A Writer deletes their account", func(t *testing.T) {
f := newDeleteAccountFixture(t)
articles, entries, images := maps.Clone(f.articles.articles), maps.Clone(f.entries.entries), maps.Clone(f.images.images)
delete(images, deleteAccountAvatarID)
rec := f.request(t, 7, deleteAccountBody)
assertDeleteAccountResponse(t, rec, http.StatusNoContent, "")
expired := false
for _, cookie := range rec.Result().Cookies() {
expired = expired || cookie.Name == "jwt" && cookie.MaxAge < 0 && cookie.Value == "" && cookie.HttpOnly
}
if !expired {
t.Errorf("expected an expired jwt cookie, got %v", rec.Header().Values("Set-Cookie"))
}
if _, exists := f.users.users[7]; exists {
t.Error("user 7 must be gone")
}
if _, one := f.keys.keys[1]; one || len(f.keys.keys) != 1 {
t.Errorf("keys 1 and 2 must be gone, got %v", f.keys.keys)
}
scrubbed := f.authors.authors[deleteAccountAuthorID]
if scrubbed.Name != "Deleted user" || scrubbed.Slug != "deleted-user-10" || scrubbed.UserID != nil || scrubbed.Bio != "" || scrubbed.AvatarID != nil || scrubbed.Website != nil || len(scrubbed.SocialLinks) != 0 {
t.Errorf("unexpected scrubbed author %+v", scrubbed)
}
if !reflect.DeepEqual(articles, f.articles.articles) || !reflect.DeepEqual(entries, f.entries.entries) || !reflect.DeepEqual(images, f.images.images) {
t.Error("articles, entries and images must be unchanged")
}
for _, event := range f.events.events[:12] {
if event.Actor.Name != "Deleted user" || event.IP != nil || event.UserAgent != nil {
t.Errorf("event %d not anonymised: %+v", event.ID, event)
}
}
if f.events.events[12].Actor.Name != "jonas" || f.events.events[12].Target.Name != "Deleted user" {
t.Errorf("another user's event keeps their name but loses the target name: %+v", f.events.events[12])
}
old := httptest.NewRequest(http.MethodGet, "/v1/auth/me", nil)
old.AddCookie(displayNameCookie(t, f.token, 7, 0))
if got := f.serve(old); got.Code != http.StatusUnauthorized {
t.Errorf("expected 401 for the old jwt, got %d", got.Code)
}
if len(f.revoker.revoked) != 1 || f.revoker.revoked[0] == "" {
t.Errorf("expected the session id to be revoked, got %v", f.revoker.revoked)
}
if len(f.mail.SentEmails) != 1 {
t.Fatalf("expected one mail, got %d", len(f.mail.SentEmails))
}
sent := f.mail.SentEmails[0]
if sent.Address != deleteAccountEmail || sent.Template != "account-deleted" || sent.Data != (useCases.MailAccountDeletedData{Host: deleteAccountHost}) {
t.Errorf("unexpected mail %+v", sent)
}
event := f.lastEvent(t)
martaID := uint32(7)
if event.Action != "user.delete_account" || event.Actor.Name != "Deleted user" || *event.Actor.ID != martaID || event.Target.Name != "Deleted user" || event.Target.ID != "7" || !event.Security || event.Outcome != audit.OutcomeSuccess || event.Category != audit.CategoryUsers {
t.Errorf("unexpected audit event %+v", event)
}
for key, value := range event.Params {
if text, isText := value.(string); isText && strings.Contains(text, "@") {
t.Errorf("params must hold no email, got %s=%v", key, value)
}
}
})
t.Run("An author profile that credits no article is deleted", func(t *testing.T) {
f := newDeleteAccountFixture(t)
f.articles.credits = map[uint32][]uint32{}
rec := f.request(t, 7, deleteAccountBody)
assertDeleteAccountResponse(t, rec, http.StatusNoContent, "")
if _, exists := f.authors.authors[deleteAccountAuthorID]; exists {
t.Error("the author row must be gone")
}
})
t.Run("The avatar of the author profile is deleted with its stored file", func(t *testing.T) {
f := newDeleteAccountFixture(t)
rec := f.request(t, 7, deleteAccountBody)
assertDeleteAccountResponse(t, rec, http.StatusNoContent, "")
if _, exists := f.images.images[deleteAccountAvatarID]; exists {
t.Error("the avatar image row must be gone")
}
if !slices.Contains(f.storage.deleted, deleteAccountAvatar) {
t.Errorf("expected the stored file to be deleted, got %v", f.storage.deleted)
}
})
t.Run("An avatar that is used elsewhere is kept", func(t *testing.T) {
cases := []struct {
name string
prepare func(f *deleteAccountFixture)
}{
{"an article body", func(f *deleteAccountFixture) {
stored := f.articles.articles[1]
stored.Body = "<img src=\"" + image.PublicPath(deleteAccountAvatar) + "\">"
f.articles.articles[1] = stored
}},
{"another author", func(f *deleteAccountFixture) {
f.authors.seed(11, "Jonas", "jonas")
f.authors.update(11, func(a *author.Author) { a.AvatarID = idPointer(deleteAccountAvatarID) })
}},
{"an article cover", func(f *deleteAccountFixture) {
stored := f.articles.articles[2]
stored.CoverImageID = idPointer(deleteAccountAvatarID)
f.articles.articles[2] = stored
}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
f := newDeleteAccountFixture(t)
c.prepare(f)
rec := f.request(t, 7, deleteAccountBody)
assertDeleteAccountResponse(t, rec, http.StatusNoContent, "")
if _, exists := f.images.images[deleteAccountAvatarID]; !exists {
t.Error("the shared avatar image must stay")
}
if len(f.storage.deleted) != 0 {
t.Errorf("no file may be deleted, got %v", f.storage.deleted)
}
})
}
})
t.Run("A storage failure after the commit is logged and the answer stays 204", func(t *testing.T) {
f := newDeleteAccountFixture(t)
f.storage.failDelete = errRepositoryFailure
var logged bytes.Buffer
previous := log.Writer()
log.SetOutput(&logged)
defer log.SetOutput(previous)
rec := f.request(t, 7, deleteAccountBody)
assertDeleteAccountResponse(t, rec, http.StatusNoContent, "")
if _, exists := f.users.users[7]; exists {
t.Error("the user must be gone")
}
if !strings.Contains(logged.String(), "stored avatar") {
t.Errorf("expected the storage failure in the log, got %q", logged.String())
}
})
t.Run("A user without an avatar deletes no file", func(t *testing.T) {
f := newDeleteAccountFixture(t)
f.authors.update(deleteAccountAuthorID, func(a *author.Author) { a.AvatarID = nil })
rec := f.request(t, 7, deleteAccountBody)
assertDeleteAccountResponse(t, rec, http.StatusNoContent, "")
if len(f.storage.deleted) != 0 {
t.Errorf("no file may be deleted, got %v", f.storage.deleted)
}
})
t.Run("Refused requests change nothing", func(t *testing.T) {
validator.InitValidator()
confirmationMessage := validator.Validate.Struct(user.DeleteAccountRequest{CurrentPassword: "x", Confirmation: "delete"}).Error()
emptyConfirmation := validator.Validate.Struct(user.DeleteAccountRequest{CurrentPassword: "x"}).Error()
passwordMessage := validator.Validate.Struct(user.DeleteAccountRequest{Confirmation: "DELETE"}).Error()
cases := []struct {
name string
body string
status int
text string
}{
{"lowercase confirmation", `{"current_password": "limewash2026", "confirmation": "delete"}`, 400, confirmationMessage},
{"empty confirmation", `{"current_password": "limewash2026", "confirmation": ""}`, 400, emptyConfirmation},
{"trailing space confirmation", `{"current_password": "limewash2026", "confirmation": "DELETE "}`, 400, confirmationMessage},
{"empty password", `{"current_password": "", "confirmation": "DELETE"}`, 400, passwordMessage},
{"not json", `not-json`, 400, "The request cannot be processed due to a mismatch in the format of the body."},
{"wrong password", `{"current_password": "nope", "confirmation": "DELETE"}`, 403, "wrong_password"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
f := newDeleteAccountFixture(t)
before := f.state()
rec := f.request(t, 7, c.body)
assertDeleteAccountResponse(t, rec, c.status, c.text)
f.assertUntouchedSince(t, before)
})
}
})
t.Run("The last Owner or administrator is blocked", func(t *testing.T) {
cases := []struct {
name string
caller uint32
prepare func(f *deleteAccountFixture)
status int
header string
}{
{"Owner, no other Owner, one Admin", 3, func(f *deleteAccountFixture) {}, 409, "Owner"},
{"Owner, no other Owner, no Admin", 3, func(f *deleteAccountFixture) { delete(f.users.users, 2) }, 409, "Owner"},
{"Owner, another verified Owner", 3, func(f *deleteAccountFixture) {
f.users.users[4] = domain.User{ID: 4, Username: "ana", IsVerified: true, RoleID: ownerRoleID}
}, 204, ""},
{"Owner, only an unverified Owner", 3, func(f *deleteAccountFixture) {
f.users.users[4] = domain.User{ID: 4, Username: "ana", IsVerified: false, RoleID: ownerRoleID}
}, 409, "Owner"},
{"Admin, no Owner and no other Admin", 2, func(f *deleteAccountFixture) { delete(f.users.users, 3) }, 409, "Admin"},
{"Admin, one Owner", 2, func(f *deleteAccountFixture) {}, 204, ""},
{"Admin, another verified Admin and no Owner", 2, func(f *deleteAccountFixture) {
delete(f.users.users, 3)
f.users.users[4] = domain.User{ID: 4, Username: "ana", IsVerified: true, RoleID: adminRoleID}
}, 204, ""},
{"Admin, only an unverified Admin and no Owner", 2, func(f *deleteAccountFixture) {
delete(f.users.users, 3)
f.users.users[4] = domain.User{ID: 4, Username: "ana", IsVerified: false, RoleID: adminRoleID}
}, 409, "Admin"},
{"Writer, no Owner and no Admin", 7, func(f *deleteAccountFixture) {
delete(f.users.users, 3)
delete(f.users.users, 2)
}, 204, ""},
{"a custom role with every admin permission, no Owner and no Admin", 7, func(f *deleteAccountFixture) {
delete(f.users.users, 3)
delete(f.users.users, 2)
custom, _ := f.roles.Create(role.Role{Name: "Everything but owner", Grants: f.roles.byName(role.AdminName).Grants})
caller := f.users.users[7]
caller.RoleID = custom.ID
f.users.users[7] = caller
}, 204, ""},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
f := newDeleteAccountFixture(t)
c.prepare(f)
before := f.state()
rec := f.request(t, c.caller, deleteAccountBody)
if rec.Code != c.status {
t.Fatalf("expected %d, got %d: %s", c.status, rec.Code, rec.Body.String())
}
if c.status == 409 {
assertDeleteAccountResponse(t, rec, 409, "last_admin")
f.assertUntouchedSince(t, before)
}
if got := rec.Header().Get("X-Last-Role"); got != c.header {
t.Errorf("expected X-Last-Role %q, got %q", c.header, got)
}
})
}
})
t.Run("A blocked attempt leaves everything and records a failed event", func(t *testing.T) {
f := newDeleteAccountFixture(t)
before := f.state()
rec := f.request(t, 3, deleteAccountBody)
assertDeleteAccountResponse(t, rec, http.StatusConflict, "last_admin")
f.assertUntouchedSince(t, before)
event := f.lastEvent(t)
if event.Action != "user.delete_account" || event.Outcome != audit.OutcomeFailed || !event.Security || event.FailureReason == nil || *event.FailureReason != "Last Owner." {
t.Errorf("unexpected audit event %+v", event)
}
})
t.Run("A failed attempt records the reason on the user's own events", func(t *testing.T) {
f := newDeleteAccountFixture(t)
f.users.users[2] = domain.User{ID: 2, Username: "jonas", Password: "hashed:" + deleteAccountPassword, IsVerified: true, RoleID: adminRoleID}
delete(f.users.users, 3)
wrong := f.request(t, 2, `{"current_password": "nope", "confirmation": "DELETE"}`)
blocked := f.request(t, 2, deleteAccountBody)
if wrong.Code != 403 || blocked.Code != 409 || len(f.recorder.events) != 2 {
t.Fatalf("unexpected outcome %d %d %d", wrong.Code, blocked.Code, len(f.recorder.events))
}
if *f.recorder.events[0].FailureReason != "Wrong password." || *f.recorder.events[1].FailureReason != "Last administrator." {
t.Errorf("unexpected reasons %q %q", *f.recorder.events[0].FailureReason, *f.recorder.events[1].FailureReason)
}
})
t.Run("A wrong password is reported before the last-role check", func(t *testing.T) {
f := newDeleteAccountFixture(t)
rec := f.request(t, 3, `{"current_password": "nope", "confirmation": "DELETE"}`)
assertDeleteAccountResponse(t, rec, http.StatusForbidden, "wrong_password")
if rec.Header().Get("X-Last-Role") != "" {
t.Error("the role must not leak without the password")
}
})
t.Run("A concurrent role change stops the removal", func(t *testing.T) {
f := newDeleteAccountFixture(t)
f.users.users[4] = domain.User{ID: 4, Username: "ana", IsVerified: true, RoleID: ownerRoleID}
f.eraser.beforeErase = func() {
demoted := f.users.users[4]
demoted.RoleID = writerRoleID
f.users.users[4] = demoted
}
before := f.state()
before.users[4] = domain.User{ID: 4, Username: "ana", IsVerified: true, RoleID: writerRoleID}
rec := f.request(t, 3, deleteAccountBody)
assertDeleteAccountResponse(t, rec, http.StatusConflict, "account_not_deletable")
f.assertUntouchedSince(t, before)
if event := f.lastEvent(t); event.Outcome != audit.OutcomeFailed || *event.FailureReason == useCases.GenericFailureReason {
t.Errorf("expected a specific failure reason, got %+v", event)
}
})
t.Run("A mail failure does not undo the deletion", func(t *testing.T) {
f := newDeleteAccountFixture(t)
f.mail.fail.failNext("Send")
var logged bytes.Buffer
previous := log.Writer()
log.SetOutput(&logged)
defer log.SetOutput(previous)
rec := f.request(t, 7, deleteAccountBody)
assertDeleteAccountResponse(t, rec, http.StatusNoContent, "")
if _, exists := f.users.users[7]; exists {
t.Error("the user must be gone")
}
if !strings.Contains(logged.String(), "account deleted mail") {
t.Errorf("expected the failed send in the log, got %q", logged.String())
}
})
t.Run("A failure in the removal rolls everything back", func(t *testing.T) {
for _, step := range []string{"DeleteAPIKeys", "ScrubAuthor", "AnonymiseEvents", "DeleteUser"} {
t.Run(step, func(t *testing.T) {
f := newDeleteAccountFixture(t)
f.eraser.fail.failNext(step)
before := f.state()
rec := f.request(t, 7, deleteAccountBody)
assertDeleteAccountResponse(t, rec, http.StatusInternalServerError, "Internal server error")
f.assertUntouchedSince(t, before)
if event := f.lastEvent(t); event.Outcome != audit.OutcomeFailed || *event.FailureReason != useCases.GenericFailureReason {
t.Errorf("unexpected audit event %+v", event)
}
})
}
})
t.Run("A failing session revocation does not undo the deletion", func(t *testing.T) {
f := newDeleteAccountFixture(t)
f.revoker.failure = errRepositoryFailure
var logged bytes.Buffer
previous := log.Writer()
log.SetOutput(&logged)
defer log.SetOutput(previous)
rec := f.request(t, 7, deleteAccountBody)
assertDeleteAccountResponse(t, rec, http.StatusNoContent, "")
if !strings.Contains(logged.String(), "revoking the session") {
t.Errorf("expected the failed revocation in the log, got %q", logged.String())
}
})
t.Run("Rate limited after five attempts", func(t *testing.T) {
f := newDeleteAccountFixture(t)
for i := 0; i < 5; i++ {
f.request(t, 7, `{"current_password": "nope", "confirmation": "DELETE"}`)
}
before := f.state()
rec := f.request(t, 7, deleteAccountBody)
assertDeleteAccountResponse(t, rec, http.StatusTooManyRequests, "Too many requests. Please try again later.")
if rec.Header().Get("Retry-After") == "" {
t.Error("expected a Retry-After header")
}
f.assertUntouchedSince(t, before)
})
t.Run("Only a real verified session may delete", func(t *testing.T) {
cases := []struct {
name string
status int
text string
build func(f *deleteAccountFixture) *http.Request
}{
{"nobody", 401, "You must be logged in to access this resource.", func(f *deleteAccountFixture) *http.Request {
return httptest.NewRequest(http.MethodDelete, "/v1/user/me", bytes.NewBufferString(deleteAccountBody))
}},
{"an API key bearer", 403, "This action needs a real session.", func(f *deleteAccountFixture) *http.Request {
req := httptest.NewRequest(http.MethodDelete, "/v1/user/me", bytes.NewBufferString(deleteAccountBody))
req.Header.Set("Authorization", "Bearer rcms_anything")
return req
}},
{"an unverified user", 403, "You must verify your account to access this resource.", func(f *deleteAccountFixture) *http.Request {
req := httptest.NewRequest(http.MethodDelete, "/v1/user/me", bytes.NewBufferString(deleteAccountBody))
req.AddCookie(displayNameCookie(t, f.token, 9, 0))
return req
}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
f := newDeleteAccountFixture(t)
before := f.state()
rec := f.serve(c.build(f))
assertDeleteAccountResponse(t, rec, c.status, c.text)
f.assertUntouchedSince(t, before)
})
}
})
t.Run("DELETE /v1/user/{id} is unchanged", func(t *testing.T) {
f := newDeleteAccountFixture(t)
other := f.serve(httptest.NewRequest(http.MethodDelete, "/v1/user/7?expected_role_id=3", nil))
self := f.request(t, 7, deleteAccountBody)
if other.Code != http.StatusNoContent || f.removeHits != 1 {
t.Errorf("expected /{id} to reach its own handler once, got %d and %d hits", other.Code, f.removeHits)
}
if self.Code != http.StatusNoContent || f.removeHits != 1 {
t.Errorf("expected /me to bypass the {id} handler, got %d and %d hits", self.Code, f.removeHits)
}
})
}
func TestAccountDeleteLastAdminErrorContract(t *testing.T) {
owner := domain.LastAdminError{Role: domain.LastRoleOwner}
admin := domain.LastAdminError{Role: domain.LastRoleAdmin}
if owner.Error() != domain.ErrLastOwner.Error() || admin.Error() != domain.ErrLastAdmin.Error() {
t.Errorf("unexpected messages %q / %q", owner.Error(), admin.Error())
}
if !errors.Is(owner, domain.ErrLastOwner) || !errors.Is(owner, domain.ErrLastAdmin) {
t.Error("the last Owner must match both sentinels")
}
if !errors.Is(admin, domain.ErrLastAdmin) || errors.Is(admin, domain.ErrLastOwner) {
t.Error("the last administrator must match only ErrLastAdmin")
}
}
+64
View File
@@ -0,0 +1,64 @@
package test
import (
"RenewCMS/api/handlers/user"
"RenewCMS/internal/domain/author"
domain "RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/config"
"RenewCMS/internal/infrastructure/mailer"
"net/http"
"os"
"reflect"
"regexp"
"strings"
"testing"
)
func readWebSource(t *testing.T, path string) string {
t.Helper()
raw, err := os.ReadFile("../web/src/" + path)
if err != nil {
t.Fatal(err)
}
return string(raw)
}
func TestAccountDeleteLiteralParity(t *testing.T) {
t.Run("Deleted user is the same name in the domain, the audit trail and the SPA", func(t *testing.T) {
if domain.DeletedUserName != author.DeletedName {
t.Errorf("user %q and author %q disagree", domain.DeletedUserName, author.DeletedName)
}
if !strings.Contains(readWebSource(t, "lib/deletedUser.ts"), "DELETED_USER_NAME = '"+domain.DeletedUserName+"'") {
t.Errorf("web/src/lib/deletedUser.ts does not name the user %q", domain.DeletedUserName)
}
if !strings.Contains(readWebSource(t, "i18n/messages.ts"), "'"+domain.DeletedUserName+"':") {
t.Errorf("the i18n catalogue has no entry for %q", domain.DeletedUserName)
}
})
t.Run("The confirmation word is the same in the handler and in the SPA", func(t *testing.T) {
field, _ := reflect.TypeOf(user.DeleteAccountRequest{}).FieldByName("Confirmation")
matched := regexp.MustCompile(`eq=(\w+)`).FindStringSubmatch(field.Tag.Get("validate"))
if matched == nil {
t.Fatalf("the Confirmation tag has no eq= rule: %q", field.Tag)
}
if !strings.Contains(readWebSource(t, "lib/accountDeletion.ts"), "DELETE_WORD = '"+matched[1]+"'") {
t.Errorf("web/src/lib/accountDeletion.ts does not use the word %q", matched[1])
}
})
t.Run("The template the use case sends is the one the mailer renders", func(t *testing.T) {
f := newDeleteAccountFixture(t)
rec := f.request(t, 7, deleteAccountBody)
if rec.Code != http.StatusNoContent || len(f.mail.SentEmails) != 1 {
t.Fatalf("unexpected outcome %d with %d mails", rec.Code, len(f.mail.SentEmails))
}
sent := f.mail.SentEmails[0]
subject, body, err := mailer.NewMailRepository(config.SmtpSettings{}).Render(sent.Template, sent.Data)
if err != nil || body == "" || subject == "RenewCMS | Action required" {
t.Errorf("the mailer does not know %q: subject %q err %v", sent.Template, subject, err)
}
})
}
+267
View File
@@ -0,0 +1,267 @@
package test
import (
"RenewCMS/internal/domain/user"
entity "RenewCMS/internal/infrastructure/persistence/models"
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
"errors"
"fmt"
"sync"
"testing"
"gorm.io/gorm"
)
func seedDeleteAccountRows(t *testing.T, db *gorm.DB, creditedArticles bool) {
t.Helper()
agent, ip := "Firefox", "203.0.113.7"
martaID, inesID := uint32(7), uint32(8)
website := "https://marta.example"
rows := []any{
&entity.User{ID: 7, Username: "marta", Password: "x", Email: "marta@renew.example", IsVerified: true, RoleID: &martaID},
&entity.User{ID: 8, Username: "ines", Password: "x", Email: "ines@renew.example", IsVerified: true, RoleID: &inesID},
&entity.APIKey{ID: 1, UserID: 7, Name: "ci", Prefix: "a", HashedKey: "h1"},
&entity.APIKey{ID: 2, UserID: 7, Name: "deploy", Prefix: "b", HashedKey: "h2"},
&entity.APIKey{ID: 3, UserID: 8, Name: "ines", Prefix: "c", HashedKey: "h3"},
&entity.Author{ID: 5, Name: "Marta Alves", Slug: "marta-alves", Bio: "bio", Website: &website, SocialLinks: `[{"type":"github","url":"https://github.com/marta"}]`, UserID: &martaID, AvatarID: &martaID},
&entity.Author{ID: 6, Name: "Ines", Slug: "ines", SocialLinks: "[]", UserID: &inesID},
&entity.Article{ID: 1, Title: "Mine", Slug: "mine", CreatedBy: 7},
&entity.Entry{ID: 1, ContentTypeID: 1, Data: "{}", CreatedBy: 7, UpdatedBy: 7},
&entity.Image{ID: 1, Path: "/a.png", Filename: "a.png", UploadedByID: 7},
&entity.AuditEvent{ID: 1, Action: "article.edit", Category: "articles", ActorType: "user", ActorID: &martaID, ActorName: "marta", TargetType: "article", TargetID: "1", TargetName: "Mine", Outcome: "success", Source: "web", IP: &ip, UserAgent: &agent, Params: `{"title":"Mine"}`, Changes: `[{"field":"title","kind":"text","after":"Mine"}]`},
&entity.AuditEvent{ID: 2, Action: "user.change_role", Category: "users", ActorType: "user", ActorID: &inesID, ActorName: "ines", TargetType: "user", TargetID: "7", TargetName: "marta", Outcome: "success", Source: "web", IP: &ip, Params: `{"to_name":"marta"}`, Changes: `[{"field":"role","kind":"text","after":"Writer"}]`},
&entity.AuditEvent{ID: 4, Action: "author.edit", Category: "authors", ActorType: "user", ActorID: &martaID, ActorName: "marta", TargetType: "author", TargetID: "5", TargetName: "Marta Alves", Outcome: "success", Source: "web", Params: `{"bio":"Writes about lime"}`, Changes: `[{"field":"bio","kind":"text","after":"Writes about lime"}]`},
&entity.AuditEvent{ID: 5, Action: "author.edit", Category: "authors", ActorType: "user", ActorID: &martaID, ActorName: "marta", TargetType: "author", TargetID: "6", TargetName: "Ines", Outcome: "success", Source: "web", Params: "{}", Changes: "[]"},
&entity.AuditEvent{ID: 6, Action: "author.edit", Category: "authors", ActorType: "user", ActorID: &inesID, ActorName: "ines", TargetType: "author", TargetID: "5", TargetName: "Marta Alves", Outcome: "success", Source: "web", Params: `{"keep":"yes"}`, Changes: "[]"},
&entity.AuditEvent{ID: 3, Action: "article.edit", Category: "articles", ActorType: "user", ActorID: &inesID, ActorName: "ines", TargetType: "article", TargetID: "1", TargetName: "Mine", Outcome: "success", Source: "web", IP: &ip, Params: `{"title":"Mine"}`, Changes: `[{"field":"title","kind":"text","after":"Mine"}]`},
}
if creditedArticles {
rows = append(rows, &entity.ArticleAuthor{ArticleID: 1, AuthorID: 5, Position: 0})
}
for _, row := range rows {
if err := db.Create(row).Error; err != nil {
t.Fatalf("seed %T: %v", row, err)
}
}
}
func deleteAccountRowCount(t *testing.T, db *gorm.DB, model any, query string, args ...any) int64 {
t.Helper()
var total int64
if err := db.Model(model).Where(query, args...).Count(&total).Error; err != nil {
t.Fatal(err)
}
return total
}
func TestAccountDeleteErasureContract(t *testing.T) {
backends := []contractBackend{
{name: "sqlite", open: openContractSQLite},
{name: "mysql", open: openContractMySQL},
{name: "postgres", open: openContractPostgres},
}
for _, backend := range backends {
t.Run(backend.name, func(t *testing.T) {
t.Run("The user, keys, author credit and events are handled in one operation", func(t *testing.T) {
db := backend.open(t)
seedDeleteAccountRows(t, db, true)
err := persistence.NewAccountEraser(db).Erase(user.Erasure{UserID: 7})
if err != nil {
t.Fatal(err)
}
if deleteAccountRowCount(t, db, &entity.User{}, "id = ?", 7) != 0 || deleteAccountRowCount(t, db, &entity.User{}, "id = ?", 8) != 1 {
t.Error("only user 7 must be removed")
}
if deleteAccountRowCount(t, db, &entity.APIKey{}, "user_id = ?", 7) != 0 || deleteAccountRowCount(t, db, &entity.APIKey{}, "user_id = ?", 8) != 1 {
t.Error("only the keys of user 7 must be removed")
}
var scrubbed entity.Author
if err := db.First(&scrubbed, 5).Error; err != nil {
t.Fatal(err)
}
if scrubbed.Name != "Deleted user" || scrubbed.Slug != "deleted-user-5" || scrubbed.Bio != "" || scrubbed.UserID != nil || scrubbed.AvatarID != nil || scrubbed.Website != nil || scrubbed.SocialLinks != "[]" {
t.Errorf("unexpected scrubbed author %+v", scrubbed)
}
var other entity.Author
if err := db.First(&other, 6).Error; err != nil || other.Name != "Ines" || other.UserID == nil {
t.Errorf("the other author must be untouched: %+v %v", other, err)
}
var own, targeted, unrelated entity.AuditEvent
db.First(&own, 1)
db.First(&targeted, 2)
db.First(&unrelated, 3)
if own.ActorName != "Deleted user" || own.IP != nil || own.UserAgent != nil || own.Params != "{}" || own.Changes != "[]" {
t.Errorf("own event not anonymised: %+v", own)
}
if own.Action != "article.edit" || own.Outcome != "success" || own.CreatedAt.IsZero() {
t.Errorf("the event itself must stay: %+v", own)
}
if targeted.TargetName != "Deleted user" || targeted.ActorName != "ines" || targeted.IP == nil || targeted.Params != "{}" || targeted.Changes != "[]" {
t.Errorf("targeted event wrong: %+v", targeted)
}
if unrelated.ActorName != "ines" || unrelated.IP == nil || unrelated.Params != `{"title":"Mine"}` || unrelated.Changes == "[]" {
t.Errorf("unrelated event must be untouched: %+v", unrelated)
}
var ownProfile, otherProfile, byOther entity.AuditEvent
db.First(&ownProfile, 4)
db.First(&otherProfile, 5)
db.First(&byOther, 6)
if ownProfile.TargetName != "Deleted user" || ownProfile.Params != "{}" || ownProfile.Changes != "[]" {
t.Errorf("an author event on the own profile must lose the name and its content: %+v", ownProfile)
}
if otherProfile.TargetName != "Ines" || otherProfile.ActorName != "Deleted user" {
t.Errorf("an author event on another person's profile keeps its target: %+v", otherProfile)
}
if byOther.TargetName != "Marta Alves" || byOther.Params != `{"keep":"yes"}` {
t.Errorf("an event of another user on this author is not erased: %+v", byOther)
}
if deleteAccountRowCount(t, db, &entity.Article{}, "created_by = ?", 7) != 1 || deleteAccountRowCount(t, db, &entity.Entry{}, "created_by = ?", 7) != 1 || deleteAccountRowCount(t, db, &entity.Image{}, "uploaded_by_id = ?", 7) != 1 {
t.Error("articles, entries and images keep the old id")
}
})
t.Run("The avatar image row is deleted in the same operation", func(t *testing.T) {
db := backend.open(t)
seedDeleteAccountRows(t, db, true)
if err := db.Create(&entity.Image{ID: 9, Path: "/avatar.png", Filename: "avatar.png", UploadedByID: 8}).Error; err != nil {
t.Fatal(err)
}
avatar := uint32(9)
err := persistence.NewAccountEraser(db).Erase(user.Erasure{UserID: 7, AvatarImageID: &avatar})
if err != nil || deleteAccountRowCount(t, db, &entity.Image{}, "id = ?", 9) != 0 || deleteAccountRowCount(t, db, &entity.Image{}, "id = ?", 1) != 1 {
t.Errorf("expected only the avatar image to be gone, got %v", err)
}
})
t.Run("A scrubbed author whose deleted-user slug is taken gets a free suffix", func(t *testing.T) {
db := backend.open(t)
seedDeleteAccountRows(t, db, true)
if err := db.Create(&entity.Author{ID: 20, Name: "Legacy", Slug: "deleted-user-5", SocialLinks: "[]"}).Error; err != nil {
t.Fatal(err)
}
err := persistence.NewAccountEraser(db).Erase(user.Erasure{UserID: 7})
var scrubbed entity.Author
db.First(&scrubbed, 5)
if err != nil || scrubbed.Slug != "deleted-user-5-2" {
t.Errorf("expected deleted-user-5-2, got %q %v", scrubbed.Slug, err)
}
})
t.Run("A failure on the audit step restores the avatar image too", func(t *testing.T) {
db := backend.open(t)
seedDeleteAccountRows(t, db, true)
if err := db.Create(&entity.Image{ID: 9, Path: "/avatar.png", Filename: "avatar.png", UploadedByID: 8}).Error; err != nil {
t.Fatal(err)
}
if err := db.Migrator().DropTable(&entity.AuditEvent{}); err != nil {
t.Fatal(err)
}
avatar := uint32(9)
err := persistence.NewAccountEraser(db).Erase(user.Erasure{UserID: 7, AvatarImageID: &avatar})
if err == nil || deleteAccountRowCount(t, db, &entity.Image{}, "id = ?", 9) != 1 {
t.Errorf("expected a rollback, got %v", err)
}
})
t.Run("Concurrent self-deletions never leave the workspace without a verified Owner", func(t *testing.T) {
db := backend.open(t)
var owner entity.Role
if err := db.Where("name = ?", "Owner").First(&owner).Error; err != nil {
t.Fatal(err)
}
const owners = 4
for id := uint32(1); id <= owners; id++ {
name := fmt.Sprintf("owner%d", id)
row := &entity.User{ID: id, Username: name, Password: "x", Email: name + "@renew.example", IsVerified: true, RoleID: &owner.ID}
if err := db.Create(row).Error; err != nil {
t.Fatal(err)
}
}
eraser := persistence.NewAccountEraser(db)
results := make(chan error, owners)
var start sync.WaitGroup
start.Add(1)
for id := uint32(1); id <= owners; id++ {
go func() {
start.Wait()
results <- eraser.Erase(user.Erasure{UserID: id, GuardRoleIDs: []uint32{owner.ID}})
}()
}
start.Done()
deleted := 0
for i := 0; i < owners; i++ {
err := <-results
if err == nil {
deleted++
} else if !errors.Is(err, user.ErrAccountNotDeletable) {
t.Errorf("a loser must answer ErrAccountNotDeletable, got %v", err)
}
}
remaining := deleteAccountRowCount(t, db, &entity.User{}, "role_id = ? AND is_verified = ?", owner.ID, true)
if remaining < 1 || int(remaining)+deleted != owners {
t.Errorf("expected at least one Owner left and a consistent count, got %d remaining after %d deletions", remaining, deleted)
}
})
t.Run("An author that credits no article is deleted", func(t *testing.T) {
db := backend.open(t)
seedDeleteAccountRows(t, db, false)
err := persistence.NewAccountEraser(db).Erase(user.Erasure{UserID: 7})
if err != nil || deleteAccountRowCount(t, db, &entity.Author{}, "id = ?", 5) != 0 || deleteAccountRowCount(t, db, &entity.Author{}, "id = ?", 6) != 1 {
t.Errorf("expected only author 5 to be gone, got %v", err)
}
})
t.Run("The role holders are counted again inside the operation", func(t *testing.T) {
db := backend.open(t)
seedDeleteAccountRows(t, db, true)
eraser := persistence.NewAccountEraser(db)
refused := eraser.Erase(user.Erasure{UserID: 7, GuardRoleIDs: []uint32{7}})
allowed := eraser.Erase(user.Erasure{UserID: 7, GuardRoleIDs: []uint32{8}})
if !errors.Is(refused, user.ErrAccountNotDeletable) {
t.Errorf("expected ErrAccountNotDeletable, got %v", refused)
}
if allowed != nil || deleteAccountRowCount(t, db, &entity.User{}, "id = ?", 7) != 0 {
t.Errorf("expected the removal once another verified holder exists, got %v", allowed)
}
})
t.Run("A refused re-check removes nothing", func(t *testing.T) {
db := backend.open(t)
seedDeleteAccountRows(t, db, true)
err := persistence.NewAccountEraser(db).Erase(user.Erasure{UserID: 7, GuardRoleIDs: []uint32{7}})
if !errors.Is(err, user.ErrAccountNotDeletable) || deleteAccountRowCount(t, db, &entity.User{}, "id = ?", 7) != 1 || deleteAccountRowCount(t, db, &entity.APIKey{}, "user_id = ?", 7) != 2 {
t.Errorf("expected nothing removed, got %v", err)
}
})
t.Run("A failure half way rolls everything back", func(t *testing.T) {
db := backend.open(t)
seedDeleteAccountRows(t, db, true)
if err := db.Migrator().DropTable(&entity.AuditEvent{}); err != nil {
t.Fatal(err)
}
err := persistence.NewAccountEraser(db).Erase(user.Erasure{UserID: 7})
if err == nil {
t.Fatal("expected the missing events table to fail the operation")
}
var author entity.Author
if db.First(&author, 5).Error != nil || author.Name != "Marta Alves" || author.UserID == nil {
t.Errorf("the author must be restored: %+v", author)
}
if deleteAccountRowCount(t, db, &entity.User{}, "id = ?", 7) != 1 || deleteAccountRowCount(t, db, &entity.APIKey{}, "user_id = ?", 7) != 2 {
t.Error("the user and the keys must be restored")
}
})
})
}
}
+71
View File
@@ -0,0 +1,71 @@
package test
import (
"RenewCMS/internal/domain/author"
"RenewCMS/internal/infrastructure/useCases"
"context"
"errors"
"testing"
)
func TestAccountDeleteReservedAuthorSlug(t *testing.T) {
t.Run("The deleted-user prefix is not a valid author slug", func(t *testing.T) {
for slug, valid := range map[string]bool{"deleted-user-5": false, "deleted-user-": false, "deleted-user": true, "deleted-users-5": true, "marta": true} {
if author.IsValidSlug(slug) != valid {
t.Errorf("%q: expected valid=%v", slug, valid)
}
}
})
t.Run("The reserved prefix is detected exactly", func(t *testing.T) {
for slug, reserved := range map[string]bool{"deleted-user-": true, "deleted-user-7": true, "deleted-user": false, "": false, "deleted-users": false} {
if author.IsReservedSlug(slug) != reserved {
t.Errorf("%q: expected reserved=%v", slug, reserved)
}
}
})
t.Run("A generated slug never lands on the reserved prefix", func(t *testing.T) {
if got := author.GenerateSlug("Deleted User 5"); author.IsReservedSlug(got) || got != "author-deleted-user-5" {
t.Errorf("unexpected slug %q", got)
}
if got := author.SlugWithSuffix("deleted-user", 2); author.IsReservedSlug(got) {
t.Errorf("suffixed slug %q is reserved", got)
}
})
t.Run("Creating an author with a reserved slug is refused", func(t *testing.T) {
authors := plainAuthors()
uc := useCases.NewCreateAuthorUseCase(authors, newMockUserRepo(), newMockImageRepo(), discardRecorder)
_, err := uc.Create(context.Background(), useCases.CreateAuthorCommand{Name: "Marta", Slug: "deleted-user-9"})
if !errors.Is(err, author.ErrInvalidSlug) || len(authors.authors) != 0 {
t.Errorf("err %v created %d", err, len(authors.authors))
}
})
t.Run("Creating an author named like the reserved prefix gets a safe slug", func(t *testing.T) {
authors := plainAuthors()
uc := useCases.NewCreateAuthorUseCase(authors, newMockUserRepo(), newMockImageRepo(), discardRecorder)
created, err := uc.Create(context.Background(), useCases.CreateAuthorCommand{Name: "Deleted User 9"})
if err != nil || created.Slug != "author-deleted-user-9" {
t.Errorf("slug %q err %v", created.Slug, err)
}
})
t.Run("Changing an author slug to a reserved one is refused", func(t *testing.T) {
authors := plainAuthors()
authors.seed(2, "B", "b")
uc := useCases.NewUpdateAuthorUseCase(authors, newMockUserRepo(), newMockImageRepo(), discardRecorder)
reserved := "deleted-user-2"
_, err := uc.Update(context.Background(), 2, useCases.AuthorCaller{UserID: 9, Grants: authorsEditAllGrants}, author.Patch{Slug: &reserved})
if !errors.Is(err, author.ErrInvalidSlug) {
t.Errorf("err = %v", err)
}
})
}
+100
View File
@@ -0,0 +1,100 @@
package test
import (
"RenewCMS/api/middleware"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestAccountExportDataEmptyCollectionsAreArrays(t *testing.T) {
f := newExportFixture(t)
for index := range f.events.events {
f.events.events[index].Params = nil
}
rec := f.export(t, exportMartaID)
files := openExportArchive(t, rec)
for _, item := range exportList(t, exportJSON(t, files, "authored-content.json"), "articles") {
authors, ok := item.(map[string]any)["authors"].([]any)
if !ok || len(authors) != 0 {
t.Errorf("uncredited article authors = %v, want empty list", item.(map[string]any)["authors"])
}
}
events := exportList(t, exportJSON(t, files, "account-activity.json"), "events")
if len(events) == 0 {
t.Fatal("expected activity events")
}
for _, item := range events {
params, ok := item.(map[string]any)["params"].(map[string]any)
if !ok || len(params) != 0 {
t.Errorf("event params = %v, want empty object", item.(map[string]any)["params"])
}
}
}
func successLimiterStatus(t *testing.T, handler http.Handler) *httptest.ResponseRecorder {
t.Helper()
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
return rec
}
func newSuccessLimiterHandler(clock *exportClock, status *int) http.Handler {
limited := middleware.NewUserSuccessLimiterAt(time.Minute, clock.now)
return limited(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(*status)
}))
}
func TestUserSuccessLimiterStatusBoundary(t *testing.T) {
cases := map[string]struct {
status int
blocked bool
}{
"399 keeps the slot": {399, true},
"400 releases the slot": {400, false},
"500 releases the slot": {500, false},
"200 keeps the slot": {200, true},
"302 keeps the slot": {302, true},
"429 releases the slot": {429, false},
"401 releases the slot": {401, false},
"204 keeps the slot too": {204, true},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
clock := &exportClock{current: time.Date(2026, time.October, 29, 9, 0, 0, 0, time.UTC)}
status := tc.status
handler := newSuccessLimiterHandler(clock, &status)
successLimiterStatus(t, handler)
second := successLimiterStatus(t, handler)
if tc.blocked && second.Code != http.StatusTooManyRequests {
t.Errorf("second call = %d, want 429", second.Code)
}
if !tc.blocked && second.Code != tc.status {
t.Errorf("second call = %d, want handler status %d", second.Code, tc.status)
}
})
}
}
func TestUserSuccessLimiterWindowEdge(t *testing.T) {
clock := &exportClock{current: time.Date(2026, time.October, 29, 9, 0, 0, 0, time.UTC)}
status := http.StatusOK
handler := newSuccessLimiterHandler(clock, &status)
successLimiterStatus(t, handler)
clock.advance(time.Minute - time.Second)
blocked := successLimiterStatus(t, handler)
if blocked.Code != http.StatusTooManyRequests || blocked.Header().Get("Retry-After") != "1" {
t.Errorf("just inside the window = %d retry-after %q, want 429 and 1", blocked.Code, blocked.Header().Get("Retry-After"))
}
clock.advance(time.Second)
if edge := successLimiterStatus(t, handler); edge.Code != http.StatusOK {
t.Errorf("exactly at the window = %d, want 200", edge.Code)
}
}
+528
View File
@@ -0,0 +1,528 @@
package test
import (
"RenewCMS/api/handlers/user"
"RenewCMS/api/middleware"
"RenewCMS/api/pkg/authtoken"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/apikey"
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/contenttype"
"RenewCMS/internal/domain/entry"
"RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/role"
domainUser "RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"archive/zip"
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"slices"
"strconv"
"strings"
"testing"
"time"
"github.com/go-chi/chi/v5"
)
const (
exportMartaID uint32 = 7
exportInesID uint32 = 8
exportUnverifID uint32 = 9
exportTokenVer uint32 = 3
exportSecret = "hashed:secret-hash"
exportRoute = "/v1/user/me/export"
)
var exportFiles = []string{"account-activity.json", "authored-content.json", "profile.json", "uploaded-media.json"}
type exportClock struct{ current time.Time }
func (c *exportClock) now() time.Time { return c.current }
func (c *exportClock) advance(d time.Duration) { c.current = c.current.Add(d) }
type exportAuditFake struct {
events []audit.Event
}
var _ audit.Repository = &exportAuditFake{}
func (f *exportAuditFake) Append(event audit.Event) (audit.Event, error) {
f.events = append(f.events, event)
return event, nil
}
func (f *exportAuditFake) List(filter audit.Filter) (audit.Page, error) {
items := []audit.Event{}
for _, event := range f.events {
if filter.Actor != nil {
if event.Actor.Type != filter.Actor.Type || filter.Actor.ID != nil && (event.Actor.ID == nil || *event.Actor.ID != *filter.Actor.ID) {
continue
}
}
items = append(items, event)
}
return audit.Page{Items: items, Total: len(items)}, nil
}
func (f *exportAuditFake) Summary(audit.SummaryQuery) (audit.Summary, error) {
return audit.Summary{}, nil
}
func (f *exportAuditFake) DeleteOlderThan(time.Time) (int, error) { return 0, nil }
func (f *exportAuditFake) DeleteOldest(int, time.Time) (int, error) { return 0, nil }
func (f *exportAuditFake) SizeBytes() (int64, error) { return 0, nil }
type exportFixture struct {
clock *exportClock
users *mockUserRepo
roles *mockRoleRepo
authors *mockAuthorRepo
articles *mockArticleRepo
entries *mockEntryRepo
images *mockImageRepo
keys *mockAPIKeyRepo
types *mockContentTypeRepo
events *exportAuditFake
recorder *recordingRecorder
token *authtoken.Auth
router *chi.Mux
}
const exportDefaultCap = 2
func newExportFixture(t *testing.T) *exportFixture {
t.Helper()
return newExportFixtureWithCap(t, exportDefaultCap)
}
func newExportFixtureWithCap(t *testing.T, maxConcurrent int) *exportFixture {
t.Helper()
validator.InitValidator()
articles := newMockArticleRepo()
f := &exportFixture{
clock: &exportClock{current: time.Date(2026, time.October, 29, 9, 41, 0, 0, time.UTC)},
users: newMockUserRepo(),
roles: newSeededMockRoleRepo(),
authors: newMockAuthorRepo(articles),
articles: articles,
entries: newMockEntryRepo(),
images: newMockImageRepo(),
keys: newMockAPIKeyRepo(),
types: newMockContentTypeRepo(),
events: &exportAuditFake{},
recorder: &recordingRecorder{},
token: authtoken.New([]byte("test-secret")),
}
editor := f.roles.byName(role.WriterName)
created := time.Date(2026, time.March, 2, 10, 0, 0, 0, time.UTC)
f.users.users[exportMartaID] = domainUser.User{
ID: exportMartaID, Username: "marta", DisplayName: "Marta Alves", Email: "marta@renew.example",
Password: exportSecret, PasswordResetCode: "reset-code-xyz", VerificationCode: "verification-code-xyz",
EmailChangeCode: "email-change-code-xyz", PendingEmail: "marta.new@renew.example", TokenVersion: exportTokenVer,
RoleID: editor.ID, IsVerified: true, CreatedAt: created, UpdatedAt: created,
}
f.users.users[exportInesID] = domainUser.User{
ID: exportInesID, Username: "ines", DisplayName: "Ines Duarte", Email: "ines@renew.example",
Password: "hashed:ines", RoleID: editor.ID, IsVerified: true, TokenVersion: exportTokenVer,
}
f.users.users[exportUnverifID] = domainUser.User{ID: exportUnverifID, Username: "newbie", Email: "newbie@renew.example", Password: "hashed:n", RoleID: editor.ID}
f.users.nextID = exportUnverifID
f.types.types[1] = contenttype.ContentType{ID: 1, Key: "testimonial", Name: "Testimonial"}
f.seedMartaContent()
f.seedInesContent()
getUser := useCases.NewGetUserUseCase(f.users)
handler := user.NewExportHandler(useCases.NewExportUserDataUseCase(
f.users, f.roles, f.authors, f.keys, f.articles, f.entries, f.types, f.images, f.events, f.clock.now, f.recorder, maxConcurrent,
))
f.router = chi.NewRouter()
f.router.Route("/v1/user", func(r chi.Router) {
r.Use(middleware.RequireSession)
r.Use(authtoken.Verifier(f.token))
r.Use(middleware.RequireSignedIn)
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(getUser))
r.Use(middleware.RequireVerifiedUser(getUser))
r.With(middleware.NewUserSuccessLimiterAt(15*time.Minute, f.clock.now)).Get("/me/export", handler.Export)
})
return f
}
func (f *exportFixture) seedMartaContent() {
stamp := time.Date(2026, time.April, 3, 8, 0, 0, 0, time.UTC)
f.articles.articles[1] = article.Article{ID: 1, Title: "Limewash basics", Body: "Mix lime putty.", Slug: "limewash-basics", SiteID: 1, IsOnline: true, CreatedBy: exportMartaID, CreatedAt: stamp, UpdatedAt: stamp}
f.articles.articles[2] = article.Article{ID: 2, Title: "Draft on tadelakt", Body: "Soap and polish.", Slug: "draft-tadelakt", SiteID: 1, CreatedBy: exportMartaID, CreatedAt: stamp, UpdatedAt: stamp}
f.entries.entries[1] = entry.Entry{ID: 1, ContentTypeID: 1, Data: map[string]any{"quote": "Loved it"}, CreatedBy: exportMartaID, UpdatedBy: exportMartaID, CreatedAt: stamp, UpdatedAt: stamp}
for i, name := range []string{"wall.png", "floor.jpg", "ceiling.webp"} {
f.images.images[uint32(i+1)] = image.Image{ID: uint32(i + 1), Path: "/static/uploadedImages/stored-" + name, Filename: name, Size: 2048, ContentType: "image/png", SHA256: strings.Repeat("a", 63) + string(rune('1'+i)), UploadedByID: exportMartaID, CreatedAt: stamp}
}
f.keys.keys[1] = apikey.APIKey{ID: 1, UserID: exportMartaID, Name: "CI deploy", Prefix: "rcms_ab12", HashedKey: "sha-of-the-key", CreatedAt: stamp}
for i := range 5 {
event := audit.NewEvent(audit.ActionLogin, audit.CategoryAuth)
event.ID = uint64(i + 1)
event.CreatedAt = stamp.Add(time.Duration(i) * time.Hour)
event.Actor = audit.Actor{Type: audit.ActorUser, ID: idPointer(exportMartaID), Name: "Marta Alves"}
event.Target = audit.Target{Type: audit.TargetUser, ID: "7", Name: "Marta Alves"}
event.Source = audit.SourceWeb
f.events.events = append(f.events.events, event)
}
}
func (f *exportFixture) seedInesContent() {
stamp := time.Date(2026, time.May, 3, 8, 0, 0, 0, time.UTC)
f.articles.articles[3] = article.Article{ID: 3, Title: "Ines private plan", Body: "Secret ines text", Slug: "ines-plan", SiteID: 1, CreatedBy: exportInesID, CreatedAt: stamp, UpdatedAt: stamp}
f.entries.entries[2] = entry.Entry{ID: 2, ContentTypeID: 1, Data: map[string]any{"quote": "Ines quote"}, CreatedBy: exportInesID, UpdatedBy: exportInesID, CreatedAt: stamp, UpdatedAt: stamp}
f.images.images[10] = image.Image{ID: 10, Path: "/static/uploadedImages/ines.png", Filename: "ines.png", Size: 10, ContentType: "image/png", UploadedByID: exportInesID, CreatedAt: stamp}
f.keys.keys[2] = apikey.APIKey{ID: 2, UserID: exportInesID, Name: "ines key", Prefix: "rcms_zz99", HashedKey: "ines-hash", CreatedAt: stamp}
targeted := audit.NewEvent(audit.ActionUserChangeRole, audit.CategoryUsers)
targeted.ID = 100
targeted.CreatedAt = stamp
targeted.Actor = audit.Actor{Type: audit.ActorUser, ID: idPointer(exportMartaID), Name: "Marta Alves"}
targeted.Target = audit.Target{Type: audit.TargetUser, ID: "8", Name: "Ines Duarte"}
targeted.Source = audit.SourceWeb
inesOwn := audit.NewEvent(audit.ActionLogin, audit.CategoryAuth)
inesOwn.ID = 101
inesOwn.Actor = audit.Actor{Type: audit.ActorUser, ID: idPointer(exportInesID), Name: "Ines Duarte"}
inesOwn.Source = audit.SourceWeb
anonymous := audit.NewEvent(audit.ActionLoginFailed, audit.CategoryAuth)
anonymous.ID = 102
anonymous.Actor = audit.AnonymousActor("marta")
anonymous.Outcome = audit.OutcomeFailed
anonymous.Source = audit.SourceWeb
f.events.events = append(f.events.events, targeted, inesOwn, anonymous)
}
func (f *exportFixture) export(t *testing.T, userID uint32) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodGet, exportRoute, nil)
req.AddCookie(displayNameCookie(t, f.token, userID, exportTokenVer))
rec := httptest.NewRecorder()
f.router.ServeHTTP(rec, req)
return rec
}
func openExportArchive(t *testing.T, rec *httptest.ResponseRecorder) map[string][]byte {
t.Helper()
body := rec.Body.Bytes()
reader, err := zip.NewReader(bytes.NewReader(body), int64(len(body)))
if err != nil {
t.Fatalf("response is not a zip: %v", err)
}
files := map[string][]byte{}
for _, file := range reader.File {
opened, err := file.Open()
if err != nil {
t.Fatalf("open %s: %v", file.Name, err)
}
content, err := io.ReadAll(opened)
_ = opened.Close()
if err != nil {
t.Fatalf("read %s: %v", file.Name, err)
}
files[file.Name] = content
}
return files
}
func exportJSON(t *testing.T, files map[string][]byte, name string) map[string]any {
t.Helper()
var decoded map[string]any
if err := json.Unmarshal(files[name], &decoded); err != nil {
t.Fatalf("%s is not a JSON object: %v", name, err)
}
return decoded
}
func exportList(t *testing.T, decoded map[string]any, key string) []any {
t.Helper()
list, ok := decoded[key].([]any)
if !ok {
t.Fatalf("expected a list under %q, got %T", key, decoded[key])
}
return list
}
func collectKeys(value any, into *[]string) {
switch typed := value.(type) {
case map[string]any:
for key, nested := range typed {
*into = append(*into, key)
collectKeys(nested, into)
}
case []any:
for _, nested := range typed {
collectKeys(nested, into)
}
}
}
func assertExportStatusAndBody(t *testing.T, rec *httptest.ResponseRecorder, status int, body string) {
t.Helper()
if rec.Code != status || strings.TrimSpace(rec.Body.String()) != body {
t.Fatalf("expected %d %q, got %d %q", status, body, rec.Code, rec.Body.String())
}
}
func TestAccountExportDataEndpointHTTP(t *testing.T) {
t.Run("A first export returns the archive", func(t *testing.T) {
f := newExportFixture(t)
rec := f.export(t, exportMartaID)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
if got := rec.Header().Get("Content-Disposition"); got != `attachment; filename="renewcms-export-marta-2026-10-29.zip"` {
t.Errorf("Content-Disposition %q", got)
}
if rec.Header().Get("Content-Type") != "application/zip" || rec.Header().Get("Cache-Control") != "no-store" {
t.Errorf("headers %v", rec.Header())
}
if rec.Header().Get("Content-Length") != strconv.Itoa(rec.Body.Len()) {
t.Errorf("Content-Length %q, body %d", rec.Header().Get("Content-Length"), rec.Body.Len())
}
files := openExportArchive(t, rec)
names := []string{}
for name := range files {
names = append(names, name)
}
slices.Sort(names)
if !slices.Equal(names, exportFiles) {
t.Fatalf("files %v", names)
}
profile := exportJSON(t, files, "profile.json")
if profile["username"] != "marta" || profile["email"] != "marta@renew.example" || profile["pending_email"] != "marta.new@renew.example" {
t.Errorf("profile %v", profile)
}
if roleBlock, _ := profile["role"].(map[string]any); roleBlock["name"] != role.WriterName {
t.Errorf("role %v", profile["role"])
}
if profile["author"] != nil {
t.Errorf("author should be null, got %v", profile["author"])
}
keys := exportList(t, profile, "api_keys")
if len(keys) != 1 || keys[0].(map[string]any)["prefix"] != "rcms_ab12" || keys[0].(map[string]any)["name"] != "CI deploy" {
t.Errorf("api keys %v", keys)
}
authored := exportJSON(t, files, "authored-content.json")
if len(exportList(t, authored, "articles")) != 2 || len(exportList(t, authored, "entries")) != 1 {
t.Errorf("authored %v", authored)
}
entryBlock := exportList(t, authored, "entries")[0].(map[string]any)
if entryBlock["content_type"] != "testimonial" {
t.Errorf("entry %v", entryBlock)
}
media := exportJSON(t, files, "uploaded-media.json")
references := exportList(t, media, "images")
if len(references) != 3 || references[0].(map[string]any)["mime"] != "image/png" {
t.Errorf("media %v", media)
}
activity := exportJSON(t, files, "account-activity.json")
if got := len(exportList(t, activity, "events")); got != 5 {
t.Errorf("expected 5 events, got %d", got)
}
if len(f.recorder.events) != 1 {
t.Fatalf("expected one audit event, got %+v", f.recorder.events)
}
recorded := f.recorder.events[0]
if recorded.Action != "user.export_data" || recorded.Category != audit.CategoryUsers || recorded.Target.ID != "7" {
t.Errorf("event %+v", recorded)
}
})
t.Run("The archive never contains secrets and is pretty-printed", func(t *testing.T) {
f := newExportFixture(t)
files := openExportArchive(t, f.export(t, exportMartaID))
forbiddenKeys := []string{"password", "token_version", "hashed_key", "verification_code", "password_reset_code", "email_change_code"}
forbiddenText := []string{exportSecret, "reset-code-xyz", "verification-code-xyz", "email-change-code-xyz", "sha-of-the-key"}
for name, content := range files {
var decoded any
if err := json.Unmarshal(content, &decoded); err != nil {
t.Fatalf("%s: %v", name, err)
}
var keys []string
collectKeys(decoded, &keys)
for _, key := range keys {
if slices.Contains(forbiddenKeys, key) || strings.Contains(key, "code") || strings.Contains(key, "token") {
t.Errorf("%s exposes key %q", name, key)
}
}
for _, text := range forbiddenText {
if bytes.Contains(content, []byte(text)) {
t.Errorf("%s contains %q", name, text)
}
}
if !bytes.Contains(content, []byte("\n ")) {
t.Errorf("%s is not pretty-printed", name)
}
}
})
t.Run("A second export within 15 minutes is refused", func(t *testing.T) {
f := newExportFixture(t)
f.export(t, exportMartaID)
f.clock.advance(3 * time.Minute)
listsBefore := f.images.uploaderLists
rec := f.export(t, exportMartaID)
assertExportStatusAndBody(t, rec, http.StatusTooManyRequests, "Too many requests. Please try again later.")
if rec.Header().Get("Retry-After") != "720" {
t.Errorf("Retry-After %q", rec.Header().Get("Retry-After"))
}
if f.images.uploaderLists != listsBefore || len(f.recorder.events) != 1 {
t.Errorf("an archive was built or an event recorded: lists %d events %d", f.images.uploaderLists, len(f.recorder.events))
}
})
t.Run("The slot is per user and expires", func(t *testing.T) {
f := newExportFixture(t)
f.export(t, exportMartaID)
f.clock.advance(15 * time.Minute)
f.export(t, exportInesID)
f.clock.advance(time.Minute)
marta := f.export(t, exportMartaID)
ines := f.export(t, exportInesID)
if marta.Code != http.StatusOK {
t.Errorf("marta expected 200, got %d", marta.Code)
}
if ines.Code != http.StatusTooManyRequests {
t.Errorf("ines expected 429, got %d", ines.Code)
}
})
t.Run("A failed export does not consume the slot", func(t *testing.T) {
f := newExportFixture(t)
f.images.fail.failNext("ListByUploader")
failed := f.export(t, exportMartaID)
if failed.Code != http.StatusInternalServerError || strings.HasPrefix(failed.Header().Get("Content-Type"), "application/zip") || failed.Header().Get("Content-Disposition") != "" {
t.Errorf("expected a plain 500, got %d %v", failed.Code, failed.Header())
}
if strings.Contains(failed.Body.String(), "PK") || len(f.recorder.events) != 0 {
t.Errorf("body %q events %d", failed.Body.String(), len(f.recorder.events))
}
retried := f.export(t, exportMartaID)
if retried.Code != http.StatusOK {
t.Errorf("expected 200 after the failure is gone, got %d", retried.Code)
}
})
t.Run("Only a real verified session may export", func(t *testing.T) {
cases := []struct {
name string
status int
text string
build func(f *exportFixture) *http.Request
}{
{"nobody (no cookie)", http.StatusUnauthorized, "You must be logged in to access this resource.", func(f *exportFixture) *http.Request {
return httptest.NewRequest(http.MethodGet, exportRoute, nil)
}},
{"an API key bearer", http.StatusForbidden, "This action needs a real session.", func(f *exportFixture) *http.Request {
req := httptest.NewRequest(http.MethodGet, exportRoute, nil)
req.Header.Set("Authorization", "Bearer rcms_anything")
return req
}},
{"an unverified user", http.StatusForbidden, "You must verify your account to access this resource.", func(f *exportFixture) *http.Request {
req := httptest.NewRequest(http.MethodGet, exportRoute, nil)
req.AddCookie(displayNameCookie(t, f.token, exportUnverifID, 0))
return req
}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
f := newExportFixture(t)
rec := httptest.NewRecorder()
f.router.ServeHTTP(rec, c.build(f))
assertExportStatusAndBody(t, rec, c.status, c.text)
})
}
})
t.Run("Someone else's data is never included", func(t *testing.T) {
f := newExportFixture(t)
files := openExportArchive(t, f.export(t, exportMartaID))
for name, content := range files {
lowered := strings.ToLower(string(content))
for _, leak := range []string{"ines", "duarte", "secret ines"} {
if strings.Contains(lowered, leak) {
t.Errorf("%s mentions %q", name, leak)
}
}
}
activity := exportJSON(t, files, "account-activity.json")
for _, raw := range exportList(t, activity, "events") {
event := raw.(map[string]any)
if event["action"] == "auth.login_failed" || event["action"] == "user.change_role" {
t.Errorf("unexpected event %v", event)
}
}
})
t.Run("A user with a linked author profile", func(t *testing.T) {
f := newExportFixture(t)
f.authors.seedLinked(1, "Marta Alves", "marta-alves", exportMartaID)
f.articles.articles[4] = article.Article{ID: 4, Title: "Credited piece", Body: "Co-written", Slug: "credited", SiteID: 1, CreatedBy: exportInesID, IsOnline: true}
f.articles.credit(4, 1)
f.articles.credit(3)
files := openExportArchive(t, f.export(t, exportMartaID))
profile := exportJSON(t, files, "profile.json")
authorBlock, _ := profile["author"].(map[string]any)
if authorBlock["slug"] != "marta-alves" {
t.Errorf("author %v", profile["author"])
}
articles := exportList(t, exportJSON(t, files, "authored-content.json"), "articles")
ids := []float64{}
for _, raw := range articles {
ids = append(ids, raw.(map[string]any)["id"].(float64))
}
if !slices.Contains(ids, 4) || len(ids) != 3 {
t.Errorf("article ids %v", ids)
}
})
}
func (f *exportAuditFake) AnonymiseUser(userID uint32, _ *uint32, name string) error {
for i, event := range f.events {
if event.Actor.Type == audit.ActorUser && event.Actor.ID != nil && *event.Actor.ID == userID {
f.events[i].Actor.Name = name
f.events[i].IP = nil
f.events[i].UserAgent = nil
}
if event.Target.Type == audit.TargetUser && event.Target.ID == strconv.FormatUint(uint64(userID), 10) {
f.events[i].Target.Name = name
}
}
return nil
}
@@ -0,0 +1,129 @@
package test
import (
"RenewCMS/internal/domain/article"
entity "RenewCMS/internal/infrastructure/persistence/models"
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
"net/http"
"slices"
"testing"
"gorm.io/gorm"
)
func TestAccountExportDataReadsContract(t *testing.T) {
backends := []contractBackend{
{name: "sqlite", open: openContractSQLite},
{name: "mysql", open: openContractMySQL},
{name: "postgres", open: openContractPostgres},
}
for _, backend := range backends {
t.Run(backend.name, func(t *testing.T) {
t.Run("articles are those created by the user, drafts included, or published ones crediting the user's author, without soft-deleted ones", func(t *testing.T) {
db := backend.open(t)
seedExportRows(t, db)
articles := persistence.NewArticleRepository(db)
authorID := uint32(5)
mine, err := articles.ListForAccount(7, nil)
credited, creditedErr := articles.ListForAccount(7, &authorID)
if err != nil || creditedErr != nil {
t.Fatalf("errors %v %v", err, creditedErr)
}
if got := articleIDs(mine); !slices.Equal(got, []uint32{1, 8}) {
t.Errorf("created by marta %v", got)
}
if got := articleIDs(credited); !slices.Equal(got, []uint32{1, 3, 8}) {
t.Errorf("created or credited %v", got)
}
})
t.Run("entries are those created by the user", func(t *testing.T) {
db := backend.open(t)
seedExportRows(t, db)
entries, err := persistence.NewEntryRepository(db).ListByCreator(7)
if err != nil || len(entries) != 1 || entries[0].ID != 1 {
t.Errorf("entries %+v %v", entries, err)
}
})
t.Run("images are those uploaded by the user without soft-deleted ones", func(t *testing.T) {
db := backend.open(t)
seedExportRows(t, db)
images, err := persistence.NewImageRepository(db).ListByUploader(7)
if err != nil || len(images) != 1 || images[0].ID != 1 {
t.Errorf("images %+v %v", images, err)
}
})
})
}
}
func articleIDs(listed []article.Article) []uint32 {
ids := make([]uint32, 0, len(listed))
for _, item := range listed {
ids = append(ids, item.ID)
}
return ids
}
func seedExportRows(t *testing.T, db *gorm.DB) {
t.Helper()
rows := []any{
&entity.Article{ID: 1, Title: "Mine", Slug: "mine", CreatedBy: 7},
&entity.Article{ID: 2, Title: "Ines", Slug: "ines", CreatedBy: 8},
&entity.Article{ID: 3, Title: "Credited", Slug: "credited", CreatedBy: 8, IsOnline: true},
&entity.Article{ID: 7, Title: "Credited draft", Slug: "credited-draft", CreatedBy: 8},
&entity.Article{ID: 8, Title: "Mine draft", Slug: "mine-draft", CreatedBy: 7},
&entity.Article{ID: 4, Title: "Gone", Slug: "gone", CreatedBy: 7},
&entity.Article{ID: 6, Title: "Credited gone", Slug: "credited-gone", CreatedBy: 8, IsOnline: true},
&entity.ArticleAuthor{ArticleID: 3, AuthorID: 5},
&entity.ArticleAuthor{ArticleID: 7, AuthorID: 5},
&entity.ArticleAuthor{ArticleID: 6, AuthorID: 5},
&entity.Entry{ID: 1, ContentTypeID: 1, Data: "{}", CreatedBy: 7, UpdatedBy: 7},
&entity.Entry{ID: 2, ContentTypeID: 1, Data: "{}", CreatedBy: 8, UpdatedBy: 8},
&entity.Image{ID: 1, Path: "/a.png", Filename: "a.png", UploadedByID: 7},
&entity.Image{ID: 2, Path: "/b.png", Filename: "b.png", UploadedByID: 8},
&entity.Image{ID: 3, Path: "/c.png", Filename: "c.png", UploadedByID: 7},
}
for _, row := range rows {
if err := db.Create(row).Error; err != nil {
t.Fatalf("seed %T: %v", row, err)
}
}
for _, id := range []uint32{4, 6} {
if err := db.Delete(&entity.Article{}, id).Error; err != nil {
t.Fatalf("soft delete article %d: %v", id, err)
}
}
if err := db.Delete(&entity.Image{}, 3).Error; err != nil {
t.Fatalf("soft delete image: %v", err)
}
}
func TestAccountExportDataFilenameHTTP(t *testing.T) {
cases := map[string]string{
"Marta.Alves_2": "renewcms-export-marta-alves-2-2026-10-29.zip",
"ÉLODIE": "renewcms-export-lodie-2026-10-29.zip",
"***": "renewcms-export-user-2026-10-29.zip",
"az09": "renewcms-export-az09-2026-10-29.zip",
}
for username, filename := range cases {
t.Run(username, func(t *testing.T) {
f := newExportFixture(t)
stored := f.users.users[exportMartaID]
stored.Username = username
f.users.users[exportMartaID] = stored
rec := f.export(t, exportMartaID)
expected := `attachment; filename="` + filename + `"`
if rec.Code != http.StatusOK || rec.Header().Get("Content-Disposition") != expected {
t.Errorf("got %d %q, want %q", rec.Code, rec.Header().Get("Content-Disposition"), expected)
}
})
}
}
+231
View File
@@ -0,0 +1,231 @@
package test
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/author"
"RenewCMS/internal/infrastructure/useCases"
"bytes"
"context"
"errors"
"net/http"
"net/http/httptest"
"slices"
"strconv"
"testing"
"time"
)
func exportedArticleIDs(t *testing.T, f *exportFixture) []float64 {
t.Helper()
files := openExportArchive(t, f.export(t, exportMartaID))
ids := []float64{}
for _, raw := range exportList(t, exportJSON(t, files, "authored-content.json"), "articles") {
ids = append(ids, raw.(map[string]any)["id"].(float64))
}
slices.Sort(ids)
return ids
}
func TestAccountExportDataArticleScope(t *testing.T) {
t.Run("a draft by someone else credited to the user is excluded", func(t *testing.T) {
f := newExportFixture(t)
f.authors.seedLinked(1, "Marta Alves", "marta-alves", exportMartaID)
f.articles.articles[4] = article.Article{ID: 4, Title: "Ines draft", CreatedBy: exportInesID}
f.articles.credit(4, 1)
if ids := exportedArticleIDs(t, f); !slices.Equal(ids, []float64{1, 2}) {
t.Errorf("article ids %v", ids)
}
})
t.Run("a published article by someone else credited to the user is included", func(t *testing.T) {
f := newExportFixture(t)
f.authors.seedLinked(1, "Marta Alves", "marta-alves", exportMartaID)
f.articles.articles[4] = article.Article{ID: 4, Title: "Ines published", CreatedBy: exportInesID, IsOnline: true}
f.articles.credit(4, 1)
if ids := exportedArticleIDs(t, f); !slices.Equal(ids, []float64{1, 2, 4}) {
t.Errorf("article ids %v", ids)
}
})
t.Run("the user's own draft is included", func(t *testing.T) {
f := newExportFixture(t)
if ids := exportedArticleIDs(t, f); !slices.Equal(ids, []float64{1, 2}) || f.articles.articles[2].IsOnline {
t.Errorf("article ids %v", ids)
}
})
}
func TestAccountExportDataCoAuthorsAreReduced(t *testing.T) {
f := newExportFixture(t)
f.authors.seedLinked(1, "Marta Alves", "marta-alves", exportMartaID)
coAuthor := f.authors.seed(2, "Dario Costa", "dario-costa")
coAuthor.Bio = "Plasterer from Porto"
coAuthor.Website = new(string)
coAuthor.AvatarID = idPointer(9)
coAuthor.SocialLinks = []author.SocialLink{{URL: "https://example.com/dario"}}
f.authors.authors[2] = coAuthor
f.articles.credit(1, 1, 2)
files := openExportArchive(t, f.export(t, exportMartaID))
var keys []string
collectKeys(exportJSON(t, files, "authored-content.json"), &keys)
for _, key := range keys {
if slices.Contains([]string{"bio", "website", "social_links", "avatar_id"}, key) {
t.Errorf("authored-content.json exposes %q", key)
}
}
first := exportList(t, exportJSON(t, files, "authored-content.json"), "articles")[0].(map[string]any)
credits := first["authors"].([]any)
if len(credits) != 2 || credits[1].(map[string]any)["name"] != "Dario Costa" || credits[1].(map[string]any)["slug"] != "dario-costa" {
t.Errorf("credits %v", credits)
}
profile := exportJSON(t, files, "profile.json")["author"].(map[string]any)
if _, ok := profile["social_links"]; !ok {
t.Errorf("profile.json author must stay complete: %v", profile)
}
}
func TestEventConcernsOtherPerson(t *testing.T) {
ownAuthor := idPointer(5)
targeted := func(kind string, id uint32) audit.Event {
event := audit.NewEvent("x", audit.CategoryUsers)
event.Target = audit.Target{Type: kind, ID: strconv.Itoa(int(id))}
return event
}
cases := []struct {
name string
event audit.Event
own *uint32
want bool
}{
{"own author", targeted(audit.TargetAuthor, 5), ownAuthor, false},
{"another author", targeted(audit.TargetAuthor, 6), ownAuthor, true},
{"an author when the user has none", targeted(audit.TargetAuthor, 5), nil, true},
{"an author target without an id", audit.Event{Target: audit.Target{Type: audit.TargetAuthor, Name: "Dario"}}, ownAuthor, true},
{"the user themself", targeted(audit.TargetUser, 7), ownAuthor, false},
{"another user", targeted(audit.TargetUser, 8), ownAuthor, true},
{"an invite", targeted(audit.TargetInvite, 3), ownAuthor, true},
{"an article", targeted(audit.TargetArticle, 3), ownAuthor, false},
{"no target", audit.Event{}, nil, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := tc.event.ConcernsOtherPerson(7, tc.own); got != tc.want {
t.Errorf("got %v, want %v", got, tc.want)
}
})
}
}
func TestAccountExportDataDropsEventsOnOtherAuthors(t *testing.T) {
f := newExportFixture(t)
f.authors.seedLinked(1, "Marta Alves", "marta-alves", exportMartaID)
own := audit.NewEvent(audit.ActionAuthorEdit, audit.CategoryAuthors)
own.ID, own.Actor = 200, audit.Actor{Type: audit.ActorUser, ID: idPointer(exportMartaID)}
own.Target = audit.NewTarget(audit.TargetAuthor, 1, "Marta Alves")
other := own
other.ID = 201
other.Target = audit.NewTarget(audit.TargetAuthor, 2, "Dario Costa")
f.events.events = append(f.events.events, own, other)
files := openExportArchive(t, f.export(t, exportMartaID))
ids := []float64{}
for _, raw := range exportList(t, exportJSON(t, files, "account-activity.json"), "events") {
ids = append(ids, raw.(map[string]any)["id"].(float64))
}
if !slices.Contains(ids, 200) || slices.Contains(ids, 201) {
t.Errorf("event ids %v", ids)
}
}
func TestAccountExportDataEncodesStraightIntoTheArchive(t *testing.T) {
f := newExportFixture(t)
files := openExportArchive(t, f.export(t, exportMartaID))
for name, content := range files {
if !bytes.HasSuffix(content, []byte("}\n")) || bytes.HasSuffix(content, []byte("\n\n")) || !bytes.HasPrefix(content, []byte("{\n \"")) {
t.Errorf("%s is not indented JSON ending with a single newline", name)
}
}
}
func TestAccountExportDataConcurrencyCap(t *testing.T) {
newBlocked := func(t *testing.T) (*exportFixture, chan struct{}, chan struct{}) {
f := newExportFixtureWithCap(t, 1)
entered := make(chan struct{}, 1)
release := make(chan struct{})
once := false
f.images.onListByUploader = func() {
if once {
return
}
once = true
entered <- struct{}{}
<-release
}
return f, entered, release
}
t.Run("a build waits for a free slot and gives up when its request ends", func(t *testing.T) {
f, entered, release := newBlocked(t)
firstDone := make(chan *httptest.ResponseRecorder)
go func() { firstDone <- f.export(t, exportMartaID) }()
<-entered
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond)
defer cancel()
req := httptest.NewRequestWithContext(ctx, http.MethodGet, exportRoute, nil)
req.AddCookie(displayNameCookie(t, f.token, exportInesID, exportTokenVer))
waiting := httptest.NewRecorder()
f.router.ServeHTTP(waiting, req)
if waiting.Code != http.StatusServiceUnavailable {
t.Errorf("waiting build = %d, want 503", waiting.Code)
}
close(release)
if first := <-firstDone; first.Code != http.StatusOK {
t.Errorf("first build = %d", first.Code)
}
if len(f.recorder.events) != 1 {
t.Errorf("events %d, want only the first build's", len(f.recorder.events))
}
if retried := f.export(t, exportInesID); retried.Code != http.StatusOK {
t.Errorf("refused user must keep the slot free, got %d", retried.Code)
}
})
t.Run("the slot is released when a build fails", func(t *testing.T) {
f := newExportFixtureWithCap(t, 1)
f.images.fail.failNext("ListByUploader")
failed := f.export(t, exportMartaID)
next := f.export(t, exportMartaID)
if failed.Code != http.StatusInternalServerError || next.Code != http.StatusOK {
t.Errorf("failed %d then %d", failed.Code, next.Code)
}
})
t.Run("the use case returns the context error", func(t *testing.T) {
f, entered, release := newBlocked(t)
usecase := useCases.NewExportUserDataUseCase(f.users, f.roles, f.authors, f.keys, f.articles, f.entries, f.types, f.images, f.events, f.clock.now, f.recorder, 1)
f.images.onListByUploader = func() { entered <- struct{}{}; <-release }
done := make(chan error)
go func() { _, err := usecase.Export(context.Background(), exportMartaID); done <- err }()
<-entered
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err := usecase.Export(ctx, exportInesID)
if !errors.Is(err, context.Canceled) {
t.Errorf("err %v", err)
}
close(release)
if err := <-done; err != nil {
t.Errorf("first build %v", err)
}
})
}
+2 -2
View File
@@ -69,14 +69,14 @@ func TestActivityDomain(t *testing.T) {
}) })
} }
t.Run("The repository exposes no way to edit or delete one event", func(t *testing.T) { t.Run("The repository exposes no way to edit or delete one event, only to anonymise a user's events", func(t *testing.T) {
repoType := reflect.TypeOf((*audit.Repository)(nil)).Elem() repoType := reflect.TypeOf((*audit.Repository)(nil)).Elem()
var names []string var names []string
for i := 0; i < repoType.NumMethod(); i++ { for i := 0; i < repoType.NumMethod(); i++ {
names = append(names, repoType.Method(i).Name) names = append(names, repoType.Method(i).Name)
} }
sort.Strings(names) 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) { if !reflect.DeepEqual(names, wanted) {
t.Errorf("expected methods %v, got %v", wanted, names) t.Errorf("expected methods %v, got %v", wanted, names)
} }
+2 -2
View File
@@ -94,7 +94,7 @@ func TestActivityPersistence(t *testing.T) {
} }
found[strings.Join(parts, ",")] = true 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] { if !found[wanted] {
t.Errorf("missing index on (%s), have %v", wanted, found) 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) names = append(names, repoType.Method(i).Name)
} }
sort.Strings(names) 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) { if !reflect.DeepEqual(names, wanted) {
t.Errorf("expected %v, got %v", wanted, names) t.Errorf("expected %v, got %v", wanted, names)
} }
+2
View File
@@ -621,3 +621,5 @@ func mustMarshalChanges(t *testing.T, event audit.Event) []byte {
} }
return encoded return encoded
} }
func (f *sizedEventsFake) AnonymiseUser(uint32, *uint32, string) error { return nil }
+4
View File
@@ -185,3 +185,7 @@ func retentionDays(days int) useCases.UpdateActivityRetentionCommand {
func retentionMaxGB(maxGB int) useCases.UpdateActivityRetentionCommand { func retentionMaxGB(maxGB int) useCases.UpdateActivityRetentionCommand {
return useCases.UpdateActivityRetentionCommand{MaxGB: &maxGB} return useCases.UpdateActivityRetentionCommand{MaxGB: &maxGB}
} }
func (p *activityProbeRepo) AnonymiseUser(userID uint32, ownAuthorID *uint32, name string) error {
return p.inner.AnonymiseUser(userID, ownAuthorID, name)
}
+6
View File
@@ -50,9 +50,15 @@ func routerRoutes(t *testing.T) map[string]bool {
} }
var routesNotYetInTheCatalogue = []string{ 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/role/{id}",
"DELETE /v1/user/{id}", "DELETE /v1/user/{id}",
"DELETE /v1/user/me",
"GET /v1/user/lookup", "GET /v1/user/lookup",
"GET /v1/user/me/export",
"GET /v1/permission", "GET /v1/permission",
"GET /v1/role", "GET /v1/role",
"GET /v1/role/{id}", "GET /v1/role/{id}",
+15
View File
@@ -5,6 +5,7 @@ import (
"RenewCMS/internal/domain/author" "RenewCMS/internal/domain/author"
"RenewCMS/internal/infrastructure/useCases" "RenewCMS/internal/infrastructure/useCases"
"errors" "errors"
"slices"
"sort" "sort"
"strings" "strings"
"testing" "testing"
@@ -52,6 +53,20 @@ func (m *mockArticleRepo) withAuthors(a article.Article) article.Article {
return a 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) { func (m *mockArticleRepo) credit(articleID uint32, authorIDs ...uint32) {
m.credits[articleID] = append([]uint32{}, authorIDs...) m.credits[articleID] = append([]uint32{}, authorIDs...)
} }
+15
View File
@@ -112,6 +112,7 @@ type mockEntryRepo struct {
failUpdateOn int failUpdateOn int
updatedIDs []uint32 updatedIDs []uint32
afterListPage func() afterListPage func()
fail failSwitch
} }
var _ entry.Repository = &mockEntryRepo{} 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) { func (m *mockEntryRepo) Update(record entry.Entry) (entry.Entry, error) {
m.updateCalls++ m.updateCalls++
m.updatedIDs = append(m.updatedIDs, record.ID) m.updatedIDs = append(m.updatedIDs, record.ID)
+21
View File
@@ -92,6 +92,27 @@ func TestGetCurrentUserHttp(t *testing.T) {
} }
}) })
t.Run("The profile carries pending_email, null when nothing is pending", func(t *testing.T) {
token := authtoken.New([]byte("test-secret"))
userRepo := newMockUserRepo()
userRepo.users[7] = user.User{ID: 7, Username: "marta", Email: "marta@renew.example", IsVerified: true, RoleID: ownerRoleID}
userRepo.users[8] = user.User{ID: 8, Username: "ines", Email: "ines@renew.example", IsVerified: true, RoleID: ownerRoleID, PendingEmail: "new@renew.example"}
router := newGetCurrentUserRouter(userRepo, &stubHasher{}, token)
none := sendGetCurrentUserRequest(router, http.MethodGet, "/v1/auth/me", mintGetCurrentUserCookie(t, token, 7, 0))
pending := sendGetCurrentUserRequest(router, http.MethodGet, "/v1/auth/me", mintGetCurrentUserCookie(t, token, 8, 0))
var noneBody, pendingBody map[string]any
_ = json.Unmarshal(none.Body.Bytes(), &noneBody)
_ = json.Unmarshal(pending.Body.Bytes(), &pendingBody)
if value, present := noneBody["pending_email"]; !present || value != nil {
t.Errorf("expected pending_email null, got %v (present %v)", value, present)
}
if pendingBody["pending_email"] != "new@renew.example" {
t.Errorf("expected the pending address, got %v", pendingBody["pending_email"])
}
})
t.Run("An unverified user still fetches their own profile, with is_verified false, not a 403", func(t *testing.T) { 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")) token := authtoken.New([]byte("test-secret"))
userRepo := newMockUserRepo() userRepo := newMockUserRepo()
+19
View File
@@ -83,6 +83,8 @@ type mockImageRepo struct {
onDelete func(id uint32) onDelete func(id uint32)
storedNameLookups int storedNameLookups int
uploaderLists int
onListByUploader func()
} }
func newMockImageRepo() *mockImageRepo { func newMockImageRepo() *mockImageRepo {
@@ -114,6 +116,23 @@ func (m *mockImageRepo) Create(input imageDomain.Image) (imageDomain.Image, erro
return img, nil 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) { func (m *mockImageRepo) ListMissingMetadata() ([]imageDomain.Image, error) {
if err := m.fail.trigger("ListMissingMetadata"); err != nil { if err := m.fail.trigger("ListMissingMetadata"); err != nil {
return nil, err return nil, err
+8
View File
@@ -197,6 +197,12 @@ func TestSQLiteSchemaIsUntouchedByTheModelChanges(t *testing.T) {
t.Errorf("a legacy user must read a NULL role_assigned_by_id: %v", row) t.Errorf("a legacy user must read a NULL role_assigned_by_id: %v", row)
} }
delete(row, "role_assigned_by_id") 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"] { for _, row := range after.rows["invites"] {
if row["role_id"] == nil { 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"} 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) { 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) { t.Run("render() falls back to the old default subject for an unrecognized template name", func(t *testing.T) {
repo := mailer.NewMailRepository(config.SmtpSettings{}) repo := mailer.NewMailRepository(config.SmtpSettings{})
+1 -1
View File
@@ -124,7 +124,7 @@ func assertSevenKeys(t *testing.T, body []byte) map[string]json.RawMessage {
func assertMeKeys(t *testing.T, body []byte) map[string]json.RawMessage { func assertMeKeys(t *testing.T, body []byte) map[string]json.RawMessage {
t.Helper() t.Helper()
decoded := decodeKeys(t, body) 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) { if len(decoded) != len(expected) {
t.Errorf("expected exactly %d keys, got %d: %s", len(expected), len(decoded), string(body)) t.Errorf("expected exactly %d keys, got %d: %s", len(expected), len(decoded), string(body))
} }
+80
View File
@@ -6,6 +6,8 @@ import (
"RenewCMS/internal/infrastructure/useCases" "RenewCMS/internal/infrastructure/useCases"
"errors" "errors"
"sort" "sort"
"strings"
"sync"
"testing" "testing"
"time" "time"
) )
@@ -58,6 +60,7 @@ type mockUserRepo struct {
beforeTransfer func() beforeTransfer func()
beforeClearVerification func() beforeClearVerification func()
knownRoles *mockRoleRepo knownRoles *mockRoleRepo
confirmLock sync.Mutex
} }
func newMockUserRepo() *mockUserRepo { func newMockUserRepo() *mockUserRepo {
@@ -80,6 +83,8 @@ func (m *mockUserRepo) sortedIds() []uint32 {
} }
func (m *mockUserRepo) Get(id uint32) (user.User, error) { func (m *mockUserRepo) Get(id uint32) (user.User, error) {
m.confirmLock.Lock()
defer m.confirmLock.Unlock()
m.getCalls++ m.getCalls++
if err := m.fail.trigger("Get"); err != nil { if err := m.fail.trigger("Get"); err != nil {
return user.User{}, err return user.User{}, err
@@ -117,6 +122,81 @@ func (m *mockUserRepo) GetByEmail(email string) (user.User, error) {
return user.User{}, user.ErrUserNotFound 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) { func (m *mockUserRepo) GetAll() ([]user.User, error) {
m.getAllCalls++ m.getAllCalls++
if err := m.fail.trigger("GetAll"); err != nil { if err := m.fail.trigger("GetAll"); err != nil {
+1 -1
View File
@@ -606,7 +606,7 @@ func TestUsersAPIPublicUserShape(t *testing.T) {
w := newUsersAPIWorld(t) w := newUsersAPIWorld(t)
rec := w.do(t, uaAdmin, http.MethodGet, "/v1/auth/me", "") rec := w.do(t, uaAdmin, http.MethodGet, "/v1/auth/me", "")
body := usersAPIDecode(t, rec) 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" { if usersAPIRoleName(t, rec) != "Admin" {
t.Errorf("access.role must stay") t.Errorf("access.role must stay")
} }
+73
View File
@@ -0,0 +1,73 @@
<script setup lang="ts">
import { SHEET_CONTENT_CLASS, SHEET_FOOTER_CLASS } from '@/components/sheetClasses'
import { t } from '@/i18n'
import { computed } from 'vue'
import type { LastRole } from '@/composables/useAccountDeletion'
const props = defineProps<{
open: boolean
lastRole: LastRole | null
}>()
const emit = defineEmits<{ back: []; users: [] }>()
const title = computed(() =>
props.lastRole ? t("You can't delete your account yet") : t("This account can't be deleted"),
)
const description = computed(() => {
if (props.lastRole === 'Owner') {
return t(
'You are the last Owner. RenewCMS needs at least one. Transfer ownership or promote another user first, then come back to delete your account.',
)
}
if (props.lastRole === 'Admin') {
return t(
'You are the last administrator. RenewCMS needs at least one. Promote another user first, then come back to delete your account.',
)
}
return t(
"RenewCMS can't delete this account right now. Ask an administrator to help, then try again.",
)
})
</script>
<template>
<UModal
:open="open"
:title="title"
:description="description"
:dismissible="false"
:close="false"
:content="{ role: 'alertdialog' }"
:ui="{
content: `${SHEET_CONTENT_CLASS} sm:top-35 sm:max-w-115`,
header: 'min-h-0 px-6 pt-5 pb-0 sm:px-6',
title: 'text-lg/6 font-semibold',
description: 'mt-1 text-sm text-muted text-pretty',
body: 'p-0 sm:p-0',
footer: SHEET_FOOTER_CLASS,
}"
>
<template #footer>
<div class="flex w-full flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<UButton
color="neutral"
variant="outline"
block
class="h-11 justify-center text-sm text-highlighted sm:h-9 sm:w-auto sm:px-3"
@click="emit('back')"
>
{{ t('Back to profile') }}
</UButton>
<UButton
v-if="lastRole"
block
class="h-11 justify-center text-sm sm:h-9 sm:w-auto sm:px-3"
@click="emit('users')"
>
{{ t('Go to Users') }}
</UButton>
</div>
</template>
</UModal>
</template>
+61
View File
@@ -0,0 +1,61 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import BlockedDialog from '@/components/BlockedDialog.vue'
import DeleteAccountDialog from '@/components/DeleteAccountDialog.vue'
import { useAccountDeletion } from '@/composables/useAccountDeletion'
const router = useRouter()
const deletion = useAccountDeletion()
const dialogOpen = ref(false)
function closeAll() {
dialogOpen.value = false
deletion.reset()
}
function goToUsers() {
closeAll()
void router.push('/users')
}
</script>
<template>
<UCard
data-testid="danger-zone"
:ui="{
root: 'rounded-none bg-default shadow-none ring-0 border border-error divide-y-0',
body: 'flex flex-col gap-3.5 p-4 sm:p-4 lg:flex-row lg:items-center lg:justify-between',
}"
>
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
<h2 class="flex items-center gap-2 text-base/5 font-semibold text-error">
<UIcon name="i-lucide-triangle-alert" class="size-4 shrink-0" />
{{ t('Danger zone') }}
</h2>
<p class="text-pretty text-muted">
{{ t("Delete your account and your personal data. This can't be undone.") }}
</p>
</div>
<UButton
color="error"
icon="i-lucide-trash-2"
class="h-11 shrink-0 justify-center px-3.5 lg:h-9"
@click="dialogOpen = true"
>
{{ t('Delete my account') }}
</UButton>
</UCard>
<DeleteAccountDialog
:open="dialogOpen && !deletion.blocked.value"
:deletion="deletion"
@update:open="dialogOpen = $event"
/>
<BlockedDialog
:open="deletion.blocked.value"
:last-role="deletion.state.value === 'blocked' ? deletion.lastRole.value : null"
@back="closeAll"
@users="goToUsers"
/>
</template>
+133
View File
@@ -0,0 +1,133 @@
<script setup lang="ts">
import { computed } from 'vue'
import { t } from '@/i18n'
import ProfileCard from '@/components/ProfileCard.vue'
import { useDataExport } from '@/composables/useDataExport'
const exporter = useDataExport()
const contents = [
{
icon: 'i-lucide-user',
get title() {
return t('Profile')
},
get text() {
return t('Username, display name, email, role and author profile.')
},
},
{
icon: 'i-lucide-file-text',
get title() {
return t('Authored content')
},
get text() {
return t('Your articles and entries.')
},
},
{
icon: 'i-lucide-image',
get title() {
return t('Uploaded media')
},
get text() {
return t('References to the files you uploaded.')
},
},
{
icon: 'i-lucide-history',
get title() {
return t('Account activity')
},
get text() {
return t('Your log-ins and the actions recorded under your name.')
},
},
]
const notice = computed(() => {
if (exporter.state.value === 'limited') {
return {
color: 'info',
icon: 'i-lucide-timer',
title: t('You can export once every 15 minutes'),
description:
exporter.minutesLeft.value === 1
? t('Your last export was at {0}. Try again in 1 minute.', [
exporter.lastExportTime.value,
])
: t('Your last export was at {0}. Try again in {1} minutes.', [
exporter.lastExportTime.value,
exporter.minutesLeft.value,
]),
} as const
}
if (exporter.state.value === 'error') {
return {
color: 'error',
icon: 'i-lucide-circle-x',
title: t("Couldn't prepare your data"),
description: t('Nothing was changed. Try again in a moment.'),
} as const
}
return null
})
const buttonLabel = computed(() => {
if (exporter.state.value === 'preparing') {
return t('Preparing download…')
}
if (exporter.state.value === 'limited') {
return t('Available in {0}', [exporter.countdown.value])
}
return t('Download my data')
})
const showHint = computed(() => exporter.state.value === 'idle' || exporter.state.value === 'error')
</script>
<template>
<ProfileCard
:title="t('Your data')"
:description="
t(
'Download a copy of the personal data RenewCMS holds about you, as JSON files in a ZIP.',
)
"
>
<ul class="grid list-none gap-x-4 gap-y-3 p-0 lg:grid-cols-2">
<li v-for="item in contents" :key="item.icon" class="flex min-w-0 gap-2.5">
<UIcon :name="item.icon" class="mt-0.5 size-4 shrink-0 text-toned" />
<div class="flex min-w-0 flex-col">
<span class="font-medium text-highlighted">{{ item.title }}</span>
<span class="text-pretty text-muted">{{ item.text }}</span>
</div>
</li>
</ul>
<UAlert
v-if="notice"
data-testid="data-export-notice"
role="status"
variant="soft"
:color="notice.color"
:icon="notice.icon"
:title="notice.title"
:description="notice.description"
:close="false"
/>
<div class="flex flex-col gap-3 lg:flex-row lg:items-center">
<UButton
icon="i-lucide-download"
:loading="exporter.busy.value"
:disabled="exporter.blocked.value"
class="h-11 justify-center px-3.5 lg:h-9"
@click="exporter.start"
>
{{ buttonLabel }}
</UButton>
<span class="min-w-0 flex-1 text-pretty text-xs/[18px] text-muted">
{{ showHint ? t('Ready in a few seconds.') : '' }}
</span>
</div>
</ProfileCard>
</template>
+226
View File
@@ -0,0 +1,226 @@
<script setup lang="ts">
import { SHEET_CONTENT_CLASS, SHEET_FOOTER_CLASS } from '@/components/sheetClasses'
import { t } from '@/i18n'
import { computed, nextTick, ref, watch } from 'vue'
import { useDataExport } from '@/composables/useDataExport'
import type { AccountDeletion } from '@/composables/useAccountDeletion'
import { DELETE_WORD } from '@/lib/accountDeletion'
const props = defineProps<{ deletion: AccountDeletion }>()
const open = defineModel<boolean>('open', { default: false })
const exporter = useDataExport()
const passwordField = ref<{ inputRef?: HTMLInputElement | null } | null>(null)
const passwordFocused = ref(false)
const wordFocused = ref(false)
const KEPT_CONTENT_TEXT =
'They stay on the site, credited to "Deleted user". Your name is removed from them.'
const consequences = [
'Your username, display name and email address',
'Your author profile and API keys',
"Your sessions: you'll be logged out everywhere",
] as const
const inputUi = { base: 'h-11 text-lg md:h-10 md:text-base' }
const errorUi = { error: 'mt-1.5 text-xs/5' }
const password = computed({
get: () => props.deletion.password.value,
set: (value: string) => props.deletion.typePassword(value),
})
const word = computed({
get: () => props.deletion.word.value,
set: (value: string) => props.deletion.typeWord(value),
})
const deleting = computed(() => props.deletion.deleting.value)
const passwordError = computed(() =>
props.deletion.state.value === 'wrongPassword'
? t("That password isn't correct. Try again.")
: '',
)
const notice = computed(() => {
if (props.deletion.state.value === 'limited') {
return t('Too many requests. Try again in a few minutes.')
}
if (props.deletion.state.value === 'error') {
return t("Couldn't delete your account. Nothing was deleted. Try again in a moment.")
}
return ''
})
const confirmLabel = computed(() => (deleting.value ? t('Deleting…') : t('Delete my account')))
watch(
() => props.deletion.state.value,
async (state) => {
if (state !== 'wrongPassword') {
return
}
await nextTick()
passwordField.value?.inputRef?.focus()
},
)
function onOpenChange(value: boolean) {
if (!value && deleting.value) {
return
}
if (!value) {
props.deletion.reset()
}
open.value = value
}
function onSubmit() {
void props.deletion.submit()
}
function onExport() {
void exporter.start()
}
</script>
<template>
<UModal
:open="open"
:title="t('Delete your account?')"
:description="t('This permanently deletes your account. It can\'t be undone.')"
:dismissible="!deleting"
:close="deleting ? false : { color: 'neutral', variant: 'ghost', class: 'text-muted' }"
:content="{ role: 'alertdialog', 'aria-busy': deleting ? 'true' : undefined }"
:ui="{
content: `${SHEET_CONTENT_CLASS} sm:top-35 sm:max-w-115`,
header: 'items-start gap-3 px-6 pt-5 pb-0 sm:px-6',
wrapper: 'pe-10',
close: 'top-5 end-6 size-11 justify-center p-0 sm:size-7',
title: 'text-lg/6 font-semibold',
description: 'mt-1 text-sm text-muted text-pretty',
body: 'px-6 pt-5 pb-0 sm:px-6 sm:pt-5 sm:pb-0',
footer: SHEET_FOOTER_CLASS,
}"
@update:open="onOpenChange"
>
<template #body>
<div class="flex flex-col gap-4">
<UAlert
v-if="notice"
role="alert"
color="error"
variant="soft"
:close="false"
icon="i-lucide-circle-x"
:ui="{ root: 'gap-2 px-3 py-2.5', description: 'text-sm text-toned' }"
:description="notice"
/>
<ul class="flex list-none flex-col gap-1.5 p-0">
<li
v-for="item in consequences"
:key="item"
class="flex items-start gap-2 text-toned"
>
<UIcon name="i-lucide-x" class="mt-0.75 size-3.5 shrink-0 text-error" />
<span class="text-pretty">{{ t(item) }}</span>
</li>
</ul>
<UAlert
role="status"
color="info"
variant="soft"
:close="false"
icon="i-lucide-info"
:title="t('Your articles and media stay')"
:description="t(KEPT_CONTENT_TEXT)"
:ui="{
root: 'gap-2.5 px-3.5 py-3',
title: 'font-semibold',
description: 'mt-0.5 text-toned',
}"
/>
<UFormField
:label="t('Current password')"
name="current-password"
:error="passwordError || undefined"
:ui="errorUi"
>
<template v-if="passwordError" #error>
<span class="flex items-center gap-1.5">
<UIcon
name="i-lucide-circle-alert"
class="size-3.5 shrink-0 text-error"
/>
{{ passwordError }}
</span>
</template>
<UInput
ref="passwordField"
v-model="password"
type="password"
autocomplete="current-password"
:disabled="deleting"
:color="passwordError ? 'error' : 'primary'"
:highlight="passwordFocused || !!passwordError"
:ui="inputUi"
class="w-full"
@focus="passwordFocused = true"
@blur="passwordFocused = false"
@keydown.enter.prevent="onSubmit"
/>
</UFormField>
<UFormField :label="t('Type DELETE to confirm')" name="confirmation">
<UInput
v-model="word"
autocomplete="off"
autocapitalize="off"
spellcheck="false"
:placeholder="DELETE_WORD"
:disabled="deleting"
color="primary"
:highlight="wordFocused"
:ui="inputUi"
class="w-full"
@focus="wordFocused = true"
@blur="wordFocused = false"
@keydown.enter.prevent="onSubmit"
/>
</UFormField>
<ULink
as="button"
type="button"
class="flex min-h-11 items-center gap-1.5 self-start font-medium text-highlighted underline underline-offset-3 sm:min-h-7"
:disabled="exporter.blocked.value"
@click="onExport"
>
<UIcon name="i-lucide-download" class="size-3.5" />
{{ t('Export my data first') }}
</ULink>
</div>
</template>
<template #footer>
<div class="flex w-full flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<UButton
color="neutral"
variant="outline"
block
class="h-11 justify-center text-sm text-highlighted disabled:text-dimmed disabled:opacity-100 sm:h-9 sm:w-auto sm:px-3"
:disabled="deleting"
@click="onOpenChange(false)"
>
{{ t('Keep my account') }}
</UButton>
<UButton
color="error"
block
:loading="deleting"
:disabled="!deletion.canSubmit.value"
class="h-11 justify-center text-sm sm:h-9 sm:w-auto sm:px-3"
@click="onSubmit"
>
{{ confirmLabel }}
</UButton>
</div>
</template>
</UModal>
</template>
+129
View File
@@ -0,0 +1,129 @@
<script setup lang="ts">
import { t } from '@/i18n'
import type { MessageKey } from '@/i18n/messages'
import { nextTick, onMounted, ref, watch } from 'vue'
const props = defineProps<{
emailError: MessageKey | ''
passwordError: MessageKey | ''
notice: MessageKey | ''
sending: boolean
sendDisabled: boolean
focusRequest: { field: 'email' | 'password'; count: number }
}>()
const email = defineModel<string>('email', { required: true })
const password = defineModel<string>('password', { required: true })
const emit = defineEmits<{
submit: []
cancel: []
blurEmail: []
}>()
const emailRef = ref<{ inputRef?: HTMLInputElement } | null>(null)
const passwordRef = ref<{ inputRef?: HTMLInputElement } | null>(null)
const inputUi = { base: 'h-11 text-lg lg:h-10 lg:text-base' }
const buttonClass = 'h-11 justify-center px-3 lg:h-9'
async function focusRequested() {
await nextTick()
await nextTick()
const target = props.focusRequest.field === 'email' ? emailRef.value : passwordRef.value
target?.inputRef?.focus()
}
onMounted(focusRequested)
watch(() => props.focusRequest, focusRequested)
</script>
<template>
<UForm
:state="{ email, password }"
class="flex flex-col gap-3.5 border border-default bg-muted p-4"
@submit="emit('submit')"
>
<UAlert
v-if="notice"
data-testid="email-change-notice"
role="alert"
color="error"
variant="soft"
icon="i-lucide-circle-x"
:description="t(notice)"
:close="false"
:ui="{ root: 'gap-2 px-3 py-2.5' }"
/>
<UFormField :label="t('New email address')" name="email" :error="!!emailError">
<UInput
ref="emailRef"
v-model="email"
type="email"
inputmode="email"
autocomplete="off"
:placeholder="t('name@example.com')"
:highlight="!!emailError"
:disabled="sending"
class="w-full"
:ui="inputUi"
@blur="emit('blurEmail')"
/>
<span
v-if="emailError"
data-testid="email-change-email-error"
class="mt-1.5 flex items-center gap-1.5 text-xs/[18px] text-error"
>
<UIcon name="i-lucide-circle-alert" class="size-3.5 shrink-0" />
{{ t(emailError) }}
</span>
</UFormField>
<UFormField :label="t('Current password')" name="password" :error="!!passwordError">
<UInput
ref="passwordRef"
v-model="password"
type="password"
autocomplete="current-password"
:highlight="!!passwordError"
:disabled="sending"
class="w-full"
:ui="inputUi"
/>
<span
v-if="passwordError"
data-testid="email-change-password-error"
class="mt-1.5 flex items-center gap-1.5 text-xs/[18px] text-error"
>
<UIcon name="i-lucide-circle-alert" class="size-3.5 shrink-0" />
{{ t(passwordError) }}
</span>
</UFormField>
<p class="text-xs/[18px] text-pretty text-muted">
{{
t(
"We'll send a verification link to the new address. Your email stays the same until you open it.",
)
}}
</p>
<div class="flex flex-col-reverse gap-2 lg:flex-row lg:justify-end">
<UButton
type="button"
color="neutral"
variant="outline"
:disabled="sending"
:class="[buttonClass, 'border border-accented bg-default text-highlighted ring-0']"
@click="emit('cancel')"
>
{{ t('Cancel') }}
</UButton>
<UButton type="submit" :loading="sending" :disabled="sendDisabled" :class="buttonClass">
{{ sending ? t('Sending…') : t('Send verification link') }}
</UButton>
</div>
</UForm>
</template>
+82
View File
@@ -0,0 +1,82 @@
<script setup lang="ts">
import { t } from '@/i18n'
import type { MessageKey } from '@/i18n/messages'
defineProps<{
pendingEmail: string
currentEmail: string
resendState: 'idle' | 'sending' | 'limited'
resendCountdown: string
note: MessageKey | ''
busy: boolean
}>()
const emit = defineEmits<{
resend: []
cancelChange: []
}>()
const buttonClass =
'h-11 justify-center border border-accented bg-default text-highlighted ring-0 lg:h-9'
</script>
<template>
<UAlert
data-testid="pending-email-notice"
role="status"
color="info"
variant="soft"
icon="i-lucide-mail"
:title="t('Waiting for verification')"
:close="false"
:ui="{ root: 'gap-2.5 px-3.5 py-3', title: 'font-semibold', description: 'mt-0.5' }"
>
<template #description>
<div class="flex flex-col gap-3">
<span class="text-pretty break-words text-toned">
{{
t(
'We sent a link to {0}. Until you open it, you keep logging in with {1}.',
[pendingEmail, currentEmail],
)
}}
</span>
<div class="-ms-6.5 flex flex-col gap-2 lg:ms-0 lg:flex-row">
<UButton
color="neutral"
variant="outline"
:loading="resendState === 'sending'"
:disabled="resendState !== 'idle' || busy"
:class="buttonClass"
@click="emit('resend')"
>
{{
resendState === 'sending'
? t('Sending…')
: resendState === 'limited'
? t('Resend in {0}', [resendCountdown])
: t('Resend link')
}}
</UButton>
<UButton
color="neutral"
variant="outline"
:disabled="resendState === 'sending' || busy"
:class="buttonClass"
@click="emit('cancelChange')"
>
{{ t('Cancel change') }}
</UButton>
</div>
<span
v-if="note"
data-testid="pending-email-note"
class="flex items-center gap-1.5 text-xs/[18px] text-error"
>
<UIcon name="i-lucide-circle-alert" class="size-3.5 shrink-0" />
{{ t(note) }}
</span>
</div>
</template>
</UAlert>
</template>
+2 -2
View File
@@ -7,7 +7,7 @@ defineProps<{
<template> <template>
<section class="border border-default bg-default"> <section class="border border-default bg-default">
<div class="flex flex-col gap-3.5 p-4 lg:p-5"> <div class="flex flex-col gap-3.5 p-4">
<div class="flex flex-col gap-0.5"> <div class="flex flex-col gap-0.5">
<h2 class="text-base/5 font-semibold text-highlighted"> <h2 class="text-base/5 font-semibold text-highlighted">
{{ title }} {{ title }}
@@ -18,7 +18,7 @@ defineProps<{
</div> </div>
<div <div
v-if="$slots.footer" v-if="$slots.footer"
class="flex items-center gap-3 border-t border-default bg-muted px-4 py-3 lg:px-5" class="flex items-center gap-3 border-t border-default bg-muted px-4 py-3"
> >
<slot name="footer" /> <slot name="footer" />
</div> </div>
+122
View File
@@ -0,0 +1,122 @@
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ApiError, authRedirectPending, redirectToLogin } from '@/composables/useApi'
import { ACCOUNT_DELETED_REASON, DELETE_WORD, accountDeletedRedirect } from '@/lib/accountDeletion'
import { useProfile } from '@/composables/useProfile'
import { useSessionStore } from '@/stores/session'
export type AccountDeletionState =
| 'idle'
| 'deleting'
| 'wrongPassword'
| 'limited'
| 'error'
| 'blocked'
| 'protected'
export type LastRole = 'Owner' | 'Admin'
function lastRoleOf(error: ApiError): LastRole {
return error.lastRole === 'Owner' ? 'Owner' : 'Admin'
}
export function useAccountDeletion() {
const router = useRouter()
const session = useSessionStore()
const { deleteAccount } = useProfile()
const password = ref('')
const word = ref('')
const state = ref<AccountDeletionState>('idle')
const lastRole = ref<LastRole>('Admin')
const deleting = computed(() => state.value === 'deleting')
const blocked = computed(() => state.value === 'blocked' || state.value === 'protected')
const ready = computed(() => password.value !== '' && word.value === DELETE_WORD)
const canSubmit = computed(() => ready.value && !deleting.value)
function typeWord(value: string) {
word.value = value
}
function typePassword(value: string) {
password.value = value
if (state.value === 'wrongPassword') {
state.value = 'idle'
}
}
function reset() {
password.value = ''
word.value = ''
state.value = 'idle'
}
function refuse(error: unknown) {
if (!(error instanceof ApiError)) {
state.value = 'error'
return
}
if (error.status === 401) {
state.value = 'idle'
redirectToLogin()
} else if (error.status === 403 && error.code === 'wrong_password') {
password.value = ''
state.value = 'wrongPassword'
} else if (error.status === 409 && error.code === 'last_admin') {
lastRole.value = lastRoleOf(error)
state.value = 'blocked'
} else if (error.status === 409 && error.code === 'account_not_deletable') {
state.value = 'protected'
} else if (error.status === 429) {
state.value = 'limited'
} else {
state.value = 'error'
}
}
async function leave() {
authRedirectPending.value = true
accountDeletedRedirect.value = true
session.clear()
try {
await router.push({ path: '/login', query: { reason: ACCOUNT_DELETED_REASON } })
} catch {
return
} finally {
authRedirectPending.value = false
}
}
async function submit() {
if (!canSubmit.value) {
return false
}
state.value = 'deleting'
try {
await deleteAccount(password.value, word.value)
} catch (error) {
refuse(error)
return false
}
await leave()
return true
}
return {
password,
word,
state,
lastRole,
deleting,
blocked,
ready,
canSubmit,
typePassword,
typeWord,
submit,
reset,
}
}
export type AccountDeletion = ReturnType<typeof useAccountDeletion>
-11
View File
@@ -82,14 +82,3 @@ export function useActivity() {
listPeople, listPeople,
} }
} }
export function downloadBlob(blob: Blob, fileName: string) {
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = fileName
document.body.appendChild(anchor)
anchor.click()
anchor.remove()
URL.revokeObjectURL(url)
}
+2 -1
View File
@@ -2,7 +2,8 @@ import { computed, onBeforeUnmount, ref, toValue, type MaybeRefOrGetter } from '
import { t } from '@/i18n' import { t } from '@/i18n'
import { useLocalizedToast } from '@/i18n/presentation' import { useLocalizedToast } from '@/i18n/presentation'
import { ApiError, isAbort } from '@/composables/useApi' import { ApiError, isAbort } from '@/composables/useApi'
import { downloadBlob, useActivity } from '@/composables/useActivity' import { useActivity } from '@/composables/useActivity'
import { downloadBlob } from '@/lib/downloadBlob'
import { useSessionStore } from '@/stores/session' import { useSessionStore } from '@/stores/session'
import { import {
allActivityFileName, allActivityFileName,
+26 -1
View File
@@ -1,7 +1,9 @@
import { ref } from 'vue' import { ref } from 'vue'
import { accountDeletedRedirect } from '@/lib/accountDeletion'
import router from '@/router' import router from '@/router'
import { useSessionStore } from '@/stores/session' import { useSessionStore } from '@/stores/session'
import { ApiError, errorCodeOf, knownErrorCode } from '@/lib/apiError' import { ApiError, errorCodeOf, knownErrorCode } from '@/lib/apiError'
import { filenameFromDisposition } from '@/lib/contentDisposition'
import { missingPermissionCopy } from '@/lib/permissionCopy' import { missingPermissionCopy } from '@/lib/permissionCopy'
import { useLocalizedToast } from '@/i18n/presentation' import { useLocalizedToast } from '@/i18n/presentation'
import { parseRoleAssignedBy, type Access, type Grant, type RoleRef } from '@/lib/permissions' import { parseRoleAssignedBy, type Access, type Grant, type RoleRef } from '@/lib/permissions'
@@ -20,6 +22,11 @@ export class MissingTotalError extends Error {
} }
} }
export interface DownloadedFile {
blob: Blob
filename: string | undefined
}
export interface PagedResponse<T> { export interface PagedResponse<T> {
body: T body: T
total: number total: number
@@ -36,6 +43,7 @@ export interface MeResult {
verified: boolean verified: boolean
username?: string username?: string
email?: string email?: string
pendingEmail?: string
displayName: string displayName: string
access?: Access access?: Access
author?: MeAuthor author?: MeAuthor
@@ -98,6 +106,9 @@ if (typeof window !== 'undefined') {
} }
export function redirectToLogin() { export function redirectToLogin() {
if (authRedirectPending.value || accountDeletedRedirect.value) {
return
}
const currentPath = router.currentRoute.value.fullPath const currentPath = router.currentRoute.value.fullPath
useSessionStore().clear() useSessionStore().clear()
authRedirectPending.value = true authRedirectPending.value = true
@@ -195,6 +206,7 @@ export function useApi() {
const body = await readErrorBody(response) const body = await readErrorBody(response)
throw new ApiError(response.status, undefined, body, { throw new ApiError(response.status, undefined, body, {
code: errorCodeOf(body) ?? knownErrorCode(response.headers.get('X-Error-Code')), code: errorCodeOf(body) ?? knownErrorCode(response.headers.get('X-Error-Code')),
lastRole: headerOrUndefined(response, 'X-Last-Role'),
}) })
} }
@@ -228,12 +240,24 @@ export function useApi() {
return response.blob() return response.blob()
} }
async function requestFile(
path: string,
options: RequestOptions = {},
): Promise<DownloadedFile> {
const response = await send(path, options)
return {
blob: await response.blob(),
filename: filenameFromDisposition(response.headers.get('Content-Disposition')),
}
}
async function getMe(): Promise<MeResult> { async function getMe(): Promise<MeResult> {
const body = await request<{ const body = await request<{
id?: number id?: number
is_verified: boolean is_verified: boolean
username?: string username?: string
email?: string email?: string
pending_email?: string | null
display_name?: string | null display_name?: string | null
access?: { role: RoleRef; permissions: Grant[]; role_assigned_by?: unknown } access?: { role: RoleRef; permissions: Grant[]; role_assigned_by?: unknown }
author?: MeAuthor | null author?: MeAuthor | null
@@ -243,6 +267,7 @@ export function useApi() {
verified: Boolean(body.is_verified), verified: Boolean(body.is_verified),
username: body.username, username: body.username,
email: body.email, email: body.email,
...(body.pending_email ? { pendingEmail: body.pending_email } : {}),
displayName: body.display_name ?? '', displayName: body.display_name ?? '',
...(body.access ...(body.access
? { ? {
@@ -257,5 +282,5 @@ export function useApi() {
} }
} }
return { getMe, request, requestPaged, requestBlob } return { getMe, request, requestPaged, requestBlob, requestFile }
} }
+98
View File
@@ -0,0 +1,98 @@
import { computed, ref, watch } from 'vue'
import { locale, t } from '@/i18n'
import { useLocalizedToast } from '@/i18n/presentation'
import { ApiError } from '@/composables/useApi'
import { downloadBlob } from '@/lib/downloadBlob'
import { useProfile } from '@/composables/useProfile'
import { useRetryAfter } from '@/composables/useRetryAfter'
export type DataExportState = 'idle' | 'preparing' | 'limited' | 'error'
const EXPORT_WINDOW_SECONDS = 900
const FALLBACK_FILENAME = 'renewcms-export.zip'
const SECONDS_PER_MINUTE = 60
const MILLISECONDS_PER_SECOND = 1000
function isHandledGlobally(error: unknown) {
return error instanceof ApiError && (error.status === 401 || error.status === 403)
}
function clockTime(moment: Date) {
return new Intl.DateTimeFormat(locale.value, {
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23',
}).format(moment)
}
export function useDataExport() {
const toast = useLocalizedToast()
const { downloadMyData } = useProfile()
const limit = useRetryAfter()
const state = ref<DataExportState>('idle')
const lastExportTime = ref('')
const minutesLeft = ref(1)
const busy = computed(() => state.value === 'preparing')
const blocked = computed(() => state.value === 'preparing' || state.value === 'limited')
watch(limit.remaining, (remaining) => {
if (remaining === 0 && state.value === 'limited') {
state.value = 'idle'
}
})
function announceStarted(filename: string) {
toast.add(() => ({
id: 'data-export-started',
color: 'success',
icon: 'i-lucide-circle-check',
get title() {
return t('Download started')
},
get description() {
return t('{0} is in your downloads.', [filename])
},
}))
}
function enterLimited(retryAfter: number | undefined) {
const seconds = retryAfter || EXPORT_WINDOW_SECONDS
const elapsed = Math.max(0, EXPORT_WINDOW_SECONDS - seconds)
lastExportTime.value = clockTime(new Date(Date.now() - elapsed * MILLISECONDS_PER_SECOND))
minutesLeft.value = Math.max(1, Math.ceil(seconds / SECONDS_PER_MINUTE))
limit.start(seconds)
state.value = 'limited'
}
async function start() {
if (blocked.value) {
return
}
state.value = 'preparing'
try {
const { blob, filename } = await downloadMyData()
const name = filename ?? FALLBACK_FILENAME
downloadBlob(blob, name)
announceStarted(name)
state.value = 'idle'
} catch (error) {
if (error instanceof ApiError && error.status === 429) {
enterLimited(error.retryAfter)
return
}
state.value = isHandledGlobally(error) ? 'idle' : 'error'
}
}
return {
state,
busy,
blocked,
lastExportTime,
minutesLeft,
countdown: limit.formatted,
start,
}
}
+290
View File
@@ -0,0 +1,290 @@
import { t } from '@/i18n'
import type { MessageKey } from '@/i18n/messages'
import { computed, ref, watch } from 'vue'
import { useLocalizedToast } from '@/i18n/presentation'
import { ApiError } from '@/composables/useApi'
import { useProfile } from '@/composables/useProfile'
import { useRetryAfter } from '@/composables/useRetryAfter'
import {
NEW_EMAIL_PATTERN,
RATE_LIMIT_FALLBACK_SECONDS,
WRONG_PASSWORD_BODY,
isSameEmail,
} from '@/lib/emailChange'
import { useSessionStore } from '@/stores/session'
const GENERIC_FAILURE_NOTICE =
'Something went wrong on our side. Nothing was changed. Try again in a moment.'
type FocusField = 'email' | 'password'
type ResendState = 'idle' | 'sending' | 'limited'
export function useEmailChange() {
const session = useSessionStore()
const toast = useLocalizedToast()
const profile = useProfile()
const sendLimit = useRetryAfter()
const resendLimit = useRetryAfter()
const formOpen = ref(false)
const newEmail = ref('')
const password = ref('')
const emailError = ref<MessageKey | ''>('')
const passwordError = ref<MessageKey | ''>('')
const notice = ref<MessageKey | ''>('')
const sending = ref(false)
const sendLimited = ref(false)
const focusRequest = ref<{ field: FocusField; count: number }>({ field: 'email', count: 0 })
const resendState = ref<ResendState>('idle')
const pendingNote = ref<MessageKey | ''>('')
const cancelling = ref(false)
const pendingEmail = computed(() => session.session?.pendingEmail)
const currentEmail = computed(() => session.session?.email)
const showChangeLink = computed(() => !formOpen.value && !pendingEmail.value)
function requestFocus(field: FocusField) {
focusRequest.value = { field, count: focusRequest.value.count + 1 }
}
function openForm() {
formOpen.value = true
requestFocus('email')
}
function resetForm() {
formOpen.value = false
newEmail.value = ''
password.value = ''
emailError.value = ''
passwordError.value = ''
notice.value = ''
sending.value = false
sendLimited.value = false
}
function validateFormat() {
return NEW_EMAIL_PATTERN.test(newEmail.value.trim())
? ''
: 'Enter a full email address, like name@example.com.'
}
function validateEmail() {
const formatError = validateFormat()
if (formatError) {
return formatError
}
if (isSameEmail(newEmail.value, currentEmail.value)) {
return "That's already your email address."
}
return ''
}
function checkEmailOnBlur() {
if (newEmail.value.trim()) {
emailError.value = validateFormat()
}
}
function validateForm() {
emailError.value = validateEmail()
passwordError.value = password.value ? '' : 'Enter your current password.'
if (emailError.value) {
requestFocus('email')
} else if (passwordError.value) {
requestFocus('password')
}
return !emailError.value && !passwordError.value
}
watch(newEmail, (value) => {
if (value) {
emailError.value = ''
}
})
watch(password, (value) => {
if (value) {
passwordError.value = ''
}
})
watch(sendLimit.remaining, (value) => {
if (value === 0 && sendLimited.value) {
sendLimited.value = false
notice.value = ''
}
})
watch(resendLimit.remaining, (value) => {
if (value === 0 && resendState.value === 'limited') {
resendState.value = 'idle'
pendingNote.value = ''
}
})
function announceSent(address: string) {
toast.add(() => ({
id: 'email-change-sent',
color: 'success',
icon: 'i-lucide-circle-check',
get title() {
return t('Verification link sent')
},
get description() {
return t('Check {0}. The link works once, within 2 hours.', [address])
},
}))
}
function announceResent() {
toast.add(() => ({
id: 'email-change-resent',
color: 'info',
icon: 'i-lucide-info',
get title() {
return t('Link sent again')
},
get description() {
return t('The previous link no longer works.')
},
}))
}
function announceCancelled(address: string | undefined) {
toast.add(() => ({
id: 'email-change-cancelled',
color: 'info',
icon: 'i-lucide-info',
get title() {
return t('Email change cancelled')
},
get description() {
return t('You keep logging in with {0}.', [address])
},
}))
}
function isHandledGlobally(error: unknown) {
return error instanceof ApiError && error.status === 401
}
function showSendFailure(error: unknown) {
if (isHandledGlobally(error)) {
return
}
if (
error instanceof ApiError &&
error.status === 403 &&
error.body === WRONG_PASSWORD_BODY
) {
passwordError.value = "That password isn't correct. Try again."
password.value = ''
requestFocus('password')
return
}
if (error instanceof ApiError && error.status === 429) {
sendLimited.value = true
notice.value = 'Too many requests. Try again in a few minutes.'
sendLimit.start(error.retryAfter || RATE_LIMIT_FALLBACK_SECONDS)
return
}
notice.value = GENERIC_FAILURE_NOTICE
}
async function submit() {
if (sending.value || sendLimited.value || !validateForm()) {
return
}
sending.value = true
notice.value = ''
const address = newEmail.value.trim()
try {
const result = await profile.requestEmailChange(address, password.value)
session.setPendingEmail(result.pending_email)
resetForm()
announceSent(result.pending_email)
} catch (error) {
showSendFailure(error)
} finally {
sending.value = false
}
}
async function resend() {
if (resendState.value !== 'idle' || cancelling.value) {
return
}
resendState.value = 'sending'
pendingNote.value = ''
try {
const result = await profile.resendEmailChange()
session.setPendingEmail(result.pending_email)
resendState.value = 'idle'
announceResent()
} catch (error) {
resendState.value = 'idle'
showResendFailure(error)
}
}
function showResendFailure(error: unknown) {
if (isHandledGlobally(error)) {
return
}
if (error instanceof ApiError && error.status === 429) {
resendState.value = 'limited'
pendingNote.value = 'Too many requests. Try again in a few minutes.'
resendLimit.start(error.retryAfter || RATE_LIMIT_FALLBACK_SECONDS)
return
}
if (error instanceof ApiError && error.status === 404) {
session.setPendingEmail(undefined)
return
}
pendingNote.value = GENERIC_FAILURE_NOTICE
}
async function cancelChange() {
if (cancelling.value || resendState.value === 'sending') {
return
}
cancelling.value = true
pendingNote.value = ''
try {
await profile.cancelEmailChange()
session.setPendingEmail(undefined)
resendLimit.start(0)
resendState.value = 'idle'
announceCancelled(currentEmail.value)
} catch (error) {
if (!isHandledGlobally(error)) {
pendingNote.value = GENERIC_FAILURE_NOTICE
}
} finally {
cancelling.value = false
}
}
return {
formOpen,
newEmail,
password,
emailError,
passwordError,
notice,
sending,
sendLimited,
focusRequest,
resendState,
resendCountdown: resendLimit.formatted,
pendingNote,
cancelling,
pendingEmail,
showChangeLink,
openForm,
resetForm,
checkEmailOnBlur,
submit,
resend,
cancelChange,
}
}
+4 -6
View File
@@ -3,6 +3,7 @@ import { useRoute } from 'vue-router'
import { ApiError } from '@/composables/useApi' import { ApiError } from '@/composables/useApi'
import { useEntries } from '@/composables/useEntries' import { useEntries } from '@/composables/useEntries'
import { useUserLookup } from '@/composables/useUserLookup' import { useUserLookup } from '@/composables/useUserLookup'
import { namesWithDeletedFallback } from '@/lib/deletedUser'
import type { ContentEntry, EntryListStatus } from '@/lib/contentTypes' import type { ContentEntry, EntryListStatus } from '@/lib/contentTypes'
import { usePagedQuery, type PagedOutcome } from '@/composables/usePagedQuery' import { usePagedQuery, type PagedOutcome } from '@/composables/usePagedQuery'
import { import {
@@ -35,12 +36,9 @@ export function useEntryList(typeKey: string) {
if (ids.length === 0) { if (ids.length === 0) {
return return
} }
const found = await lookupUsers(ids).catch(() => []) const found = await lookupUsers(ids).catch(() => null)
if (current === lookupToken) { if (found && current === lookupToken) {
names.value = { names.value = { ...names.value, ...namesWithDeletedFallback(ids, found) }
...names.value,
...Object.fromEntries(found.map((user) => [user.id, user.display_name])),
}
} }
} }
+2 -4
View File
@@ -1,5 +1,6 @@
import { ref, watch } from 'vue' import { ref, watch } from 'vue'
import { useUserLookup } from '@/composables/useUserLookup' import { useUserLookup } from '@/composables/useUserLookup'
import { namesWithDeletedFallback } from '@/lib/deletedUser'
import type { ContentEntry } from '@/lib/contentTypes' import type { ContentEntry } from '@/lib/contentTypes'
function authorIds(entry: ContentEntry | null) { function authorIds(entry: ContentEntry | null) {
@@ -19,10 +20,7 @@ export function useEntryNames(entry: () => ContentEntry | null) {
wanted.forEach((id) => requested.add(id)) wanted.forEach((id) => requested.add(id))
try { try {
const users = await lookupUsers(wanted) const users = await lookupUsers(wanted)
names.value = { names.value = { ...names.value, ...namesWithDeletedFallback(wanted, users) }
...names.value,
...Object.fromEntries(users.map((user) => [user.id, user.display_name])),
}
} catch { } catch {
wanted.forEach((id) => requested.delete(id)) wanted.forEach((id) => requested.delete(id))
} }
+50 -2
View File
@@ -3,7 +3,7 @@ import { useApi } from '@/composables/useApi'
const JSON_HEADERS = { 'Content-Type': 'application/json' } const JSON_HEADERS = { 'Content-Type': 'application/json' }
export function useProfile() { export function useProfile() {
const { request } = useApi() const { request, requestFile } = useApi()
function updateDisplayName(displayName: string) { function updateDisplayName(displayName: string) {
return request<{ display_name?: string | null }>('/v1/user/me', { return request<{ display_name?: string | null }>('/v1/user/me', {
@@ -21,5 +21,53 @@ export function useProfile() {
}) })
} }
return { updateDisplayName, sendPasswordReset } function requestEmailChange(newEmail: string, currentPassword: string) {
return request<{ pending_email: string }>('/v1/auth/email/change', {
method: 'POST',
headers: JSON_HEADERS,
body: JSON.stringify({ new_email: newEmail, current_password: currentPassword }),
})
}
function resendEmailChange() {
return request<{ pending_email: string }>('/v1/auth/email/resend', { method: 'POST' })
}
function cancelEmailChange() {
return request<void>('/v1/auth/email/change', { method: 'DELETE' })
}
function confirmEmailChange(code: string) {
return request<{ email: string }>('/v1/auth/email/confirm', {
method: 'POST',
headers: JSON_HEADERS,
body: JSON.stringify({ code }),
onUnauthorized: 'passthrough',
onForbidden: 'passthrough',
})
}
function downloadMyData() {
return requestFile('/v1/user/me/export')
}
function deleteAccount(currentPassword: string, confirmation: string) {
return request<void>('/v1/user/me', {
method: 'DELETE',
headers: JSON_HEADERS,
body: JSON.stringify({ current_password: currentPassword, confirmation }),
onUnauthorized: 'passthrough',
})
}
return {
deleteAccount,
downloadMyData,
updateDisplayName,
sendPasswordReset,
requestEmailChange,
resendEmailChange,
cancelEmailChange,
confirmEmailChange,
}
} }
+87
View File
@@ -0,0 +1,87 @@
import { afterEach, describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import uiPlugin from '@nuxt/ui/vue-plugin'
import { createPinia } from 'pinia'
import router from '@/router'
import BlockedDialog from '@/components/BlockedDialog.vue'
import { setLocale } from '@/i18n'
import { messages, type MessageKey } from '@/i18n/messages'
const KEYS: MessageKey[] = [
'Danger zone',
"Delete your account and your personal data. This can't be undone.",
'Delete my account',
'Delete your account?',
"This permanently deletes your account. It can't be undone.",
'Your username, display name and email address',
'Your author profile and API keys',
"Your sessions: you'll be logged out everywhere",
'Your articles and media stay',
'They stay on the site, credited to "Deleted user". Your name is removed from them.',
'Deleted user',
'Export my data first',
'Current password',
'Type DELETE to confirm',
'Keep my account',
'Deleting…',
"That password isn't correct. Try again.",
'Too many requests. Try again in a few minutes.',
"Couldn't delete your account. Nothing was deleted. Try again in a moment.",
"You can't delete your account yet",
'You are the last administrator. RenewCMS needs at least one. Promote another user first, then come back to delete your account.',
'You are the last Owner. RenewCMS needs at least one. Transfer ownership or promote another user first, then come back to delete your account.',
'Back to profile',
'Go to Users',
"This account can't be deleted",
"RenewCMS can't delete this account right now. Ask an administrator to help, then try again.",
'Your account has been deleted',
'Your articles and media were kept without your name.',
'Deleted the account of {0}',
'Failed to delete the account of {0}',
]
function placeholders(text: string) {
return (text.match(/\{\d+\}/g) ?? []).sort()
}
afterEach(() => setLocale('en'))
describe('account deletion catalogue', () => {
it.each(KEYS)('has a French value with the same placeholders for %s', (key) => {
const french = messages[key]
expect(french.trim()).not.toBe('')
expect(placeholders(french)).toEqual(placeholders(key))
})
it('keeps the confirmation word DELETE literal in the French label', () => {
expect(messages['Type DELETE to confirm']).toContain('DELETE')
})
it('renders the blocked dialog in French and back in English', async () => {
const mountBlocked = () =>
mount(BlockedDialog, {
props: { open: true, lastRole: 'Owner' },
attachTo: document.body,
global: { plugins: [router, uiPlugin, createPinia()] },
})
setLocale('fr')
const french = mountBlocked()
await french.vm.$nextTick()
expect(document.body.textContent).toContain(
'Vous ne pouvez pas encore supprimer votre compte',
)
expect(document.body.textContent).toContain('Retour au profil')
expect(document.body.textContent).not.toContain('Back to profile')
french.unmount()
document.body.innerHTML = ''
setLocale('en')
const english = mountBlocked()
await english.vm.$nextTick()
expect(document.body.textContent).toContain("You can't delete your account yet")
english.unmount()
document.body.innerHTML = ''
})
})
+116
View File
@@ -1212,6 +1212,21 @@ export const messages = {
'Registered the account {0}': 'Compte créé : {0}', 'Registered the account {0}': 'Compte créé : {0}',
'Verified the account {0}': 'Compte vérifié : {0}', 'Verified the account {0}': 'Compte vérifié : {0}',
'Updated the profile of {0}': 'Profil mis à jour : {0}', 'Updated the profile of {0}': 'Profil mis à jour : {0}',
'Requested an email change for {0}': 'Changement d’e-mail demandé : {0}',
'Failed to request an email change for {0}': 'Échec de la demande de changement d’e-mail : {0}',
'Resent the email change link for {0}': 'Lien de changement d’e-mail renvoyé : {0}',
'Failed to resend the email change link for {0}':
'Échec du renvoi du lien de changement d’e-mail : {0}',
'Cancelled the email change for {0}': 'Changement d’e-mail annulé : {0}',
'Failed to cancel the email change for {0}':
'Échec de l’annulation du changement d’e-mail : {0}',
'Confirmed the email change for {0}': 'Changement d’e-mail confirmé : {0}',
'Failed to confirm the email change for {0}':
'Échec de la confirmation du changement d’e-mail : {0}',
'Exported the personal data of {0}': 'Données personnelles exportées : {0}',
'Failed to export the personal data of {0}': 'Échec de l’export des données personnelles : {0}',
'Deleted the account of {0}': 'Compte supprimé : {0}',
'Failed to delete the account of {0}': 'Échec de la suppression du compte : {0}',
'Invited {0}': 'Invitation envoyée à {0}', 'Invited {0}': 'Invitation envoyée à {0}',
'Cancelled the invite for {0}': 'Invitation annulée : {0}', 'Cancelled the invite for {0}': 'Invitation annulée : {0}',
'Renewed the invite for {0}': 'Invitation renouvelée : {0}', 'Renewed the invite for {0}': 'Invitation renouvelée : {0}',
@@ -3303,6 +3318,107 @@ export const messages = {
"You haven't created {0} yet": 'Vous n’avez pas encore créé d’entrée dans {0}', "You haven't created {0} yet": 'Vous n’avez pas encore créé d’entrée dans {0}',
"Your role shows only entries you created, so other people's {0} aren't listed here. Use New entry to add one.": "Your role shows only entries you created, so other people's {0} aren't listed here. Use New entry to add one.":
'Votre rôle n’affiche que les entrées que vous avez créées, donc les entrées des autres personnes dans {0} ne sont pas listées ici. Utilisez Nouvelle entrée pour en ajouter une.', 'Votre rôle n’affiche que les entrées que vous avez créées, donc les entrées des autres personnes dans {0} ne sont pas listées ici. Utilisez Nouvelle entrée pour en ajouter une.',
'Change email': 'Changer l’e-mail',
'New email address': 'Nouvelle adresse e-mail',
'name@example.com': 'nom@exemple.fr',
'Current password': 'Mot de passe actuel',
"We'll send a verification link to the new address. Your email stays the same until you open it.":
'Nous enverrons un lien de vérification à la nouvelle adresse. Votre e-mail reste inchangé tant que vous n’ouvrez pas le lien.',
'Send verification link': 'Envoyer le lien de vérification',
"That's already your email address.": 'C’est déjà votre adresse e-mail.',
'Enter your current password.': 'Saisissez votre mot de passe actuel.',
"That password isn't correct. Try again.": 'Ce mot de passe n’est pas correct. Réessayez.',
'Too many requests. Try again in a few minutes.':
'Trop de demandes. Réessayez dans quelques minutes.',
'Waiting for verification': 'En attente de vérification',
'We sent a link to {0}. Until you open it, you keep logging in with {1}.':
'Nous avons envoyé un lien à {0}. Tant que vous ne l’ouvrez pas, vous continuez à vous connecter avec {1}.',
'Resend link': 'Renvoyer le lien',
'Resend in {0}': 'Renvoyer dans {0}',
'Cancel change': 'Annuler le changement',
'Verification link sent': 'Lien de vérification envoyé',
'Check {0}. The link works once, within 2 hours.':
'Consultez {0}. Le lien ne fonctionne qu’une fois, dans les 2 heures.',
'Link sent again': 'Lien renvoyé',
'The previous link no longer works.': 'Le lien précédent ne fonctionne plus.',
'Email change cancelled': 'Changement d’e-mail annulé',
'You keep logging in with {0}.': 'Vous continuez à vous connecter avec {0}.',
'Confirming your new email…': 'Confirmation de votre nouvelle adresse e-mail…',
'Your email address has changed': 'Votre adresse e-mail a changé',
'Your account now uses {0}. Use it the next time you log in.':
'Votre compte utilise maintenant {0}. Utilisez-la lors de votre prochaine connexion.',
'Go to profile': 'Aller au profil',
'Email address changed': 'Adresse e-mail modifiée',
'Your account now uses {0}.': 'Votre compte utilise maintenant {0}.',
"This link doesn't work": 'Ce lien ne fonctionne pas',
'It may have expired (links last 2 hours), been cancelled or already been used.':
'Il a peut-être expiré (les liens durent 2 heures), été annulé ou déjà été utilisé.',
'Request a new link from your profile.': 'Demandez un nouveau lien depuis votre profil.',
"We couldn't change your email address": 'Nous n’avons pas pu changer votre adresse e-mail',
"This address can't be used for your account. Go back to your profile and try a different one.":
'Cette adresse ne peut pas être utilisée pour votre compte. Retournez à votre profil et essayez-en une autre.',
'Log in to confirm your new email':
'Connectez-vous pour confirmer votre nouvelle adresse e-mail',
"You opened this link somewhere you're not logged in. Log in here and we'll confirm the change right after.":
'Vous avez ouvert ce lien là où vous n’êtes pas connecté. Connectez-vous ici et nous confirmerons le changement juste après.',
'Log in and confirm': 'Se connecter et confirmer',
'The confirmation code is missing. Open the link from your email again, or copy the full address into your browser.':
'Le code de confirmation manque. Rouvrez le lien de votre e-mail ou copiez l’adresse complète dans votre navigateur.',
"Couldn't confirm the change": 'Impossible de confirmer le changement',
'Your data': 'Vos données',
'Download a copy of the personal data RenewCMS holds about you, as JSON files in a ZIP.':
'Téléchargez une copie des données personnelles que RenewCMS conserve à votre sujet, sous forme de fichiers JSON dans un ZIP.',
'Username, display name, email, role and author profile.':
'Nom d’utilisateur, nom d’affichage, e-mail, rôle et profil d’auteur.',
'Authored content': 'Contenus rédigés',
'Your articles and entries.': 'Vos articles et vos entrées.',
'Uploaded media': 'Médias téléversés',
'References to the files you uploaded.': 'Références des fichiers que vous avez téléversés.',
'Account activity': 'Activité du compte',
'Your log-ins and the actions recorded under your name.':
'Vos connexions et les actions enregistrées à votre nom.',
'Download my data': 'Télécharger mes données',
'Ready in a few seconds.': 'Prêt en quelques secondes.',
'{0} is in your downloads.': '{0} se trouve dans vos téléchargements.',
'You can export once every 15 minutes': 'Vous pouvez exporter une fois toutes les 15 minutes',
'Your last export was at {0}. Try again in {1} minutes.':
'Votre dernier export date de {0}. Réessayez dans {1} minutes.',
'Your last export was at {0}. Try again in 1 minute.':
'Votre dernier export date de {0}. Réessayez dans 1 minute.',
'Available in {0}': 'Disponible dans {0}',
"Couldn't prepare your data": 'Impossible de préparer vos données',
'Danger zone': 'Zone de danger',
"Delete your account and your personal data. This can't be undone.":
'Supprimez votre compte et vos données personnelles. Cette action est irréversible.',
'Delete my account': 'Supprimer mon compte',
'Delete your account?': 'Supprimer votre compte ?',
"This permanently deletes your account. It can't be undone.":
'Cette action supprime définitivement votre compte. Elle est irréversible.',
'Your username, display name and email address':
'Votre nom d’utilisateur, votre nom d’affichage et votre adresse e-mail',
'Your author profile and API keys': 'Votre profil d’auteur et vos clés d’API',
"Your sessions: you'll be logged out everywhere":
'Vos sessions : vous serez déconnecté partout',
'Your articles and media stay': 'Vos articles et vos médias restent',
'They stay on the site, credited to "Deleted user". Your name is removed from them.':
'Ils restent sur le site, attribués à « Utilisateur supprimé ». Votre nom en est retiré.',
'Deleted user': 'Utilisateur supprimé',
'Export my data first': 'Exporter mes données d’abord',
'Type DELETE to confirm': 'Saisissez DELETE pour confirmer',
"Couldn't delete your account. Nothing was deleted. Try again in a moment.":
'Impossible de supprimer votre compte. Rien n’a été supprimé. Réessayez dans un instant.',
"You can't delete your account yet": 'Vous ne pouvez pas encore supprimer votre compte',
'You are the last administrator. RenewCMS needs at least one. Promote another user first, then come back to delete your account.':
'Vous êtes le dernier administrateur. RenewCMS en a besoin d’au moins un. Promouvez d’abord un autre utilisateur, puis revenez supprimer votre compte.',
'You are the last Owner. RenewCMS needs at least one. Transfer ownership or promote another user first, then come back to delete your account.':
'Vous êtes le dernier propriétaire. RenewCMS en a besoin d’au moins un. Transférez d’abord la propriété ou promouvez un autre utilisateur, puis revenez supprimer votre compte.',
'Back to profile': 'Retour au profil',
"This account can't be deleted": 'Ce compte ne peut pas être supprimé',
"RenewCMS can't delete this account right now. Ask an administrator to help, then try again.":
'RenewCMS ne peut pas supprimer ce compte pour le moment. Demandez de l’aide à un administrateur, puis réessayez.',
'Your account has been deleted': 'Votre compte a été supprimé',
'Your articles and media were kept without your name.':
'Vos articles et vos médias ont été conservés sans votre nom.',
} as const } as const
export type MessageKey = keyof typeof messages export type MessageKey = keyof typeof messages
@@ -269,6 +269,12 @@ describe('The catalogue is complete in both languages', () => {
'user.register', 'user.register',
'user.verify', 'user.verify',
'user.update_profile', 'user.update_profile',
'user.request_email_change',
'user.resend_email_change',
'user.cancel_email_change',
'user.confirm_email_change',
'user.export_data',
'user.delete_account',
'invite.create', 'invite.create',
'invite.cancel', 'invite.cancel',
'invite.renew', 'invite.renew',
@@ -301,6 +307,48 @@ describe('The catalogue is complete in both languages', () => {
}) })
}) })
describe('email change sentences', () => {
const target = { type: 'user', id: '7', name: 'marta', exists: true }
it.each([
[
'user.request_email_change',
'Requested an email change for marta',
'Failed to request an email change for marta',
],
[
'user.resend_email_change',
'Resent the email change link for marta',
'Failed to resend the email change link for marta',
],
[
'user.cancel_email_change',
'Cancelled the email change for marta',
'Failed to cancel the email change for marta',
],
[
'user.confirm_email_change',
'Confirmed the email change for marta',
'Failed to confirm the email change for marta',
],
[
'user.export_data',
'Exported the personal data of marta',
'Failed to export the personal data of marta',
],
[
'user.delete_account',
'Deleted the account of marta',
'Failed to delete the account of marta',
],
])('writes %s as a sentence, success and failure', (action, success, failed) => {
expect(sentenceText(item({ action, category: 'users', target }))).toBe(success)
expect(sentenceText(item({ action, category: 'users', target, outcome: 'failed' }))).toBe(
failed,
)
})
})
describe('role and ownership sentences', () => { describe('role and ownership sentences', () => {
const roleTarget = (name: string) => ({ type: 'role', id: '4', name, exists: true }) const roleTarget = (name: string) => ({ type: 'role', id: '4', name, exists: true })
const userTarget = (name: string) => ({ type: 'user', id: '9', name, exists: true }) const userTarget = (name: string) => ({ type: 'user', id: '9', name, exists: true })
@@ -531,6 +579,17 @@ describe('Roles sync: the six role events in EN and FR', () => {
]) ])
}) })
it('writes user.delete_account in English and in French, success and failed', () => {
const target = userTarget('marta')
const deleted = event({ action: 'user.delete_account', category: 'users', target })
expect(both(deleted)).toEqual(['Deleted the account of marta', 'Compte supprimé : marta'])
expect(both({ ...deleted, outcome: 'failed' })).toEqual([
'Failed to delete the account of marta',
'Échec de la suppression du compte : marta',
])
})
it('writes user.remove with the success and the failed sentence', () => { it('writes user.remove with the success and the failed sentence', () => {
const target = userTarget('Ines Duarte') const target = userTarget('Ines Duarte')
const removed = event({ action: 'user.remove', category: 'users', target }) const removed = event({ action: 'user.remove', category: 'users', target })
@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest'
import { filenameFromDisposition } from '@/lib/contentDisposition'
describe('filenameFromDisposition', () => {
it('reads a quoted filename', () => {
expect(
filenameFromDisposition('attachment; filename="renewcms-export-marta-2026-10-29.zip"'),
).toBe('renewcms-export-marta-2026-10-29.zip')
})
it('reads a bare filename', () => {
expect(filenameFromDisposition('attachment; filename=export.zip')).toBe('export.zip')
})
it('unescapes quoted characters and ignores letter case and spacing', () => {
expect(filenameFromDisposition('attachment; FILENAME = "a\\"b.zip"')).toBe('a"b.zip')
})
it('prefers the quoted filename when a bare one follows', () => {
expect(filenameFromDisposition('attachment; filename="one.zip"; filename=two.zip')).toBe(
'one.zip',
)
})
it('stops a bare filename at the next parameter', () => {
expect(filenameFromDisposition('attachment; filename=export.zip; size=3')).toBe(
'export.zip',
)
})
it('returns nothing without a header or a filename', () => {
expect(filenameFromDisposition(null)).toBeUndefined()
expect(filenameFromDisposition('attachment')).toBeUndefined()
})
})
+22
View File
@@ -0,0 +1,22 @@
import { afterEach, describe, expect, it } from 'vitest'
import { setLocale } from '@/i18n'
import { namesWithDeletedFallback } from '@/lib/deletedUser'
afterEach(() => setLocale('en'))
describe('namesWithDeletedFallback', () => {
const found = [{ id: 2, display_name: 'Jonas Weber' }]
it('names an id that the lookup did not return Deleted user', () => {
expect(namesWithDeletedFallback([2, 7], found)).toEqual({
2: 'Jonas Weber',
7: 'Deleted user',
})
})
it('speaks French when the language is French', () => {
setLocale('fr')
expect(namesWithDeletedFallback([7], [])).toEqual({ 7: 'Utilisateur supprimé' })
})
})
@@ -0,0 +1,45 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { downloadBlob } from '@/lib/downloadBlob'
describe('downloadBlob', () => {
const createObjectURL = vi.fn(() => 'blob:export')
const revokeObjectURL = vi.fn()
beforeEach(() => {
vi.useFakeTimers()
createObjectURL.mockClear()
revokeObjectURL.mockClear()
vi.stubGlobal('URL', { createObjectURL, revokeObjectURL })
})
afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
})
it('clicks an anchor carrying the file name and removes it', () => {
const clicked: HTMLAnchorElement[] = []
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (
this: HTMLAnchorElement,
) {
clicked.push(this)
})
downloadBlob(new Blob(['zip']), 'export.zip')
expect(clicked).toHaveLength(1)
expect(clicked[0]!.download).toBe('export.zip')
expect(clicked[0]!.href).toBe('blob:export')
expect(document.body.contains(clicked[0]!)).toBe(false)
})
it('revokes the object URL only after a delay so the download is not cancelled', () => {
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
downloadBlob(new Blob(['zip']), 'export.zip')
expect(revokeObjectURL).not.toHaveBeenCalled()
vi.advanceTimersByTime(1000)
expect(revokeObjectURL).toHaveBeenCalledWith('blob:export')
})
})
Loaded 100 of 123 files, more files were not shown because too many files have changed in this diff. Show more