feat: change email, export my data, delete account (with data anonymization)
This commit is contained in:
1 parent
bb191c389a
commit
10a66cd979
123 files changed
+10789
-76
No files matched your search
@@ -98,6 +98,10 @@ type UseCases struct {
|
||||
RequestPasswordResetUseCase *useCases.RequestPasswordResetUseCase
|
||||
ResetPasswordUseCase *useCases.ResetPasswordUseCase
|
||||
UpdateUserProfileUseCase *useCases.UpdateUserProfileUseCase
|
||||
RequestEmailChangeUseCase *useCases.RequestEmailChangeUseCase
|
||||
ResendEmailChangeUseCase *useCases.ResendEmailChangeUseCase
|
||||
CancelEmailChangeUseCase *useCases.CancelEmailChangeUseCase
|
||||
ConfirmEmailChangeUseCase *useCases.ConfirmEmailChangeUseCase
|
||||
ListUsersUseCase *useCases.ListUsersUseCase
|
||||
CreateInviteUseCase *useCases.CreateInviteUseCase
|
||||
CancelInviteUseCase *useCases.CancelInviteUseCase
|
||||
@@ -105,6 +109,8 @@ type UseCases struct {
|
||||
ListUsersAndInvitesUseCase *useCases.ListUsersAndInvitesUseCase
|
||||
ChangeUserRoleUseCase *useCases.ChangeUserRoleUseCase
|
||||
RemoveUserUseCase *useCases.RemoveUserUseCase
|
||||
ExportUserDataUseCase *useCases.ExportUserDataUseCase
|
||||
DeleteOwnAccountUseCase *useCases.DeleteOwnAccountUseCase
|
||||
TransferOwnershipUseCase *useCases.TransferOwnershipUseCase
|
||||
GetInviteUseCase *useCases.GetInviteUseCase
|
||||
AcceptInviteUseCase *useCases.AcceptInviteUseCase
|
||||
@@ -174,6 +180,7 @@ type Repositories struct {
|
||||
EntryRepo domainEntry.Repository
|
||||
AuthorRepo domainAuthor.Repository
|
||||
UserRepo domainUser.Repository
|
||||
UserEraser domainUser.Eraser
|
||||
RoleRepo domainRole.Repository
|
||||
ImageRepo domainImage.Repository
|
||||
Transactor domainTransaction.Transactor
|
||||
@@ -208,6 +215,8 @@ const (
|
||||
updateHelperFileName = "update-helper.json"
|
||||
updatesDirName = "updates"
|
||||
backupsDirName = "backups"
|
||||
|
||||
maxConcurrentDataExports = 2
|
||||
)
|
||||
|
||||
func imageStorageConfig(settings config.Settings) storage.Config {
|
||||
@@ -319,6 +328,7 @@ func getRepositories(connection *database.Connection, settings config.Settings)
|
||||
EntryRepo: persistence.NewEntryRepository(db),
|
||||
AuthorRepo: persistence.NewAuthorRepository(db),
|
||||
UserRepo: persistence.NewUserRepository(db),
|
||||
UserEraser: persistence.NewAccountEraser(db),
|
||||
RoleRepo: persistence.NewRoleRepository(db),
|
||||
ImageRepo: persistence.NewImageRepository(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 {
|
||||
hasher := hashing.NewBcryptHasher()
|
||||
mailDispatcher := mailer.NewAsyncDispatcher()
|
||||
sessionRevocation := useCases.NewSessionRevocationUseCase(repos.SessionRepo, time.Now)
|
||||
renderer := markdown.NewConverter()
|
||||
keyGenerator := hashing.NewSHA256KeyGenerator()
|
||||
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())
|
||||
|
||||
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()),
|
||||
ActivityCleanupScheduler: scheduler.NewSystemCleanupRunner(activityCleanup, 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),
|
||||
GetArticleUseCase: useCases.NewGetArticleUseCase(repos.ArticleRepo, renderer),
|
||||
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),
|
||||
ResetPasswordUseCase: useCases.NewResetPasswordUseCase(repos.UserRepo, hasher, repos.APIKeyRepo, time.Now, 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),
|
||||
CreateInviteUseCase: useCases.NewCreateInviteUseCase(repos.UserRepo, repos.InviteRepo, repos.RoleRepo, repos.ContentTypeRepo, hasher, repos.MailRepo, settings.PublicURL, 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),
|
||||
ChangeUserRoleUseCase: useCases.NewChangeUserRoleUseCase(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),
|
||||
GetInviteUseCase: useCases.NewGetInviteUseCase(repos.InviteRepo, hasher),
|
||||
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),
|
||||
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(repos.ImageRepo, imageStorage, repos.VariantFileRepo, recorder, repos.SiteRepo, repos.AuthorRepo, repos.ArticleRepo),
|
||||
MergeMediaUseCase: mergeMedia,
|
||||
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(repos.ImageRepo, repos.ArticleRepo, repos.SiteRepo, repos.AuthorRepo, repos.ContentTypeRepo, repos.EntryRepo),
|
||||
GetMediaUsageUseCase: mediaUsage,
|
||||
GetPurgeSettingsUseCase: useCases.NewGetPurgeSettingsUseCase(repos.PurgeSettingsRepo),
|
||||
UpdatePurgeSettingsUseCase: useCases.NewUpdatePurgeSettingsUseCase(repos.PurgeSettingsRepo, time.Now),
|
||||
PreviewPurgeUseCase: useCases.NewPreviewPurgeUseCase(repos.ImageRepo, repos.ArticleRepo, repos.SiteRepo, repos.AuthorRepo, repos.ContentTypeRepo, repos.EntryRepo),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -40,16 +40,7 @@ func NewLoginHandler(token *authtoken.Auth, create *useCases.CreateUserUseCase,
|
||||
}
|
||||
|
||||
func removeJwtCookie(w http.ResponseWriter, isProduction bool) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: authtoken.CookieName,
|
||||
Value: "",
|
||||
Expires: time.Now(),
|
||||
MaxAge: -1,
|
||||
Secure: isProduction,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Path: "/",
|
||||
})
|
||||
http.SetCookie(w, authtoken.ExpiredCookie(isProduction))
|
||||
}
|
||||
|
||||
func setJwtCookie(w http.ResponseWriter, token *authtoken.Auth, isProduction bool, u user.User) error {
|
||||
|
||||
@@ -41,8 +41,9 @@ type meAccess struct {
|
||||
|
||||
type meResponse struct {
|
||||
publicuser.PublicUser
|
||||
Author *meAuthor `json:"author"`
|
||||
Access meAccess `json:"access"`
|
||||
PendingEmail *string `json:"pending_email"`
|
||||
Author *meAuthor `json:"author"`
|
||||
Access meAccess `json:"access"`
|
||||
}
|
||||
|
||||
func toMeAccess(access useCases.Access) meAccess {
|
||||
@@ -60,6 +61,13 @@ func toMeAuthor(found *author.Author) *meAuthor {
|
||||
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) {
|
||||
userId, ok := claims.UserId(r)
|
||||
if !ok {
|
||||
@@ -89,7 +97,7 @@ func (h *MeHandler) Me(w http.ResponseWriter, r *http.Request) {
|
||||
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 {
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -127,3 +127,12 @@ const InvalidRoleErrorMessage = "invalid_role"
|
||||
const UserRoleChangedErrorMessage = "user_role_changed"
|
||||
const InvalidTransferErrorMessage = "invalid_transfer"
|
||||
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"
|
||||
@@ -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
@@ -52,6 +52,14 @@ func GetHelloWorld(w http.ResponseWriter, _ *http.Request) {
|
||||
const (
|
||||
apiKeyMaxRequests = 120
|
||||
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 {
|
||||
@@ -119,6 +127,7 @@ func NewAuthRouter(container *UseCases, isProduction bool, rateLimit config.Auth
|
||||
verificationHandler := auth.NewVerificationHandler(container.VerifyUserUseCase, container.CancelRegistrationUseCase, isProduction)
|
||||
meHandler := auth.NewMeHandler(container.GetUserUseCase, container.GetMyAuthorUseCase, container.GetMyAccessUseCase)
|
||||
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)
|
||||
|
||||
r.Post("/register", loginHandler.Register)
|
||||
@@ -149,6 +158,22 @@ func NewAuthRouter(container *UseCases, isProduction bool, rateLimit config.Auth
|
||||
r.Post("/verify", verificationHandler.Verify)
|
||||
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.Use(authtoken.Verifier(container.Token))
|
||||
r.Use(container.apiKeyAuth())
|
||||
@@ -159,7 +184,7 @@ func NewAuthRouter(container *UseCases, isProduction bool, rateLimit config.Auth
|
||||
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()
|
||||
|
||||
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)
|
||||
roleHandler := user.NewRoleHandler(container.ChangeUserRoleUseCase)
|
||||
removeHandler := user.NewRemoveHandler(container.RemoveUserUseCase)
|
||||
exportHandler := user.NewExportHandler(container.ExportUserDataUseCase)
|
||||
deleteAccountHandler := user.NewDeleteAccountHandler(container.DeleteOwnAccountUseCase, isProduction)
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
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.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.Use(middleware.RequireSession)
|
||||
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.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("/site", NewSiteRouter(container, settings.TrustedProxies))
|
||||
r.Mount("/role", NewRoleRouter(container, settings.TrustedProxies))
|
||||
|
||||
+92
-3
@@ -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} |
|
||||
| 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 |
|
||||
| Sites | GET /v1/site/, GET /v1/site/{key}/sitemap |
|
||||
| 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` |
|
||||
| `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` |
|
||||
| `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` |
|
||||
@@ -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
|
||||
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
|
||||
|
||||
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
|
||||
(`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,
|
||||
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_id` on the user (no foreign key, no backfill, existing rows are `NULL`) and the name is never stored: it
|
||||
|
||||
+8
-8
@@ -15,14 +15,15 @@ Readers of your public site are not tracked by RenewCMS: the public routes set n
|
||||
|
||||
| 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. |
|
||||
| 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. |
|
||||
| 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. |
|
||||
| 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. |
|
||||
| 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)). |
|
||||
| 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 |
|
||||
|---|---|---|
|
||||
| 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. |
|
||||
| `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
|
||||
|
||||
- **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.
|
||||
- **Rectification (art. 16)**: users can change their display name and author profile themselves. Changing an email address currently needs a database edit.
|
||||
- **Erasure (art. 17)**: an unverified account can cancel its own registration. Deleting a verified user has no button yet; see [Gaps](#gaps).
|
||||
- **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, 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)**: 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
|
||||
|
||||
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.
|
||||
- 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.
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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
|
||||
@@ -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)
|
||||
GetByName(name string) (Article, error)
|
||||
GetAll() ([]Article, error)
|
||||
ListForAccount(creatorID uint32, authorID *uint32) ([]Article, error)
|
||||
Create(post Article) (Article, error)
|
||||
UpdateBody(id uint32, body string) (Article, error)
|
||||
Update(id uint32, update Update) (Article, error)
|
||||
|
||||
@@ -106,6 +106,12 @@ const (
|
||||
ActionUserChangeRole = "user.change_role"
|
||||
ActionUserTransferOwnership = "user.transfer_ownership"
|
||||
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"
|
||||
ActionInviteCancel = "invite.cancel"
|
||||
ActionInviteRenew = "invite.renew"
|
||||
@@ -132,6 +138,7 @@ const (
|
||||
const (
|
||||
SystemActorName = "System"
|
||||
ReasonWrongLogin = "Wrong username or password."
|
||||
ReasonWrongPassword = "Wrong password."
|
||||
MaxUserAgentLength = 255
|
||||
ParamRemoved = "removed"
|
||||
ParamFrom = "from"
|
||||
|
||||
@@ -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)
|
||||
DeleteOldest(limit int, before time.Time) (int, error)
|
||||
SizeBytes() (int64, error)
|
||||
AnonymiseUser(userID uint32, ownAuthorID *uint32, name string) error
|
||||
}
|
||||
|
||||
type SettingsRepository interface {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -112,7 +112,7 @@ type Page struct {
|
||||
}
|
||||
|
||||
func IsValidSlug(candidate string) bool {
|
||||
return slug.IsValid(candidate)
|
||||
return slug.IsValid(candidate) && !IsReservedSlug(candidate)
|
||||
}
|
||||
|
||||
func IsValidName(name string) bool {
|
||||
@@ -172,9 +172,9 @@ func ValidateLinks(links []SocialLink) error {
|
||||
}
|
||||
|
||||
func GenerateSlug(name string) string {
|
||||
return slug.Generate(name, FallbackSlug)
|
||||
return avoidReserved(slug.Generate(name, FallbackSlug))
|
||||
}
|
||||
|
||||
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)
|
||||
Get(contentTypeID uint32, id uint32) (Entry, error)
|
||||
ListPage(filters Filters) (PageResult, error)
|
||||
ListByCreator(creatorID uint32) ([]Entry, error)
|
||||
Update(entry Entry) (Entry, error)
|
||||
Delete(contentTypeID uint32, id uint32) error
|
||||
DeleteByContentType(contentTypeID uint32) error
|
||||
|
||||
@@ -10,6 +10,7 @@ type Repository interface {
|
||||
Get(id uint32) (Image, error)
|
||||
Delete(id uint32) error
|
||||
List(query SearchQuery) ([]Image, error)
|
||||
ListByUploader(userID uint32) ([]Image, error)
|
||||
FindByHash(hash string) ([]Image, error)
|
||||
Rename(id uint32, filename string) (Image, error)
|
||||
ListDuplicates() ([]Image, error)
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
package mail
|
||||
|
||||
type Dispatcher interface {
|
||||
Dispatch(job func())
|
||||
}
|
||||
@@ -22,9 +22,16 @@ var ErrPasswordTooLong = errors.New("password too long")
|
||||
var ErrRoleChanged = errors.New("user role changed concurrently")
|
||||
var ErrInvalidTransfer = errors.New("invalid ownership transfer")
|
||||
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 EmailChangeLifetime = 2 * time.Hour
|
||||
|
||||
const MaxPasswordBytes = 72
|
||||
|
||||
func ValidatePassword(password string) error {
|
||||
@@ -47,6 +54,9 @@ type User struct {
|
||||
VerificationCode string `json:"verification_code"`
|
||||
VerificationExpiration time.Time `json:"verification_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"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
@@ -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)
|
||||
ResetPassword(userId uint32, hashedPassword 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)
|
||||
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"
|
||||
case "password-reset":
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
VerificationExpiration: m.VerificationExpiration,
|
||||
PasswordResetExpiration: m.PasswordResetExpiration,
|
||||
PendingEmail: nullableStringToDomain(m.PendingEmail),
|
||||
EmailChangeCode: nullableStringToDomain(m.EmailChangeCode),
|
||||
EmailChangeExpiration: m.EmailChangeExpiration,
|
||||
TokenVersion: m.TokenVersion,
|
||||
CreatedAt: m.CreatedAt,
|
||||
UpdatedAt: m.UpdatedAt,
|
||||
@@ -66,6 +69,9 @@ func UserToModel(a user.User) models.User {
|
||||
VerificationCode: DomainToNullableString(a.VerificationCode),
|
||||
VerificationExpiration: a.VerificationExpiration,
|
||||
PasswordResetExpiration: a.PasswordResetExpiration,
|
||||
PendingEmail: DomainToNullableString(a.PendingEmail),
|
||||
EmailChangeCode: DomainToNullableString(a.EmailChangeCode),
|
||||
EmailChangeExpiration: a.EmailChangeExpiration,
|
||||
TokenVersion: a.TokenVersion,
|
||||
CreatedAt: a.CreatedAt,
|
||||
UpdatedAt: a.UpdatedAt,
|
||||
|
||||
@@ -10,8 +10,8 @@ type AuditEvent struct {
|
||||
ActorType string `gorm:"not null;size:16;index:idx_audit_events_actor,priority:1"`
|
||||
ActorID *uint32 `gorm:"index:idx_audit_events_actor,priority:2"`
|
||||
ActorName string `gorm:"not null;size:255"`
|
||||
TargetType string `gorm:"not null;size:32"`
|
||||
TargetID string `gorm:"not null;size:64"`
|
||||
TargetType string `gorm:"not null;size:32;index:idx_audit_events_target,priority:1"`
|
||||
TargetID string `gorm:"not null;size:64;index:idx_audit_events_target,priority:2"`
|
||||
TargetName string `gorm:"not null;size:255"`
|
||||
Outcome string `gorm:"not null;size:16"`
|
||||
FailureReason *string `gorm:"size:255"`
|
||||
|
||||
@@ -17,6 +17,9 @@ type User struct {
|
||||
VerificationCode *string `gorm:"unique;size:255"`
|
||||
VerificationExpiration 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"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime"`
|
||||
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{}
|
||||
|
||||
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"
|
||||
entity "RenewCMS/internal/infrastructure/persistence/models"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -233,3 +234,38 @@ func (a *AuditEventRepository) DeleteOldest(limit int, before time.Time) (int, e
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
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{}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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) {
|
||||
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)
|
||||
|
||||
@@ -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.ErrInvalidDisplayName, "That display name is too long."},
|
||||
{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."},
|
||||
{invite.ErrEmailHasAccount, "That email already has an account."},
|
||||
{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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,698 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/api/handlers/auth"
|
||||
"RenewCMS/api/middleware"
|
||||
"RenewCMS/api/pkg/authtoken"
|
||||
validator "RenewCMS/api/pkg/validator"
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/domain/mail"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
const (
|
||||
changeEmailHost = "https://cms.example.com"
|
||||
changeEmailOld = "marta@renew.example"
|
||||
changeEmailNew = "marta.alves@atelier-renew.example"
|
||||
changeEmailPassword = "limewash2026"
|
||||
)
|
||||
|
||||
type changeEmailDispatcher struct {
|
||||
mu sync.Mutex
|
||||
jobs []func()
|
||||
}
|
||||
|
||||
func (d *changeEmailDispatcher) Dispatch(job func()) {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
d.jobs = append(d.jobs, job)
|
||||
}
|
||||
|
||||
func (d *changeEmailDispatcher) Drain() {
|
||||
d.mu.Lock()
|
||||
jobs := d.jobs
|
||||
d.jobs = nil
|
||||
d.mu.Unlock()
|
||||
for _, job := range jobs {
|
||||
job()
|
||||
}
|
||||
}
|
||||
|
||||
func (d *changeEmailDispatcher) pending() int {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
return len(d.jobs)
|
||||
}
|
||||
|
||||
var _ mail.Dispatcher = &changeEmailDispatcher{}
|
||||
|
||||
type changeEmailFixture struct {
|
||||
dispatcher *changeEmailDispatcher
|
||||
users *mockUserRepo
|
||||
mail *mockMailRepo
|
||||
recorder *recordingRecorder
|
||||
token *authtoken.Auth
|
||||
router *chi.Mux
|
||||
}
|
||||
|
||||
func newChangeEmailFixture(t *testing.T) *changeEmailFixture {
|
||||
t.Helper()
|
||||
validator.InitValidator()
|
||||
f := &changeEmailFixture{
|
||||
dispatcher: &changeEmailDispatcher{},
|
||||
users: newMockUserRepo(),
|
||||
mail: newMockMailRepo(),
|
||||
recorder: &recordingRecorder{},
|
||||
token: authtoken.New([]byte("test-secret")),
|
||||
}
|
||||
f.users.users[7] = user.User{ID: 7, Username: "marta", Email: changeEmailOld, Password: "hashed:" + changeEmailPassword, IsVerified: true}
|
||||
f.users.users[8] = user.User{ID: 8, Username: "ines", Email: "ines@renew.example", Password: "hashed:other", IsVerified: true}
|
||||
f.users.nextID = 8
|
||||
|
||||
hasher := &stubHasher{}
|
||||
getUser := useCases.NewGetUserUseCase(f.users)
|
||||
handler := auth.NewEmailChangeHandler(
|
||||
useCases.NewRequestEmailChangeUseCase(f.users, hasher, f.mail, f.dispatcher, changeEmailHost, time.Now, f.recorder),
|
||||
useCases.NewResendEmailChangeUseCase(f.users, hasher, f.mail, f.dispatcher, changeEmailHost, time.Now, f.recorder),
|
||||
useCases.NewCancelEmailChangeUseCase(f.users, f.recorder),
|
||||
useCases.NewConfirmEmailChangeUseCase(f.users, hasher, time.Now, f.recorder),
|
||||
)
|
||||
f.router = chi.NewRouter()
|
||||
f.router.Route("/v1/auth", func(r chi.Router) {
|
||||
r.Use(middleware.RequireSession)
|
||||
r.Use(authtoken.Verifier(f.token))
|
||||
r.Use(middleware.RequireSignedIn)
|
||||
r.Use(authtoken.Authenticator)
|
||||
r.Use(middleware.RequireCurrentToken(getUser))
|
||||
r.Use(middleware.RequireVerifiedUser(getUser))
|
||||
r.Delete("/email/change", handler.Cancel)
|
||||
r.Post("/email/confirm", handler.Confirm)
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.NewUserRateLimiter(5, 15*time.Minute))
|
||||
r.Post("/email/change", handler.Request)
|
||||
r.Post("/email/resend", handler.Resend)
|
||||
})
|
||||
})
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *changeEmailFixture) as(t *testing.T, userId uint32, method, target, body string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(method, target, bytes.NewBufferString(body))
|
||||
req.AddCookie(displayNameCookie(t, f.token, userId, 0))
|
||||
rec := httptest.NewRecorder()
|
||||
f.router.ServeHTTP(rec, req)
|
||||
f.dispatcher.Drain()
|
||||
return rec
|
||||
}
|
||||
|
||||
func (f *changeEmailFixture) request(t *testing.T, body string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
return f.as(t, 7, http.MethodPost, "/v1/auth/email/change", body)
|
||||
}
|
||||
|
||||
func (f *changeEmailFixture) requestFor(t *testing.T, newEmail string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
return f.request(t, `{"new_email": "`+newEmail+`", "current_password": "`+changeEmailPassword+`"}`)
|
||||
}
|
||||
|
||||
func (f *changeEmailFixture) resend(t *testing.T) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
return f.as(t, 7, http.MethodPost, "/v1/auth/email/resend", "")
|
||||
}
|
||||
|
||||
func (f *changeEmailFixture) cancel(t *testing.T) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
return f.as(t, 7, http.MethodDelete, "/v1/auth/email/change", "")
|
||||
}
|
||||
|
||||
func (f *changeEmailFixture) confirm(t *testing.T, userId uint32, code string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
return f.as(t, userId, http.MethodPost, "/v1/auth/email/confirm", `{"code": "`+code+`"}`)
|
||||
}
|
||||
|
||||
func (f *changeEmailFixture) lastVerificationCode(t *testing.T) string {
|
||||
t.Helper()
|
||||
for i := len(f.mail.SentEmails) - 1; i >= 0; i-- {
|
||||
if data, ok := f.mail.SentEmails[i].Data.(useCases.MailVerifyNewEmailData); ok {
|
||||
return data.VerificationCode
|
||||
}
|
||||
}
|
||||
t.Fatal("no verify-new-email mail was sent")
|
||||
return ""
|
||||
}
|
||||
|
||||
func (f *changeEmailFixture) mailsTo(address string) []recordedMail {
|
||||
var sent []recordedMail
|
||||
for _, m := range f.mail.SentEmails {
|
||||
if m.Address == address {
|
||||
sent = append(sent, m)
|
||||
}
|
||||
}
|
||||
return sent
|
||||
}
|
||||
|
||||
func (f *changeEmailFixture) marta() user.User {
|
||||
return f.users.users[7]
|
||||
}
|
||||
|
||||
func assertChangeEmailResponse(t *testing.T, rec *httptest.ResponseRecorder, status int, body string) {
|
||||
t.Helper()
|
||||
if rec.Code != status {
|
||||
t.Fatalf("expected status %d, got %d: %s", status, rec.Code, rec.Body.String())
|
||||
}
|
||||
if strings.TrimSpace(rec.Body.String()) != body {
|
||||
t.Fatalf("expected body %q, got %q", body, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func assertNothingPending(t *testing.T, f *changeEmailFixture) {
|
||||
t.Helper()
|
||||
if marta := f.marta(); marta.PendingEmail != "" || marta.EmailChangeCode != "" || !marta.EmailChangeExpiration.IsZero() {
|
||||
t.Errorf("expected no pending change, got %+v", marta)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountChangeEmailRequestHTTP(t *testing.T) {
|
||||
t.Run("A valid request stores a pending change and sends two mails", func(t *testing.T) {
|
||||
f := newChangeEmailFixture(t)
|
||||
|
||||
rec := f.requestFor(t, changeEmailNew)
|
||||
|
||||
assertChangeEmailResponse(t, rec, http.StatusOK, `{"pending_email":"`+changeEmailNew+`"}`)
|
||||
marta := f.marta()
|
||||
if marta.Email != changeEmailOld {
|
||||
t.Errorf("users.email must be untouched, got %q", marta.Email)
|
||||
}
|
||||
rawCode := f.lastVerificationCode(t)
|
||||
if marta.PendingEmail != changeEmailNew || marta.EmailChangeCode == "" || marta.EmailChangeCode == rawCode {
|
||||
t.Errorf("expected the pending email and a hashed code, got %+v", marta)
|
||||
}
|
||||
if delta := time.Until(marta.EmailChangeExpiration.Add(-2 * time.Hour)); delta > 5*time.Second || delta < -5*time.Second {
|
||||
t.Errorf("expected the expiration within 5 seconds of now plus 2 hours, got %v", marta.EmailChangeExpiration)
|
||||
}
|
||||
if len(f.mail.SentEmails) != 2 {
|
||||
t.Fatalf("expected two mails, got %d", len(f.mail.SentEmails))
|
||||
}
|
||||
verify := f.mailsTo(changeEmailNew)
|
||||
if len(verify) != 1 || verify[0].Template != "verify-new-email" {
|
||||
t.Fatalf("expected one verify-new-email mail to the new address, got %+v", verify)
|
||||
}
|
||||
if data := verify[0].Data.(useCases.MailVerifyNewEmailData); data.Host != changeEmailHost || data.Username != "marta" || data.VerificationCode != rawCode {
|
||||
t.Errorf("unexpected verify data %+v", data)
|
||||
}
|
||||
notice := f.mailsTo(changeEmailOld)
|
||||
if len(notice) != 1 || notice[0].Template != "email-change-notice" {
|
||||
t.Fatalf("expected one email-change-notice to the old address, got %+v", notice)
|
||||
}
|
||||
if data := notice[0].Data.(useCases.MailEmailChangeNoticeData); data.Host != changeEmailHost || data.Username != "marta" || data.NewEmail != changeEmailNew {
|
||||
t.Errorf("unexpected notice data %+v", data)
|
||||
}
|
||||
if len(f.recorder.events) != 1 {
|
||||
t.Fatalf("expected one audit event, got %d", len(f.recorder.events))
|
||||
}
|
||||
event := f.recorder.events[0]
|
||||
if event.Action != "user.request_email_change" || event.Category != audit.CategoryUsers || event.Outcome != audit.OutcomeSuccess || !event.Security || event.Target.ID != "7" || len(event.Params) != 0 {
|
||||
t.Errorf("unexpected audit event %+v", event)
|
||||
}
|
||||
})
|
||||
|
||||
refused := []struct {
|
||||
name string
|
||||
body string
|
||||
status int
|
||||
text string
|
||||
}{
|
||||
{"an invalid address", `{"new_email": "marta.alves@atelier", "current_password": "limewash2026"}`, http.StatusBadRequest, "NewEmail"},
|
||||
{"an empty address", `{"new_email": "", "current_password": "limewash2026"}`, http.StatusBadRequest, "NewEmail"},
|
||||
{"an empty password", `{"new_email": "marta.alves@atelier-renew.example", "current_password": ""}`, http.StatusBadRequest, "CurrentPassword"},
|
||||
{"a body that is not json", `not-json`, http.StatusBadRequest, "The request cannot be processed due to a mismatch in the format of the body."},
|
||||
{"the current address in another case", `{"new_email": "MARTA@renew.example", "current_password": "limewash2026"}`, http.StatusBadRequest, "same_email"},
|
||||
{"a wrong password", `{"new_email": "marta.alves@atelier-renew.example", "current_password": "nope"}`, http.StatusForbidden, "wrong_password"},
|
||||
}
|
||||
for _, tc := range refused {
|
||||
t.Run("A refused request changes nothing and sends nothing: "+tc.name, func(t *testing.T) {
|
||||
f := newChangeEmailFixture(t)
|
||||
|
||||
rec := f.request(t, tc.body)
|
||||
|
||||
if rec.Code != tc.status || !strings.Contains(rec.Body.String(), tc.text) {
|
||||
t.Errorf("expected %d containing %q, got %d %q", tc.status, tc.text, rec.Code, rec.Body.String())
|
||||
}
|
||||
assertNothingPending(t, f)
|
||||
if len(f.mail.SentEmails) != 0 {
|
||||
t.Errorf("expected no mail, got %d", len(f.mail.SentEmails))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("A wrong password records a failed security event", func(t *testing.T) {
|
||||
f := newChangeEmailFixture(t)
|
||||
|
||||
f.request(t, `{"new_email": "`+changeEmailNew+`", "current_password": "nope"}`)
|
||||
|
||||
if len(f.recorder.events) != 1 {
|
||||
t.Fatalf("expected one audit event, got %d", len(f.recorder.events))
|
||||
}
|
||||
event := f.recorder.events[0]
|
||||
if event.Action != "user.request_email_change" || event.Outcome != audit.OutcomeFailed || !event.Security || event.FailureReason == nil || *event.FailureReason != "Wrong password." {
|
||||
t.Errorf("unexpected audit event %+v", event)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("An address already in use answers like success and sends no verification mail", func(t *testing.T) {
|
||||
f := newChangeEmailFixture(t)
|
||||
fresh := f.requestFor(t, changeEmailNew)
|
||||
freshBody := fresh.Body.String()
|
||||
g := newChangeEmailFixture(t)
|
||||
|
||||
taken := g.requestFor(t, "Ines@Renew.example")
|
||||
|
||||
if taken.Code != fresh.Code || strings.ReplaceAll(taken.Body.String(), "Ines@Renew.example", changeEmailNew) != freshBody {
|
||||
t.Errorf("expected the same response as a free address, got %d %q", taken.Code, taken.Body.String())
|
||||
}
|
||||
for name, values := range fresh.Header() {
|
||||
if name != "Date" && strings.Join(taken.Header()[name], ",") != strings.Join(values, ",") {
|
||||
t.Errorf("header %s differs", name)
|
||||
}
|
||||
}
|
||||
if g.marta().PendingEmail != "Ines@Renew.example" {
|
||||
t.Errorf("expected the pending change to be stored, got %q", g.marta().PendingEmail)
|
||||
}
|
||||
if len(g.mailsTo("Ines@Renew.example"))+len(g.mailsTo("ines@renew.example")) != 0 {
|
||||
t.Error("no mail may reach the taken address")
|
||||
}
|
||||
if notice := g.mailsTo(changeEmailOld); len(notice) != 1 || notice[0].Template != "email-change-notice" {
|
||||
t.Errorf("expected the notice to the old address, got %+v", notice)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A new request replaces the previous link", func(t *testing.T) {
|
||||
f := newChangeEmailFixture(t)
|
||||
f.requestFor(t, "a@renew.example")
|
||||
first := f.lastVerificationCode(t)
|
||||
f.requestFor(t, "b@renew.example")
|
||||
second := f.lastVerificationCode(t)
|
||||
|
||||
old := f.confirm(t, 7, first)
|
||||
current := f.confirm(t, 7, second)
|
||||
|
||||
assertChangeEmailResponse(t, old, http.StatusForbidden, "invalid_email_change_code")
|
||||
assertChangeEmailResponse(t, current, http.StatusOK, `{"email":"b@renew.example"}`)
|
||||
})
|
||||
|
||||
t.Run("A failed send is logged and the response is unchanged", func(t *testing.T) {
|
||||
f := newChangeEmailFixture(t)
|
||||
f.mail.fail.failNextWith("Send", errors.New("smtp: connection refused"))
|
||||
var logBuf bytes.Buffer
|
||||
previous := log.Writer()
|
||||
log.SetOutput(&logBuf)
|
||||
defer log.SetOutput(previous)
|
||||
|
||||
rec := f.requestFor(t, changeEmailNew)
|
||||
|
||||
assertChangeEmailResponse(t, rec, http.StatusOK, `{"pending_email":"`+changeEmailNew+`"}`)
|
||||
if f.marta().PendingEmail != changeEmailNew {
|
||||
t.Error("the pending change must be stored")
|
||||
}
|
||||
logged := logBuf.String()
|
||||
if !strings.Contains(logged, "smtp: connection refused") || !strings.Contains(logged, "verification") {
|
||||
t.Errorf("expected the log to name the failed send and the error, got %q", logged)
|
||||
}
|
||||
if f.marta().EmailChangeCode != "" && strings.Contains(logged, strings.TrimPrefix(f.marta().EmailChangeCode, "hashed:")) {
|
||||
t.Error("the log must not contain the raw code")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A failed notice send is logged and the response is unchanged", func(t *testing.T) {
|
||||
f := newChangeEmailFixture(t)
|
||||
f.mail.fail.failNextWith("Send", errors.New("smtp: notice refused"))
|
||||
var logBuf bytes.Buffer
|
||||
previous := log.Writer()
|
||||
log.SetOutput(&logBuf)
|
||||
defer log.SetOutput(previous)
|
||||
|
||||
rec := f.requestFor(t, "ines@renew.example")
|
||||
|
||||
assertChangeEmailResponse(t, rec, http.StatusOK, `{"pending_email":"ines@renew.example"}`)
|
||||
logged := logBuf.String()
|
||||
if !strings.Contains(logged, "smtp: notice refused") || !strings.Contains(logged, "notice") {
|
||||
t.Errorf("expected the log to name the failed notice send, got %q", logged)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A successful send logs nothing", func(t *testing.T) {
|
||||
f := newChangeEmailFixture(t)
|
||||
var logBuf bytes.Buffer
|
||||
previous := log.Writer()
|
||||
log.SetOutput(&logBuf)
|
||||
defer log.SetOutput(previous)
|
||||
|
||||
f.requestFor(t, changeEmailNew)
|
||||
|
||||
if logBuf.Len() != 0 {
|
||||
t.Errorf("expected no log output, got %q", logBuf.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The sixth request in 15 minutes is rate limited", func(t *testing.T) {
|
||||
f := newChangeEmailFixture(t)
|
||||
for i := 0; i < 3; i++ {
|
||||
f.requestFor(t, changeEmailNew)
|
||||
}
|
||||
f.resend(t)
|
||||
f.resend(t)
|
||||
sentBefore := len(f.mail.SentEmails)
|
||||
eventsBefore := len(f.recorder.events)
|
||||
codeBefore := f.marta().EmailChangeCode
|
||||
|
||||
rec := f.requestFor(t, "other@renew.example")
|
||||
|
||||
assertChangeEmailResponse(t, rec, http.StatusTooManyRequests, "Too many requests. Please try again later.")
|
||||
if rec.Header().Get("Retry-After") == "" {
|
||||
t.Error("expected a Retry-After header")
|
||||
}
|
||||
if len(f.mail.SentEmails) != sentBefore || len(f.recorder.events) != eventsBefore || f.marta().EmailChangeCode != codeBefore || f.marta().PendingEmail != changeEmailNew {
|
||||
t.Error("nothing may be sent or stored once limited")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The endpoints refuse a missing session", func(t *testing.T) {
|
||||
f := newChangeEmailFixture(t)
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/auth/email/change", strings.NewReader("{}"))
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
f.router.ServeHTTP(rec, req)
|
||||
|
||||
assertChangeEmailResponse(t, rec, http.StatusUnauthorized, "You must be logged in to access this resource.")
|
||||
})
|
||||
}
|
||||
|
||||
func TestAccountChangeEmailResendAndCancelHTTP(t *testing.T) {
|
||||
t.Run("Resend issues a new code and invalidates the old one", func(t *testing.T) {
|
||||
f := newChangeEmailFixture(t)
|
||||
f.requestFor(t, changeEmailNew)
|
||||
first := f.lastVerificationCode(t)
|
||||
f.mail.SentEmails = nil
|
||||
f.recorder.events = nil
|
||||
|
||||
rec := f.resend(t)
|
||||
|
||||
assertChangeEmailResponse(t, rec, http.StatusOK, `{"pending_email":"`+changeEmailNew+`"}`)
|
||||
if len(f.mail.SentEmails) != 1 || f.mail.SentEmails[0].Template != "verify-new-email" || f.mail.SentEmails[0].Address != changeEmailNew {
|
||||
t.Fatalf("expected one verify-new-email mail only, got %+v", f.mail.SentEmails)
|
||||
}
|
||||
second := f.lastVerificationCode(t)
|
||||
if second == first {
|
||||
t.Error("expected a new code")
|
||||
}
|
||||
if delta := time.Until(f.marta().EmailChangeExpiration.Add(-2 * time.Hour)); delta > 5*time.Second || delta < -5*time.Second {
|
||||
t.Errorf("expected the expiration reset to now plus 2 hours, got %v", f.marta().EmailChangeExpiration)
|
||||
}
|
||||
resendEvents := f.recorder.events
|
||||
assertChangeEmailResponse(t, f.confirm(t, 7, first), http.StatusForbidden, "invalid_email_change_code")
|
||||
if len(resendEvents) != 1 || resendEvents[0].Action != "user.resend_email_change" {
|
||||
t.Errorf("expected one user.resend_email_change event, got %+v", resendEvents)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Resend for a taken address sends nothing", func(t *testing.T) {
|
||||
f := newChangeEmailFixture(t)
|
||||
f.users.users[7] = func() user.User {
|
||||
marta := f.users.users[7]
|
||||
marta.PendingEmail = "ines@renew.example"
|
||||
marta.EmailChangeCode = "hashed:old"
|
||||
return marta
|
||||
}()
|
||||
|
||||
rec := f.resend(t)
|
||||
|
||||
assertChangeEmailResponse(t, rec, http.StatusOK, `{"pending_email":"ines@renew.example"}`)
|
||||
if len(f.mail.SentEmails) != 0 {
|
||||
t.Errorf("expected no mail, got %d", len(f.mail.SentEmails))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Resend with nothing pending", func(t *testing.T) {
|
||||
f := newChangeEmailFixture(t)
|
||||
|
||||
assertChangeEmailResponse(t, f.resend(t), http.StatusNotFound, "no_pending_email_change")
|
||||
})
|
||||
|
||||
t.Run("Cancel clears the pending change", func(t *testing.T) {
|
||||
f := newChangeEmailFixture(t)
|
||||
f.requestFor(t, changeEmailNew)
|
||||
code := f.lastVerificationCode(t)
|
||||
f.recorder.events = nil
|
||||
|
||||
rec := f.cancel(t)
|
||||
|
||||
if rec.Code != http.StatusNoContent || rec.Body.Len() != 0 {
|
||||
t.Errorf("expected an empty 204, got %d %q", rec.Code, rec.Body.String())
|
||||
}
|
||||
assertNothingPending(t, f)
|
||||
cancelEvents := f.recorder.events
|
||||
assertChangeEmailResponse(t, f.confirm(t, 7, code), http.StatusForbidden, "invalid_email_change_code")
|
||||
if len(cancelEvents) != 1 || cancelEvents[0].Action != "user.cancel_email_change" {
|
||||
t.Errorf("expected one user.cancel_email_change event, got %+v", cancelEvents)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Cancel keeps the pending change and records nothing when the store fails", func(t *testing.T) {
|
||||
f := newChangeEmailFixture(t)
|
||||
f.requestFor(t, changeEmailNew)
|
||||
f.recorder.events = nil
|
||||
f.users.fail.failNextWith("ClearPendingEmailChange", errors.New("db down"))
|
||||
|
||||
rec := f.cancel(t)
|
||||
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Errorf("expected 500, got %d %q", rec.Code, rec.Body.String())
|
||||
}
|
||||
if f.marta().PendingEmail != changeEmailNew {
|
||||
t.Error("the pending change must remain")
|
||||
}
|
||||
if len(f.recorder.events) != 1 || f.recorder.events[0].Action != "user.cancel_email_change" || f.recorder.events[0].Outcome != audit.OutcomeFailed {
|
||||
t.Errorf("expected one failed user.cancel_email_change event, got %+v", f.recorder.events)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Cancel with nothing pending is harmless", func(t *testing.T) {
|
||||
f := newChangeEmailFixture(t)
|
||||
|
||||
rec := f.cancel(t)
|
||||
|
||||
if rec.Code != http.StatusNoContent || len(f.recorder.events) != 0 {
|
||||
t.Errorf("expected 204 and no event, got %d and %d events", rec.Code, len(f.recorder.events))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestAccountChangeEmailConfirmHTTP(t *testing.T) {
|
||||
t.Run("A valid code changes the email and is single use", func(t *testing.T) {
|
||||
f := newChangeEmailFixture(t)
|
||||
f.requestFor(t, changeEmailNew)
|
||||
code := f.lastVerificationCode(t)
|
||||
f.recorder.events = nil
|
||||
|
||||
rec := f.confirm(t, 7, code)
|
||||
|
||||
assertChangeEmailResponse(t, rec, http.StatusOK, `{"email":"`+changeEmailNew+`"}`)
|
||||
if f.marta().Email != changeEmailNew || f.marta().TokenVersion != 0 {
|
||||
t.Errorf("expected the new email and an unchanged token version, got %+v", f.marta())
|
||||
}
|
||||
assertNothingPending(t, f)
|
||||
if len(f.recorder.events) != 1 || f.recorder.events[0].Action != "user.confirm_email_change" || !f.recorder.events[0].Security {
|
||||
t.Errorf("expected one security user.confirm_email_change event, got %+v", f.recorder.events)
|
||||
}
|
||||
assertChangeEmailResponse(t, f.confirm(t, 7, code), http.StatusForbidden, "invalid_email_change_code")
|
||||
})
|
||||
|
||||
t.Run("A code that does not work answers one 403: nothing is pending", func(t *testing.T) {
|
||||
f := newChangeEmailFixture(t)
|
||||
|
||||
assertChangeEmailResponse(t, f.confirm(t, 7, "any"), http.StatusForbidden, "invalid_email_change_code")
|
||||
if f.marta().Email != changeEmailOld {
|
||||
t.Error("users.email must be unchanged")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A code that does not work answers one 403: the code expired 1 second ago", func(t *testing.T) {
|
||||
f := newChangeEmailFixture(t)
|
||||
f.requestFor(t, changeEmailNew)
|
||||
code := f.lastVerificationCode(t)
|
||||
marta := f.marta()
|
||||
marta.EmailChangeExpiration = time.Now().Add(-time.Second)
|
||||
f.users.users[7] = marta
|
||||
|
||||
assertChangeEmailResponse(t, f.confirm(t, 7, code), http.StatusForbidden, "invalid_email_change_code")
|
||||
if f.marta().Email != changeEmailOld {
|
||||
t.Error("users.email must be unchanged")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A code that does not work answers one 403: a wrong code", func(t *testing.T) {
|
||||
f := newChangeEmailFixture(t)
|
||||
f.requestFor(t, changeEmailNew)
|
||||
|
||||
assertChangeEmailResponse(t, f.confirm(t, 7, "wrong"), http.StatusForbidden, "invalid_email_change_code")
|
||||
if f.marta().Email != changeEmailOld {
|
||||
t.Error("users.email must be unchanged")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A code that does not work answers one 403: the code was issued to another user", func(t *testing.T) {
|
||||
f := newChangeEmailFixture(t)
|
||||
f.requestFor(t, changeEmailNew)
|
||||
code := f.lastVerificationCode(t)
|
||||
|
||||
assertChangeEmailResponse(t, f.confirm(t, 8, code), http.StatusForbidden, "invalid_email_change_code")
|
||||
if f.marta().Email != changeEmailOld || f.users.users[8].Email != "ines@renew.example" {
|
||||
t.Error("no email may change")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A code that does not work answers one 403: the change was cancelled", func(t *testing.T) {
|
||||
f := newChangeEmailFixture(t)
|
||||
f.requestFor(t, changeEmailNew)
|
||||
code := f.lastVerificationCode(t)
|
||||
f.cancel(t)
|
||||
|
||||
assertChangeEmailResponse(t, f.confirm(t, 7, code), http.StatusForbidden, "invalid_email_change_code")
|
||||
})
|
||||
|
||||
t.Run("The address was taken in the meantime", func(t *testing.T) {
|
||||
f := newChangeEmailFixture(t)
|
||||
f.requestFor(t, "later@renew.example")
|
||||
code := f.lastVerificationCode(t)
|
||||
ines := f.users.users[8]
|
||||
ines.Email = "later@renew.example"
|
||||
f.users.users[8] = ines
|
||||
|
||||
rec := f.confirm(t, 7, code)
|
||||
|
||||
assertChangeEmailResponse(t, rec, http.StatusConflict, "email_unavailable")
|
||||
if f.marta().Email != changeEmailOld {
|
||||
t.Error("users.email must be unchanged")
|
||||
}
|
||||
assertNothingPending(t, f)
|
||||
})
|
||||
|
||||
t.Run("An empty code is refused before any lookup", func(t *testing.T) {
|
||||
f := newChangeEmailFixture(t)
|
||||
f.requestFor(t, changeEmailNew)
|
||||
f.users.getCalls = 0
|
||||
f.confirm(t, 7, "")
|
||||
middlewareLookups := f.users.getCalls
|
||||
f.users.getCalls = 0
|
||||
|
||||
rec := f.confirm(t, 7, "")
|
||||
|
||||
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "Code") {
|
||||
t.Errorf("expected a 400 with the validator message, got %d %q", rec.Code, rec.Body.String())
|
||||
}
|
||||
if f.users.getCalls != middlewareLookups {
|
||||
t.Error("the use case must not look the user up")
|
||||
}
|
||||
if f.marta().PendingEmail != changeEmailNew {
|
||||
t.Error("nothing may change")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestAccountChangeEmailTimingAndFailuresHTTP(t *testing.T) {
|
||||
t.Run("No mail is sent on the request path, free or taken address alike", func(t *testing.T) {
|
||||
for _, address := range []string{changeEmailNew, "ines@renew.example"} {
|
||||
f := newChangeEmailFixture(t)
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/auth/email/change", strings.NewReader(`{"new_email": "`+address+`", "current_password": "`+changeEmailPassword+`"}`))
|
||||
req.AddCookie(displayNameCookie(t, f.token, 7, 0))
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
f.router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK || len(f.mail.SentEmails) != 0 || f.dispatcher.pending() != 2 {
|
||||
t.Errorf("%s: status %d, sent %d, queued %d", address, rec.Code, len(f.mail.SentEmails), f.dispatcher.pending())
|
||||
}
|
||||
f.dispatcher.Drain()
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Every email-change refusal records a specific failure reason", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
run func(f *changeEmailFixture)
|
||||
}{
|
||||
{"same address", func(f *changeEmailFixture) { f.requestFor(t, changeEmailOld) }},
|
||||
{"nothing pending on resend", func(f *changeEmailFixture) { f.resend(t) }},
|
||||
{"invalid code", func(f *changeEmailFixture) { f.confirm(t, 7, "nope") }},
|
||||
{"address unavailable", func(f *changeEmailFixture) {
|
||||
f.requestFor(t, "later@renew.example")
|
||||
code := f.lastVerificationCode(t)
|
||||
ines := f.users.users[8]
|
||||
ines.Email = "later@renew.example"
|
||||
f.users.users[8] = ines
|
||||
f.recorder.events = nil
|
||||
f.confirm(t, 7, code)
|
||||
}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
f := newChangeEmailFixture(t)
|
||||
|
||||
tc.run(f)
|
||||
|
||||
last := f.recorder.events[len(f.recorder.events)-1]
|
||||
if last.Outcome != audit.OutcomeFailed || !last.Security || last.FailureReason == nil || *last.FailureReason == useCases.GenericFailureReason {
|
||||
t.Errorf("expected a specific failed security event, got %+v", last)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Concurrent confirms of one address let exactly one user win", func(t *testing.T) {
|
||||
f := newChangeEmailFixture(t)
|
||||
const contenders = 8
|
||||
codes := map[uint32]string{}
|
||||
for i := uint32(0); i < contenders; i++ {
|
||||
id := 20 + i
|
||||
f.users.users[id] = user.User{ID: id, Username: "u", Email: "u@renew.example", IsVerified: true, PendingEmail: "shared@renew.example", EmailChangeCode: "hashed:code", EmailChangeExpiration: time.Now().Add(time.Hour)}
|
||||
codes[id] = "code"
|
||||
}
|
||||
confirm := useCases.NewConfirmEmailChangeUseCase(f.users, atomicPlainHasher{}, time.Now, discardRecorder)
|
||||
var mu sync.Mutex
|
||||
var group sync.WaitGroup
|
||||
wins, unavailable := 0, 0
|
||||
for id, code := range codes {
|
||||
group.Add(1)
|
||||
go func() {
|
||||
defer group.Done()
|
||||
_, err := confirm.ConfirmEmailChange(context.Background(), id, code)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if err == nil {
|
||||
wins++
|
||||
} else if errors.Is(err, user.ErrEmailUnavailable) {
|
||||
unavailable++
|
||||
}
|
||||
}()
|
||||
}
|
||||
group.Wait()
|
||||
|
||||
if wins != 1 || unavailable != contenders-1 {
|
||||
t.Errorf("expected 1 win and %d unavailable, got %d and %d", contenders-1, wins, unavailable)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
domainUser "RenewCMS/internal/domain/user"
|
||||
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestUserRepositoryEmailChangeContract(t *testing.T) {
|
||||
backends := []contractBackend{
|
||||
{name: "sqlite", open: openContractSQLite},
|
||||
{name: "mysql", open: openContractMySQL},
|
||||
{name: "postgres", open: openContractPostgres},
|
||||
}
|
||||
expiration := time.Now().Add(2 * time.Hour).UTC().Truncate(time.Second)
|
||||
cases := []struct {
|
||||
name string
|
||||
run func(t *testing.T, users *persistence.UserRepository, marta domainUser.User, ines domainUser.User)
|
||||
}{
|
||||
{"setting a pending change keeps the email and clearing empties the three fields", func(t *testing.T, users *persistence.UserRepository, marta domainUser.User, ines domainUser.User) {
|
||||
set, err := users.SetPendingEmailChange(marta.ID, "new@renew.example", "hash", expiration)
|
||||
if err != nil || set.PendingEmail != "new@renew.example" || set.EmailChangeCode != "hash" || !set.EmailChangeExpiration.Equal(expiration) || set.Email != marta.Email {
|
||||
t.Fatalf("set %+v %v", set, err)
|
||||
}
|
||||
|
||||
cleared, err := users.ClearPendingEmailChange(marta.ID)
|
||||
|
||||
if err != nil || cleared.PendingEmail != "" || cleared.EmailChangeCode != "" || !cleared.EmailChangeExpiration.IsZero() {
|
||||
t.Errorf("cleared %+v %v", cleared, err)
|
||||
}
|
||||
}},
|
||||
{"an address is in use whatever its case, except for the asking user", func(t *testing.T, users *persistence.UserRepository, marta domainUser.User, ines domainUser.User) {
|
||||
taken, err := users.IsEmailInUse("INES@renew.example", marta.ID)
|
||||
own, ownErr := users.IsEmailInUse(marta.Email, marta.ID)
|
||||
free, freeErr := users.IsEmailInUse("free@renew.example", marta.ID)
|
||||
|
||||
if err != nil || !taken || ownErr != nil || own || freeErr != nil || free {
|
||||
t.Errorf("taken %v %v own %v %v free %v %v", taken, err, own, ownErr, free, freeErr)
|
||||
}
|
||||
}},
|
||||
{"confirming moves the pending address to the email once", func(t *testing.T, users *persistence.UserRepository, marta domainUser.User, ines domainUser.User) {
|
||||
_, _ = users.SetPendingEmailChange(marta.ID, "new@renew.example", "hash", expiration)
|
||||
|
||||
confirmed, err := users.ConfirmEmailChange(marta.ID, "new@renew.example", "hash", time.Now())
|
||||
_, again := users.ConfirmEmailChange(marta.ID, "new@renew.example", "hash", time.Now())
|
||||
|
||||
if err != nil || confirmed.Email != "new@renew.example" || confirmed.PendingEmail != "" || confirmed.EmailChangeCode != "" {
|
||||
t.Errorf("confirmed %+v %v", confirmed, err)
|
||||
}
|
||||
if !errors.Is(again, domainUser.ErrInvalidEmailChangeCode) {
|
||||
t.Errorf("second confirm %v", again)
|
||||
}
|
||||
}},
|
||||
{"confirming after the expiry changes nothing", func(t *testing.T, users *persistence.UserRepository, marta domainUser.User, ines domainUser.User) {
|
||||
_, _ = users.SetPendingEmailChange(marta.ID, "new@renew.example", "hash", expiration)
|
||||
|
||||
_, err := users.ConfirmEmailChange(marta.ID, "new@renew.example", "hash", expiration.Add(time.Second))
|
||||
stored, _ := users.Get(marta.ID)
|
||||
|
||||
if !errors.Is(err, domainUser.ErrInvalidEmailChangeCode) || stored.Email != marta.Email {
|
||||
t.Errorf("err %v stored %+v", err, stored)
|
||||
}
|
||||
}},
|
||||
{"confirming with another code changes nothing", func(t *testing.T, users *persistence.UserRepository, marta domainUser.User, ines domainUser.User) {
|
||||
_, _ = users.SetPendingEmailChange(marta.ID, "new@renew.example", "hash", expiration)
|
||||
|
||||
_, err := users.ConfirmEmailChange(marta.ID, "new@renew.example", "other", time.Now())
|
||||
stored, _ := users.Get(marta.ID)
|
||||
|
||||
if !errors.Is(err, domainUser.ErrInvalidEmailChangeCode) || stored.Email != marta.Email || stored.PendingEmail != "new@renew.example" {
|
||||
t.Errorf("err %v stored %+v", err, stored)
|
||||
}
|
||||
}},
|
||||
{"confirming a taken address clears the pending change and keeps the email", func(t *testing.T, users *persistence.UserRepository, marta domainUser.User, ines domainUser.User) {
|
||||
_, _ = users.SetPendingEmailChange(marta.ID, "Ines@Renew.example", "hash", expiration)
|
||||
|
||||
_, err := users.ConfirmEmailChange(marta.ID, "Ines@Renew.example", "hash", time.Now())
|
||||
stored, _ := users.Get(marta.ID)
|
||||
|
||||
if !errors.Is(err, domainUser.ErrEmailUnavailable) || stored.Email != marta.Email || stored.PendingEmail != "" || stored.EmailChangeCode != "" {
|
||||
t.Errorf("err %v stored %+v", err, stored)
|
||||
}
|
||||
}},
|
||||
}
|
||||
for _, backend := range backends {
|
||||
t.Run(backend.name, func(t *testing.T) {
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
db := backend.open(t)
|
||||
users := persistence.NewUserRepository(db)
|
||||
marta := mustCreateEmailChangeUser(t, users, "marta", "marta@renew.example")
|
||||
ines := mustCreateEmailChangeUser(t, users, "ines", "ines@renew.example")
|
||||
c.run(t, users, marta, ines)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func mustCreateEmailChangeUser(t *testing.T, users *persistence.UserRepository, username string, email string) domainUser.User {
|
||||
t.Helper()
|
||||
created, err := users.Create(domainUser.User{Username: username, Email: email, Password: "hash", IsVerified: true})
|
||||
if err != nil {
|
||||
t.Fatalf("create %s: %v", username, err)
|
||||
}
|
||||
return created
|
||||
}
|
||||
|
||||
func TestUserRepositoryConcurrentEmailChangeOnSQLite(t *testing.T) {
|
||||
const contenders = 8
|
||||
expiration := time.Now().Add(2 * time.Hour)
|
||||
users := persistence.NewUserRepository(openContractSQLite(t))
|
||||
ids := make([]uint32, 0, contenders)
|
||||
for i := 0; i < contenders; i++ {
|
||||
created := mustCreateEmailChangeUser(t, users, fmt.Sprintf("user%d", i), fmt.Sprintf("user%d@renew.example", i))
|
||||
if _, err := users.SetPendingEmailChange(created.ID, "shared@renew.example", "hash", expiration); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ids = append(ids, created.ID)
|
||||
}
|
||||
|
||||
results := make([]error, contenders)
|
||||
var group sync.WaitGroup
|
||||
for i, id := range ids {
|
||||
group.Add(1)
|
||||
go func() {
|
||||
defer group.Done()
|
||||
_, results[i] = users.ConfirmEmailChange(id, "shared@renew.example", "hash", time.Now())
|
||||
}()
|
||||
}
|
||||
group.Wait()
|
||||
|
||||
succeeded := 0
|
||||
for _, err := range results {
|
||||
switch {
|
||||
case err == nil:
|
||||
succeeded++
|
||||
case !errors.Is(err, domainUser.ErrEmailUnavailable):
|
||||
t.Errorf("a losing confirm must answer ErrEmailUnavailable, got %v", err)
|
||||
}
|
||||
}
|
||||
if succeeded != 1 {
|
||||
t.Errorf("expected exactly one success, got %d", succeeded)
|
||||
}
|
||||
for _, id := range ids {
|
||||
stored, _ := users.Get(id)
|
||||
if stored.PendingEmail != "" {
|
||||
t.Errorf("user %d still has a pending change", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func failUserUpdates(t *testing.T, db *gorm.DB, failures ...error) {
|
||||
t.Helper()
|
||||
var mu sync.Mutex
|
||||
remaining := failures
|
||||
err := db.Callback().Update().Before("gorm:update").Register("test:fail_user_updates", func(tx *gorm.DB) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(remaining) == 0 || tx.Statement.Table != "users" {
|
||||
return
|
||||
}
|
||||
failure := remaining[0]
|
||||
remaining = remaining[1:]
|
||||
if failure != nil {
|
||||
_ = tx.AddError(failure)
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserRepositoryConfirmEmailChangeUniqueViolationRace(t *testing.T) {
|
||||
expiration := time.Now().Add(2 * time.Hour)
|
||||
setup := func(t *testing.T, failures ...error) (*persistence.UserRepository, domainUser.User) {
|
||||
db := openContractSQLite(t)
|
||||
users := persistence.NewUserRepository(db)
|
||||
account := mustCreateEmailChangeUser(t, users, "racer", "racer@renew.example")
|
||||
if _, err := users.SetPendingEmailChange(account.ID, "raced@renew.example", "hash", expiration); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
failUserUpdates(t, db, failures...)
|
||||
return users, account
|
||||
}
|
||||
|
||||
t.Run("a unique violation clears the pending change and answers ErrEmailUnavailable", func(t *testing.T) {
|
||||
users, account := setup(t, gorm.ErrDuplicatedKey)
|
||||
updated, err := users.ConfirmEmailChange(account.ID, "raced@renew.example", "hash", time.Now())
|
||||
if !errors.Is(err, domainUser.ErrEmailUnavailable) {
|
||||
t.Fatalf("expected ErrEmailUnavailable, got %v", err)
|
||||
}
|
||||
if updated.ID != 0 {
|
||||
t.Errorf("no user must be returned, got %+v", updated)
|
||||
}
|
||||
stored, _ := users.Get(account.ID)
|
||||
if stored.PendingEmail != "" || stored.Email != "racer@renew.example" {
|
||||
t.Errorf("pending change must be cleared and email kept, got %+v", stored)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a failing cleanup after a unique violation surfaces its own error", func(t *testing.T) {
|
||||
cleanupFailure := errors.New("cleanup exploded")
|
||||
users, account := setup(t, gorm.ErrDuplicatedKey, cleanupFailure)
|
||||
_, err := users.ConfirmEmailChange(account.ID, "raced@renew.example", "hash", time.Now())
|
||||
if !errors.Is(err, cleanupFailure) {
|
||||
t.Fatalf("expected the cleanup error, got %v", err)
|
||||
}
|
||||
if errors.Is(err, domainUser.ErrEmailUnavailable) {
|
||||
t.Error("a failed cleanup must not be reported as ErrEmailUnavailable")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a non-unique database error is returned untouched", func(t *testing.T) {
|
||||
failure := errors.New("disk on fire")
|
||||
users, account := setup(t, failure)
|
||||
_, err := users.ConfirmEmailChange(account.ID, "raced@renew.example", "hash", time.Now())
|
||||
if !errors.Is(err, failure) {
|
||||
t.Fatalf("expected the database error, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/user"
|
||||
entity "RenewCMS/internal/infrastructure/persistence/models"
|
||||
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func failAccountDeleteKeyRemoval(t *testing.T, db *gorm.DB, name string, failure error) {
|
||||
t.Helper()
|
||||
err := db.Callback().Delete().Before("gorm:delete").Register(name, func(tx *gorm.DB) {
|
||||
if tx.Statement.Table == "api_keys" {
|
||||
_ = tx.AddError(failure)
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Callback().Delete().Remove(name) })
|
||||
}
|
||||
|
||||
func TestAccountDeleteContentionMapping(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
failure string
|
||||
guards []uint32
|
||||
notDeletable bool
|
||||
}{
|
||||
{"locked database with a guard role", "database is locked", []uint32{8}, true},
|
||||
{"sqlite busy with a guard role", "SQLITE_BUSY: x", []uint32{8}, true},
|
||||
{"deadlock with a guard role", "Deadlock found when trying to get lock", []uint32{8}, true},
|
||||
{"serialization failure with a guard role", "could not serialize access", []uint32{8}, true},
|
||||
{"locked database without a guard role", "database is locked", nil, false},
|
||||
{"unrelated failure with a guard role", "disk exploded", []uint32{8}, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
db := openContractSQLite(t)
|
||||
seedDeleteAccountRows(t, db, true)
|
||||
failure := errors.New(tc.failure)
|
||||
failAccountDeleteKeyRemoval(t, db, "test:account_delete_contention", failure)
|
||||
|
||||
err := persistence.NewAccountEraser(db).Erase(user.Erasure{UserID: 7, GuardRoleIDs: tc.guards})
|
||||
|
||||
if errors.Is(err, user.ErrAccountNotDeletable) != tc.notDeletable {
|
||||
t.Errorf("ErrAccountNotDeletable = %v, want %v (err %v)", !tc.notDeletable, tc.notDeletable, err)
|
||||
}
|
||||
if !tc.notDeletable && !errors.Is(err, failure) {
|
||||
t.Errorf("expected the original failure, got %v", err)
|
||||
}
|
||||
if deleteAccountRowCount(t, db, &entity.User{}, "id = ?", 7) != 1 {
|
||||
t.Error("the user must still exist after a failed erasure")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountDeleteSQLiteTakesTheWriteLockBeforeCounting(t *testing.T) {
|
||||
db := openContractSQLite(t)
|
||||
seedDeleteAccountRows(t, db, true)
|
||||
roleWrites := 0
|
||||
err := db.Callback().Update().Before("gorm:update").Register("test:account_delete_role_writes", func(tx *gorm.DB) {
|
||||
if tx.Statement.Table == "roles" {
|
||||
roleWrites++
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Callback().Update().Remove("test:account_delete_role_writes") })
|
||||
|
||||
err = persistence.NewAccountEraser(db).Erase(user.Erasure{UserID: 7, GuardRoleIDs: []uint32{8}})
|
||||
|
||||
if err != nil || roleWrites != 1 {
|
||||
t.Errorf("expected one lock write on the guard roles and no error, got %d writes and %v", roleWrites, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,775 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/api/handlers/user"
|
||||
"RenewCMS/api/middleware"
|
||||
"RenewCMS/api/pkg/authtoken"
|
||||
validator "RenewCMS/api/pkg/validator"
|
||||
"RenewCMS/internal/domain/apikey"
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/domain/author"
|
||||
"RenewCMS/internal/domain/entry"
|
||||
"RenewCMS/internal/domain/image"
|
||||
"RenewCMS/internal/domain/role"
|
||||
domain "RenewCMS/internal/domain/user"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"bytes"
|
||||
"errors"
|
||||
"log"
|
||||
"maps"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
const (
|
||||
deleteAccountHost = "https://cms.example.com"
|
||||
deleteAccountEmail = "marta@renew.example"
|
||||
deleteAccountPassword = "limewash2026"
|
||||
deleteAccountBody = `{"current_password": "limewash2026", "confirmation": "DELETE"}`
|
||||
deleteAccountAuthorID = uint32(10)
|
||||
deleteAccountAvatarID = uint32(5)
|
||||
deleteAccountAvatar = "avatar.png"
|
||||
)
|
||||
|
||||
type deleteAccountRevoker struct {
|
||||
revoked []string
|
||||
failure error
|
||||
}
|
||||
|
||||
func (r *deleteAccountRevoker) Revoke(id string, _ time.Time) error {
|
||||
if r.failure != nil {
|
||||
return r.failure
|
||||
}
|
||||
r.revoked = append(r.revoked, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
type deleteAccountEraserFake struct {
|
||||
users *mockUserRepo
|
||||
keys *mockAPIKeyRepo
|
||||
authors *mockAuthorRepo
|
||||
images *mockImageRepo
|
||||
events *exportAuditFake
|
||||
fail failSwitch
|
||||
beforeErase func()
|
||||
erasures []domain.Erasure
|
||||
}
|
||||
|
||||
var _ domain.Eraser = &deleteAccountEraserFake{}
|
||||
|
||||
func (f *deleteAccountEraserFake) Erase(erasure domain.Erasure) error {
|
||||
f.erasures = append(f.erasures, erasure)
|
||||
if f.beforeErase != nil {
|
||||
hook := f.beforeErase
|
||||
f.beforeErase = nil
|
||||
hook()
|
||||
}
|
||||
users, keys, authors, events, images := maps.Clone(f.users.users), maps.Clone(f.keys.keys), maps.Clone(f.authors.authors), slices.Clone(f.events.events), maps.Clone(f.images.images)
|
||||
err := f.run(erasure)
|
||||
if err != nil {
|
||||
f.users.users, f.keys.keys, f.authors.authors, f.events.events, f.images.images = users, keys, authors, events, images
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (f *deleteAccountEraserFake) run(erasure domain.Erasure) error {
|
||||
if len(erasure.GuardRoleIDs) > 0 && f.verifiedHolders(erasure) == 0 {
|
||||
return domain.ErrAccountNotDeletable
|
||||
}
|
||||
if err := f.fail.trigger("DeleteAPIKeys"); err != nil {
|
||||
return err
|
||||
}
|
||||
for id, key := range f.keys.keys {
|
||||
if key.UserID == erasure.UserID {
|
||||
delete(f.keys.keys, id)
|
||||
}
|
||||
}
|
||||
if err := f.fail.trigger("ScrubAuthor"); err != nil {
|
||||
return err
|
||||
}
|
||||
f.releaseAuthor(erasure.UserID)
|
||||
if erasure.AvatarImageID != nil {
|
||||
delete(f.images.images, *erasure.AvatarImageID)
|
||||
}
|
||||
if err := f.fail.trigger("AnonymiseEvents"); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = f.events.AnonymiseUser(erasure.UserID, nil, domain.DeletedUserName)
|
||||
if err := f.fail.trigger("DeleteUser"); err != nil {
|
||||
return err
|
||||
}
|
||||
return f.users.Delete(erasure.UserID)
|
||||
}
|
||||
|
||||
func (f *deleteAccountEraserFake) verifiedHolders(erasure domain.Erasure) int {
|
||||
holders := 0
|
||||
for id, candidate := range f.users.users {
|
||||
if id != erasure.UserID && candidate.IsVerified && slices.Contains(erasure.GuardRoleIDs, candidate.RoleID) {
|
||||
holders++
|
||||
}
|
||||
}
|
||||
return holders
|
||||
}
|
||||
|
||||
func (f *deleteAccountEraserFake) releaseAuthor(userID uint32) {
|
||||
for id, found := range f.authors.authors {
|
||||
if found.UserID == nil || *found.UserID != userID {
|
||||
continue
|
||||
}
|
||||
if f.authors.withCount(found).ArticleCount == 0 {
|
||||
delete(f.authors.authors, id)
|
||||
continue
|
||||
}
|
||||
f.authors.authors[id] = author.Author{ID: id, Name: author.DeletedName, Slug: author.DeletedSlug(id), SocialLinks: []author.SocialLink{}}
|
||||
}
|
||||
}
|
||||
|
||||
type deleteAccountFixture struct {
|
||||
dispatcher *changeEmailDispatcher
|
||||
users *mockUserRepo
|
||||
roles *mockRoleRepo
|
||||
keys *mockAPIKeyRepo
|
||||
authors *mockAuthorRepo
|
||||
articles *mockArticleRepo
|
||||
entries *mockEntryRepo
|
||||
images *mockImageRepo
|
||||
events *exportAuditFake
|
||||
recorder *recordingRecorder
|
||||
mail *mockMailRepo
|
||||
revoker *deleteAccountRevoker
|
||||
eraser *deleteAccountEraserFake
|
||||
storage *mockImageStorage
|
||||
variants *mockVariantFileRepo
|
||||
sites *mockSiteRepo
|
||||
token *authtoken.Auth
|
||||
router *chi.Mux
|
||||
removeHits int
|
||||
}
|
||||
|
||||
func newDeleteAccountFixture(t *testing.T) *deleteAccountFixture {
|
||||
t.Helper()
|
||||
validator.InitValidator()
|
||||
f := &deleteAccountFixture{
|
||||
dispatcher: &changeEmailDispatcher{},
|
||||
users: newMockUserRepo(),
|
||||
roles: newSeededMockRoleRepo(),
|
||||
keys: newMockAPIKeyRepo(),
|
||||
articles: newMockArticleRepo(),
|
||||
entries: &mockEntryRepo{entries: map[uint32]entry.Entry{}},
|
||||
images: &mockImageRepo{images: map[uint32]image.Image{}},
|
||||
events: &exportAuditFake{},
|
||||
recorder: &recordingRecorder{},
|
||||
mail: newMockMailRepo(),
|
||||
revoker: &deleteAccountRevoker{},
|
||||
storage: newMockImageStorage(),
|
||||
variants: newMockVariantFileRepo(),
|
||||
sites: plainSites(),
|
||||
token: authtoken.New([]byte("test-secret")),
|
||||
}
|
||||
f.authors = newMockAuthorRepo(f.articles)
|
||||
f.seed()
|
||||
f.eraser = &deleteAccountEraserFake{users: f.users, keys: f.keys, authors: f.authors, images: f.images, events: f.events}
|
||||
f.mountRouter()
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *deleteAccountFixture) seed() {
|
||||
password := "hashed:" + deleteAccountPassword
|
||||
f.users.users[7] = domain.User{ID: 7, Username: "marta", Email: deleteAccountEmail, Password: password, IsVerified: true, RoleID: writerRoleID}
|
||||
f.users.users[2] = domain.User{ID: 2, Username: "jonas", Email: "jonas@renew.example", Password: password, IsVerified: true, RoleID: adminRoleID}
|
||||
f.users.users[3] = domain.User{ID: 3, Username: "ines", Email: "ines@renew.example", Password: password, IsVerified: true, RoleID: ownerRoleID}
|
||||
f.users.users[9] = domain.User{ID: 9, Username: "paul", Email: "paul@renew.example", Password: password, IsVerified: false, RoleID: writerRoleID}
|
||||
f.users.nextID = 9
|
||||
f.keys.keys[1] = apikey.APIKey{ID: 1, UserID: 7, Name: "ci"}
|
||||
f.keys.keys[2] = apikey.APIKey{ID: 2, UserID: 7, Name: "deploy"}
|
||||
f.keys.keys[3] = apikey.APIKey{ID: 3, UserID: 2, Name: "jonas key"}
|
||||
f.authors.seedLinked(deleteAccountAuthorID, "Marta Alves", "marta-alves", 7)
|
||||
f.authors.update(deleteAccountAuthorID, func(a *author.Author) {
|
||||
a.Bio = "Writes about lime."
|
||||
website := "https://marta.example"
|
||||
a.Website = &website
|
||||
a.AvatarID = idPointer(deleteAccountAvatarID)
|
||||
a.SocialLinks = []author.SocialLink{{Type: author.SocialGitHub, URL: "https://github.com/marta"}}
|
||||
})
|
||||
for id := uint32(1); id <= 3; id++ {
|
||||
f.articles.articles[id] = article.Article{ID: id, Title: "Article", CreatedBy: 7}
|
||||
f.articles.credits[id] = []uint32{deleteAccountAuthorID}
|
||||
}
|
||||
for id := uint32(1); id <= 2; id++ {
|
||||
f.entries.entries[id] = entry.Entry{ID: id, CreatedBy: 7, UpdatedBy: 7}
|
||||
}
|
||||
for id := uint32(1); id <= 4; id++ {
|
||||
f.images.images[id] = image.Image{ID: id, Filename: "a.png", UploadedByID: 7}
|
||||
}
|
||||
f.images.images[deleteAccountAvatarID] = image.Image{ID: deleteAccountAvatarID, Path: image.PublicPath(deleteAccountAvatar), Filename: deleteAccountAvatar, UploadedByID: 2}
|
||||
f.storage.blobs[deleteAccountAvatar] = []byte("avatar")
|
||||
for i := 0; i < 12; i++ {
|
||||
ip, agent, martaID := "203.0.113.7", "Firefox", uint32(7)
|
||||
f.events.events = append(f.events.events, audit.Event{
|
||||
ID: uint64(i + 1),
|
||||
Action: "article.edit",
|
||||
Actor: audit.Actor{Type: audit.ActorUser, ID: &martaID, Name: "marta"},
|
||||
Target: audit.Target{Type: audit.TargetArticle, ID: "1", Name: "Article"},
|
||||
IP: &ip,
|
||||
UserAgent: &agent,
|
||||
})
|
||||
}
|
||||
jonasID := uint32(2)
|
||||
f.events.events = append(f.events.events, audit.Event{
|
||||
ID: 13,
|
||||
Action: "user.change_role",
|
||||
Actor: audit.Actor{Type: audit.ActorUser, ID: &jonasID, Name: "jonas"},
|
||||
Target: audit.Target{Type: audit.TargetUser, ID: "7", Name: "marta"},
|
||||
})
|
||||
}
|
||||
|
||||
func (f *deleteAccountFixture) mountRouter() {
|
||||
getUser := useCases.NewGetUserUseCase(f.users)
|
||||
usage := useCases.NewGetMediaUsageUseCase(f.images, f.articles, f.sites, f.authors, newMockContentTypeRepo(), f.entries)
|
||||
deleteUseCase := useCases.NewDeleteOwnAccountUseCase(f.users, f.roles, &stubHasher{}, f.eraser, f.revoker, f.mail, f.dispatcher, deleteAccountHost, f.recorder, f.authors, f.images, usage, f.storage, f.variants)
|
||||
handler := user.NewDeleteAccountHandler(deleteUseCase, false)
|
||||
f.router = chi.NewRouter()
|
||||
f.router.Route("/v1/user", func(r chi.Router) {
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.RequireSession)
|
||||
r.Use(authtoken.Verifier(f.token))
|
||||
r.Use(middleware.RequireSignedIn)
|
||||
r.Use(authtoken.Authenticator)
|
||||
r.Use(middleware.RequireCurrentToken(getUser))
|
||||
r.Use(middleware.RequireVerifiedUser(getUser))
|
||||
r.Use(middleware.NewUserRateLimiter(5, 15*time.Minute))
|
||||
r.Delete("/me", handler.Delete)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Delete("/{id}", func(w http.ResponseWriter, _ *http.Request) {
|
||||
f.removeHits++
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
})
|
||||
})
|
||||
f.router.Route("/v1/auth", func(r chi.Router) {
|
||||
r.Use(authtoken.Verifier(f.token))
|
||||
r.Use(authtoken.Authenticator)
|
||||
r.Use(middleware.RequireCurrentToken(getUser))
|
||||
r.Get("/me", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) })
|
||||
})
|
||||
}
|
||||
|
||||
func (f *deleteAccountFixture) request(t *testing.T, userID uint32, body string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodDelete, "/v1/user/me", bytes.NewBufferString(body))
|
||||
req.AddCookie(displayNameCookie(t, f.token, userID, 0))
|
||||
return f.serve(req)
|
||||
}
|
||||
|
||||
func (f *deleteAccountFixture) serve(req *http.Request) *httptest.ResponseRecorder {
|
||||
rec := httptest.NewRecorder()
|
||||
f.router.ServeHTTP(rec, req)
|
||||
f.dispatcher.Drain()
|
||||
return rec
|
||||
}
|
||||
|
||||
type deleteAccountState struct {
|
||||
users map[uint32]domain.User
|
||||
keys map[uint32]apikey.APIKey
|
||||
authors map[uint32]author.Author
|
||||
events []audit.Event
|
||||
articles map[uint32]article.Article
|
||||
entries map[uint32]entry.Entry
|
||||
images map[uint32]image.Image
|
||||
}
|
||||
|
||||
func (f *deleteAccountFixture) state() deleteAccountState {
|
||||
return deleteAccountState{
|
||||
users: maps.Clone(f.users.users),
|
||||
keys: maps.Clone(f.keys.keys),
|
||||
authors: f.authors.snapshot(),
|
||||
events: slices.Clone(f.events.events),
|
||||
articles: maps.Clone(f.articles.articles),
|
||||
entries: maps.Clone(f.entries.entries),
|
||||
images: maps.Clone(f.images.images),
|
||||
}
|
||||
}
|
||||
|
||||
func (f *deleteAccountFixture) assertUntouchedSince(t *testing.T, before deleteAccountState) {
|
||||
t.Helper()
|
||||
if !reflect.DeepEqual(before, f.state()) {
|
||||
t.Error("expected the users, keys, authors, content and events to be unchanged")
|
||||
}
|
||||
if len(f.mail.SentEmails) != 0 {
|
||||
t.Errorf("expected no mail, got %d", len(f.mail.SentEmails))
|
||||
}
|
||||
if len(f.revoker.revoked) != 0 {
|
||||
t.Errorf("expected no revoked session, got %v", f.revoker.revoked)
|
||||
}
|
||||
if len(f.storage.deleted) != 0 {
|
||||
t.Errorf("expected no deleted file, got %v", f.storage.deleted)
|
||||
}
|
||||
}
|
||||
|
||||
func (f *deleteAccountFixture) lastEvent(t *testing.T) audit.Event {
|
||||
t.Helper()
|
||||
if len(f.recorder.events) != 1 {
|
||||
t.Fatalf("expected one audit event, got %d", len(f.recorder.events))
|
||||
}
|
||||
return f.recorder.events[0]
|
||||
}
|
||||
|
||||
func assertDeleteAccountResponse(t *testing.T, rec *httptest.ResponseRecorder, status int, body string) {
|
||||
t.Helper()
|
||||
if rec.Code != status || strings.TrimSpace(rec.Body.String()) != body {
|
||||
t.Fatalf("expected %d %q, got %d %q", status, body, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountDeleteEndpointHTTP(t *testing.T) {
|
||||
t.Run("A Writer deletes their account", func(t *testing.T) {
|
||||
f := newDeleteAccountFixture(t)
|
||||
articles, entries, images := maps.Clone(f.articles.articles), maps.Clone(f.entries.entries), maps.Clone(f.images.images)
|
||||
delete(images, deleteAccountAvatarID)
|
||||
|
||||
rec := f.request(t, 7, deleteAccountBody)
|
||||
|
||||
assertDeleteAccountResponse(t, rec, http.StatusNoContent, "")
|
||||
expired := false
|
||||
for _, cookie := range rec.Result().Cookies() {
|
||||
expired = expired || cookie.Name == "jwt" && cookie.MaxAge < 0 && cookie.Value == "" && cookie.HttpOnly
|
||||
}
|
||||
if !expired {
|
||||
t.Errorf("expected an expired jwt cookie, got %v", rec.Header().Values("Set-Cookie"))
|
||||
}
|
||||
if _, exists := f.users.users[7]; exists {
|
||||
t.Error("user 7 must be gone")
|
||||
}
|
||||
if _, one := f.keys.keys[1]; one || len(f.keys.keys) != 1 {
|
||||
t.Errorf("keys 1 and 2 must be gone, got %v", f.keys.keys)
|
||||
}
|
||||
scrubbed := f.authors.authors[deleteAccountAuthorID]
|
||||
if scrubbed.Name != "Deleted user" || scrubbed.Slug != "deleted-user-10" || scrubbed.UserID != nil || scrubbed.Bio != "" || scrubbed.AvatarID != nil || scrubbed.Website != nil || len(scrubbed.SocialLinks) != 0 {
|
||||
t.Errorf("unexpected scrubbed author %+v", scrubbed)
|
||||
}
|
||||
if !reflect.DeepEqual(articles, f.articles.articles) || !reflect.DeepEqual(entries, f.entries.entries) || !reflect.DeepEqual(images, f.images.images) {
|
||||
t.Error("articles, entries and images must be unchanged")
|
||||
}
|
||||
for _, event := range f.events.events[:12] {
|
||||
if event.Actor.Name != "Deleted user" || event.IP != nil || event.UserAgent != nil {
|
||||
t.Errorf("event %d not anonymised: %+v", event.ID, event)
|
||||
}
|
||||
}
|
||||
if f.events.events[12].Actor.Name != "jonas" || f.events.events[12].Target.Name != "Deleted user" {
|
||||
t.Errorf("another user's event keeps their name but loses the target name: %+v", f.events.events[12])
|
||||
}
|
||||
old := httptest.NewRequest(http.MethodGet, "/v1/auth/me", nil)
|
||||
old.AddCookie(displayNameCookie(t, f.token, 7, 0))
|
||||
if got := f.serve(old); got.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected 401 for the old jwt, got %d", got.Code)
|
||||
}
|
||||
if len(f.revoker.revoked) != 1 || f.revoker.revoked[0] == "" {
|
||||
t.Errorf("expected the session id to be revoked, got %v", f.revoker.revoked)
|
||||
}
|
||||
if len(f.mail.SentEmails) != 1 {
|
||||
t.Fatalf("expected one mail, got %d", len(f.mail.SentEmails))
|
||||
}
|
||||
sent := f.mail.SentEmails[0]
|
||||
if sent.Address != deleteAccountEmail || sent.Template != "account-deleted" || sent.Data != (useCases.MailAccountDeletedData{Host: deleteAccountHost}) {
|
||||
t.Errorf("unexpected mail %+v", sent)
|
||||
}
|
||||
event := f.lastEvent(t)
|
||||
martaID := uint32(7)
|
||||
if event.Action != "user.delete_account" || event.Actor.Name != "Deleted user" || *event.Actor.ID != martaID || event.Target.Name != "Deleted user" || event.Target.ID != "7" || !event.Security || event.Outcome != audit.OutcomeSuccess || event.Category != audit.CategoryUsers {
|
||||
t.Errorf("unexpected audit event %+v", event)
|
||||
}
|
||||
for key, value := range event.Params {
|
||||
if text, isText := value.(string); isText && strings.Contains(text, "@") {
|
||||
t.Errorf("params must hold no email, got %s=%v", key, value)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("An author profile that credits no article is deleted", func(t *testing.T) {
|
||||
f := newDeleteAccountFixture(t)
|
||||
f.articles.credits = map[uint32][]uint32{}
|
||||
|
||||
rec := f.request(t, 7, deleteAccountBody)
|
||||
|
||||
assertDeleteAccountResponse(t, rec, http.StatusNoContent, "")
|
||||
if _, exists := f.authors.authors[deleteAccountAuthorID]; exists {
|
||||
t.Error("the author row must be gone")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The avatar of the author profile is deleted with its stored file", func(t *testing.T) {
|
||||
f := newDeleteAccountFixture(t)
|
||||
|
||||
rec := f.request(t, 7, deleteAccountBody)
|
||||
|
||||
assertDeleteAccountResponse(t, rec, http.StatusNoContent, "")
|
||||
if _, exists := f.images.images[deleteAccountAvatarID]; exists {
|
||||
t.Error("the avatar image row must be gone")
|
||||
}
|
||||
if !slices.Contains(f.storage.deleted, deleteAccountAvatar) {
|
||||
t.Errorf("expected the stored file to be deleted, got %v", f.storage.deleted)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("An avatar that is used elsewhere is kept", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
prepare func(f *deleteAccountFixture)
|
||||
}{
|
||||
{"an article body", func(f *deleteAccountFixture) {
|
||||
stored := f.articles.articles[1]
|
||||
stored.Body = "<img src=\"" + image.PublicPath(deleteAccountAvatar) + "\">"
|
||||
f.articles.articles[1] = stored
|
||||
}},
|
||||
{"another author", func(f *deleteAccountFixture) {
|
||||
f.authors.seed(11, "Jonas", "jonas")
|
||||
f.authors.update(11, func(a *author.Author) { a.AvatarID = idPointer(deleteAccountAvatarID) })
|
||||
}},
|
||||
{"an article cover", func(f *deleteAccountFixture) {
|
||||
stored := f.articles.articles[2]
|
||||
stored.CoverImageID = idPointer(deleteAccountAvatarID)
|
||||
f.articles.articles[2] = stored
|
||||
}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
f := newDeleteAccountFixture(t)
|
||||
c.prepare(f)
|
||||
|
||||
rec := f.request(t, 7, deleteAccountBody)
|
||||
|
||||
assertDeleteAccountResponse(t, rec, http.StatusNoContent, "")
|
||||
if _, exists := f.images.images[deleteAccountAvatarID]; !exists {
|
||||
t.Error("the shared avatar image must stay")
|
||||
}
|
||||
if len(f.storage.deleted) != 0 {
|
||||
t.Errorf("no file may be deleted, got %v", f.storage.deleted)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A storage failure after the commit is logged and the answer stays 204", func(t *testing.T) {
|
||||
f := newDeleteAccountFixture(t)
|
||||
f.storage.failDelete = errRepositoryFailure
|
||||
var logged bytes.Buffer
|
||||
previous := log.Writer()
|
||||
log.SetOutput(&logged)
|
||||
defer log.SetOutput(previous)
|
||||
|
||||
rec := f.request(t, 7, deleteAccountBody)
|
||||
|
||||
assertDeleteAccountResponse(t, rec, http.StatusNoContent, "")
|
||||
if _, exists := f.users.users[7]; exists {
|
||||
t.Error("the user must be gone")
|
||||
}
|
||||
if !strings.Contains(logged.String(), "stored avatar") {
|
||||
t.Errorf("expected the storage failure in the log, got %q", logged.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A user without an avatar deletes no file", func(t *testing.T) {
|
||||
f := newDeleteAccountFixture(t)
|
||||
f.authors.update(deleteAccountAuthorID, func(a *author.Author) { a.AvatarID = nil })
|
||||
|
||||
rec := f.request(t, 7, deleteAccountBody)
|
||||
|
||||
assertDeleteAccountResponse(t, rec, http.StatusNoContent, "")
|
||||
if len(f.storage.deleted) != 0 {
|
||||
t.Errorf("no file may be deleted, got %v", f.storage.deleted)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Refused requests change nothing", func(t *testing.T) {
|
||||
validator.InitValidator()
|
||||
confirmationMessage := validator.Validate.Struct(user.DeleteAccountRequest{CurrentPassword: "x", Confirmation: "delete"}).Error()
|
||||
emptyConfirmation := validator.Validate.Struct(user.DeleteAccountRequest{CurrentPassword: "x"}).Error()
|
||||
passwordMessage := validator.Validate.Struct(user.DeleteAccountRequest{Confirmation: "DELETE"}).Error()
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
status int
|
||||
text string
|
||||
}{
|
||||
{"lowercase confirmation", `{"current_password": "limewash2026", "confirmation": "delete"}`, 400, confirmationMessage},
|
||||
{"empty confirmation", `{"current_password": "limewash2026", "confirmation": ""}`, 400, emptyConfirmation},
|
||||
{"trailing space confirmation", `{"current_password": "limewash2026", "confirmation": "DELETE "}`, 400, confirmationMessage},
|
||||
{"empty password", `{"current_password": "", "confirmation": "DELETE"}`, 400, passwordMessage},
|
||||
{"not json", `not-json`, 400, "The request cannot be processed due to a mismatch in the format of the body."},
|
||||
{"wrong password", `{"current_password": "nope", "confirmation": "DELETE"}`, 403, "wrong_password"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
f := newDeleteAccountFixture(t)
|
||||
before := f.state()
|
||||
|
||||
rec := f.request(t, 7, c.body)
|
||||
|
||||
assertDeleteAccountResponse(t, rec, c.status, c.text)
|
||||
f.assertUntouchedSince(t, before)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The last Owner or administrator is blocked", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
caller uint32
|
||||
prepare func(f *deleteAccountFixture)
|
||||
status int
|
||||
header string
|
||||
}{
|
||||
{"Owner, no other Owner, one Admin", 3, func(f *deleteAccountFixture) {}, 409, "Owner"},
|
||||
{"Owner, no other Owner, no Admin", 3, func(f *deleteAccountFixture) { delete(f.users.users, 2) }, 409, "Owner"},
|
||||
{"Owner, another verified Owner", 3, func(f *deleteAccountFixture) {
|
||||
f.users.users[4] = domain.User{ID: 4, Username: "ana", IsVerified: true, RoleID: ownerRoleID}
|
||||
}, 204, ""},
|
||||
{"Owner, only an unverified Owner", 3, func(f *deleteAccountFixture) {
|
||||
f.users.users[4] = domain.User{ID: 4, Username: "ana", IsVerified: false, RoleID: ownerRoleID}
|
||||
}, 409, "Owner"},
|
||||
{"Admin, no Owner and no other Admin", 2, func(f *deleteAccountFixture) { delete(f.users.users, 3) }, 409, "Admin"},
|
||||
{"Admin, one Owner", 2, func(f *deleteAccountFixture) {}, 204, ""},
|
||||
{"Admin, another verified Admin and no Owner", 2, func(f *deleteAccountFixture) {
|
||||
delete(f.users.users, 3)
|
||||
f.users.users[4] = domain.User{ID: 4, Username: "ana", IsVerified: true, RoleID: adminRoleID}
|
||||
}, 204, ""},
|
||||
{"Admin, only an unverified Admin and no Owner", 2, func(f *deleteAccountFixture) {
|
||||
delete(f.users.users, 3)
|
||||
f.users.users[4] = domain.User{ID: 4, Username: "ana", IsVerified: false, RoleID: adminRoleID}
|
||||
}, 409, "Admin"},
|
||||
{"Writer, no Owner and no Admin", 7, func(f *deleteAccountFixture) {
|
||||
delete(f.users.users, 3)
|
||||
delete(f.users.users, 2)
|
||||
}, 204, ""},
|
||||
{"a custom role with every admin permission, no Owner and no Admin", 7, func(f *deleteAccountFixture) {
|
||||
delete(f.users.users, 3)
|
||||
delete(f.users.users, 2)
|
||||
custom, _ := f.roles.Create(role.Role{Name: "Everything but owner", Grants: f.roles.byName(role.AdminName).Grants})
|
||||
caller := f.users.users[7]
|
||||
caller.RoleID = custom.ID
|
||||
f.users.users[7] = caller
|
||||
}, 204, ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
f := newDeleteAccountFixture(t)
|
||||
c.prepare(f)
|
||||
before := f.state()
|
||||
|
||||
rec := f.request(t, c.caller, deleteAccountBody)
|
||||
|
||||
if rec.Code != c.status {
|
||||
t.Fatalf("expected %d, got %d: %s", c.status, rec.Code, rec.Body.String())
|
||||
}
|
||||
if c.status == 409 {
|
||||
assertDeleteAccountResponse(t, rec, 409, "last_admin")
|
||||
f.assertUntouchedSince(t, before)
|
||||
}
|
||||
if got := rec.Header().Get("X-Last-Role"); got != c.header {
|
||||
t.Errorf("expected X-Last-Role %q, got %q", c.header, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A blocked attempt leaves everything and records a failed event", func(t *testing.T) {
|
||||
f := newDeleteAccountFixture(t)
|
||||
before := f.state()
|
||||
|
||||
rec := f.request(t, 3, deleteAccountBody)
|
||||
|
||||
assertDeleteAccountResponse(t, rec, http.StatusConflict, "last_admin")
|
||||
f.assertUntouchedSince(t, before)
|
||||
event := f.lastEvent(t)
|
||||
if event.Action != "user.delete_account" || event.Outcome != audit.OutcomeFailed || !event.Security || event.FailureReason == nil || *event.FailureReason != "Last Owner." {
|
||||
t.Errorf("unexpected audit event %+v", event)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A failed attempt records the reason on the user's own events", func(t *testing.T) {
|
||||
f := newDeleteAccountFixture(t)
|
||||
f.users.users[2] = domain.User{ID: 2, Username: "jonas", Password: "hashed:" + deleteAccountPassword, IsVerified: true, RoleID: adminRoleID}
|
||||
delete(f.users.users, 3)
|
||||
|
||||
wrong := f.request(t, 2, `{"current_password": "nope", "confirmation": "DELETE"}`)
|
||||
blocked := f.request(t, 2, deleteAccountBody)
|
||||
|
||||
if wrong.Code != 403 || blocked.Code != 409 || len(f.recorder.events) != 2 {
|
||||
t.Fatalf("unexpected outcome %d %d %d", wrong.Code, blocked.Code, len(f.recorder.events))
|
||||
}
|
||||
if *f.recorder.events[0].FailureReason != "Wrong password." || *f.recorder.events[1].FailureReason != "Last administrator." {
|
||||
t.Errorf("unexpected reasons %q %q", *f.recorder.events[0].FailureReason, *f.recorder.events[1].FailureReason)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A wrong password is reported before the last-role check", func(t *testing.T) {
|
||||
f := newDeleteAccountFixture(t)
|
||||
|
||||
rec := f.request(t, 3, `{"current_password": "nope", "confirmation": "DELETE"}`)
|
||||
|
||||
assertDeleteAccountResponse(t, rec, http.StatusForbidden, "wrong_password")
|
||||
if rec.Header().Get("X-Last-Role") != "" {
|
||||
t.Error("the role must not leak without the password")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A concurrent role change stops the removal", func(t *testing.T) {
|
||||
f := newDeleteAccountFixture(t)
|
||||
f.users.users[4] = domain.User{ID: 4, Username: "ana", IsVerified: true, RoleID: ownerRoleID}
|
||||
f.eraser.beforeErase = func() {
|
||||
demoted := f.users.users[4]
|
||||
demoted.RoleID = writerRoleID
|
||||
f.users.users[4] = demoted
|
||||
}
|
||||
before := f.state()
|
||||
before.users[4] = domain.User{ID: 4, Username: "ana", IsVerified: true, RoleID: writerRoleID}
|
||||
|
||||
rec := f.request(t, 3, deleteAccountBody)
|
||||
|
||||
assertDeleteAccountResponse(t, rec, http.StatusConflict, "account_not_deletable")
|
||||
f.assertUntouchedSince(t, before)
|
||||
if event := f.lastEvent(t); event.Outcome != audit.OutcomeFailed || *event.FailureReason == useCases.GenericFailureReason {
|
||||
t.Errorf("expected a specific failure reason, got %+v", event)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A mail failure does not undo the deletion", func(t *testing.T) {
|
||||
f := newDeleteAccountFixture(t)
|
||||
f.mail.fail.failNext("Send")
|
||||
var logged bytes.Buffer
|
||||
previous := log.Writer()
|
||||
log.SetOutput(&logged)
|
||||
defer log.SetOutput(previous)
|
||||
|
||||
rec := f.request(t, 7, deleteAccountBody)
|
||||
|
||||
assertDeleteAccountResponse(t, rec, http.StatusNoContent, "")
|
||||
if _, exists := f.users.users[7]; exists {
|
||||
t.Error("the user must be gone")
|
||||
}
|
||||
if !strings.Contains(logged.String(), "account deleted mail") {
|
||||
t.Errorf("expected the failed send in the log, got %q", logged.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A failure in the removal rolls everything back", func(t *testing.T) {
|
||||
for _, step := range []string{"DeleteAPIKeys", "ScrubAuthor", "AnonymiseEvents", "DeleteUser"} {
|
||||
t.Run(step, func(t *testing.T) {
|
||||
f := newDeleteAccountFixture(t)
|
||||
f.eraser.fail.failNext(step)
|
||||
before := f.state()
|
||||
|
||||
rec := f.request(t, 7, deleteAccountBody)
|
||||
|
||||
assertDeleteAccountResponse(t, rec, http.StatusInternalServerError, "Internal server error")
|
||||
f.assertUntouchedSince(t, before)
|
||||
if event := f.lastEvent(t); event.Outcome != audit.OutcomeFailed || *event.FailureReason != useCases.GenericFailureReason {
|
||||
t.Errorf("unexpected audit event %+v", event)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A failing session revocation does not undo the deletion", func(t *testing.T) {
|
||||
f := newDeleteAccountFixture(t)
|
||||
f.revoker.failure = errRepositoryFailure
|
||||
var logged bytes.Buffer
|
||||
previous := log.Writer()
|
||||
log.SetOutput(&logged)
|
||||
defer log.SetOutput(previous)
|
||||
|
||||
rec := f.request(t, 7, deleteAccountBody)
|
||||
|
||||
assertDeleteAccountResponse(t, rec, http.StatusNoContent, "")
|
||||
if !strings.Contains(logged.String(), "revoking the session") {
|
||||
t.Errorf("expected the failed revocation in the log, got %q", logged.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Rate limited after five attempts", func(t *testing.T) {
|
||||
f := newDeleteAccountFixture(t)
|
||||
for i := 0; i < 5; i++ {
|
||||
f.request(t, 7, `{"current_password": "nope", "confirmation": "DELETE"}`)
|
||||
}
|
||||
before := f.state()
|
||||
|
||||
rec := f.request(t, 7, deleteAccountBody)
|
||||
|
||||
assertDeleteAccountResponse(t, rec, http.StatusTooManyRequests, "Too many requests. Please try again later.")
|
||||
if rec.Header().Get("Retry-After") == "" {
|
||||
t.Error("expected a Retry-After header")
|
||||
}
|
||||
f.assertUntouchedSince(t, before)
|
||||
})
|
||||
|
||||
t.Run("Only a real verified session may delete", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
status int
|
||||
text string
|
||||
build func(f *deleteAccountFixture) *http.Request
|
||||
}{
|
||||
{"nobody", 401, "You must be logged in to access this resource.", func(f *deleteAccountFixture) *http.Request {
|
||||
return httptest.NewRequest(http.MethodDelete, "/v1/user/me", bytes.NewBufferString(deleteAccountBody))
|
||||
}},
|
||||
{"an API key bearer", 403, "This action needs a real session.", func(f *deleteAccountFixture) *http.Request {
|
||||
req := httptest.NewRequest(http.MethodDelete, "/v1/user/me", bytes.NewBufferString(deleteAccountBody))
|
||||
req.Header.Set("Authorization", "Bearer rcms_anything")
|
||||
return req
|
||||
}},
|
||||
{"an unverified user", 403, "You must verify your account to access this resource.", func(f *deleteAccountFixture) *http.Request {
|
||||
req := httptest.NewRequest(http.MethodDelete, "/v1/user/me", bytes.NewBufferString(deleteAccountBody))
|
||||
req.AddCookie(displayNameCookie(t, f.token, 9, 0))
|
||||
return req
|
||||
}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
f := newDeleteAccountFixture(t)
|
||||
before := f.state()
|
||||
|
||||
rec := f.serve(c.build(f))
|
||||
|
||||
assertDeleteAccountResponse(t, rec, c.status, c.text)
|
||||
f.assertUntouchedSince(t, before)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("DELETE /v1/user/{id} is unchanged", func(t *testing.T) {
|
||||
f := newDeleteAccountFixture(t)
|
||||
|
||||
other := f.serve(httptest.NewRequest(http.MethodDelete, "/v1/user/7?expected_role_id=3", nil))
|
||||
self := f.request(t, 7, deleteAccountBody)
|
||||
|
||||
if other.Code != http.StatusNoContent || f.removeHits != 1 {
|
||||
t.Errorf("expected /{id} to reach its own handler once, got %d and %d hits", other.Code, f.removeHits)
|
||||
}
|
||||
if self.Code != http.StatusNoContent || f.removeHits != 1 {
|
||||
t.Errorf("expected /me to bypass the {id} handler, got %d and %d hits", self.Code, f.removeHits)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestAccountDeleteLastAdminErrorContract(t *testing.T) {
|
||||
owner := domain.LastAdminError{Role: domain.LastRoleOwner}
|
||||
admin := domain.LastAdminError{Role: domain.LastRoleAdmin}
|
||||
if owner.Error() != domain.ErrLastOwner.Error() || admin.Error() != domain.ErrLastAdmin.Error() {
|
||||
t.Errorf("unexpected messages %q / %q", owner.Error(), admin.Error())
|
||||
}
|
||||
if !errors.Is(owner, domain.ErrLastOwner) || !errors.Is(owner, domain.ErrLastAdmin) {
|
||||
t.Error("the last Owner must match both sentinels")
|
||||
}
|
||||
if !errors.Is(admin, domain.ErrLastAdmin) || errors.Is(admin, domain.ErrLastOwner) {
|
||||
t.Error("the last administrator must match only ErrLastAdmin")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/api/handlers/user"
|
||||
"RenewCMS/internal/domain/author"
|
||||
domain "RenewCMS/internal/domain/user"
|
||||
"RenewCMS/internal/infrastructure/config"
|
||||
"RenewCMS/internal/infrastructure/mailer"
|
||||
"net/http"
|
||||
"os"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func readWebSource(t *testing.T, path string) string {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile("../web/src/" + path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func TestAccountDeleteLiteralParity(t *testing.T) {
|
||||
t.Run("Deleted user is the same name in the domain, the audit trail and the SPA", func(t *testing.T) {
|
||||
if domain.DeletedUserName != author.DeletedName {
|
||||
t.Errorf("user %q and author %q disagree", domain.DeletedUserName, author.DeletedName)
|
||||
}
|
||||
if !strings.Contains(readWebSource(t, "lib/deletedUser.ts"), "DELETED_USER_NAME = '"+domain.DeletedUserName+"'") {
|
||||
t.Errorf("web/src/lib/deletedUser.ts does not name the user %q", domain.DeletedUserName)
|
||||
}
|
||||
if !strings.Contains(readWebSource(t, "i18n/messages.ts"), "'"+domain.DeletedUserName+"':") {
|
||||
t.Errorf("the i18n catalogue has no entry for %q", domain.DeletedUserName)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The confirmation word is the same in the handler and in the SPA", func(t *testing.T) {
|
||||
field, _ := reflect.TypeOf(user.DeleteAccountRequest{}).FieldByName("Confirmation")
|
||||
matched := regexp.MustCompile(`eq=(\w+)`).FindStringSubmatch(field.Tag.Get("validate"))
|
||||
if matched == nil {
|
||||
t.Fatalf("the Confirmation tag has no eq= rule: %q", field.Tag)
|
||||
}
|
||||
if !strings.Contains(readWebSource(t, "lib/accountDeletion.ts"), "DELETE_WORD = '"+matched[1]+"'") {
|
||||
t.Errorf("web/src/lib/accountDeletion.ts does not use the word %q", matched[1])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The template the use case sends is the one the mailer renders", func(t *testing.T) {
|
||||
f := newDeleteAccountFixture(t)
|
||||
|
||||
rec := f.request(t, 7, deleteAccountBody)
|
||||
|
||||
if rec.Code != http.StatusNoContent || len(f.mail.SentEmails) != 1 {
|
||||
t.Fatalf("unexpected outcome %d with %d mails", rec.Code, len(f.mail.SentEmails))
|
||||
}
|
||||
sent := f.mail.SentEmails[0]
|
||||
subject, body, err := mailer.NewMailRepository(config.SmtpSettings{}).Render(sent.Template, sent.Data)
|
||||
if err != nil || body == "" || subject == "RenewCMS | Action required" {
|
||||
t.Errorf("the mailer does not know %q: subject %q err %v", sent.Template, subject, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/user"
|
||||
entity "RenewCMS/internal/infrastructure/persistence/models"
|
||||
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func seedDeleteAccountRows(t *testing.T, db *gorm.DB, creditedArticles bool) {
|
||||
t.Helper()
|
||||
agent, ip := "Firefox", "203.0.113.7"
|
||||
martaID, inesID := uint32(7), uint32(8)
|
||||
website := "https://marta.example"
|
||||
rows := []any{
|
||||
&entity.User{ID: 7, Username: "marta", Password: "x", Email: "marta@renew.example", IsVerified: true, RoleID: &martaID},
|
||||
&entity.User{ID: 8, Username: "ines", Password: "x", Email: "ines@renew.example", IsVerified: true, RoleID: &inesID},
|
||||
&entity.APIKey{ID: 1, UserID: 7, Name: "ci", Prefix: "a", HashedKey: "h1"},
|
||||
&entity.APIKey{ID: 2, UserID: 7, Name: "deploy", Prefix: "b", HashedKey: "h2"},
|
||||
&entity.APIKey{ID: 3, UserID: 8, Name: "ines", Prefix: "c", HashedKey: "h3"},
|
||||
&entity.Author{ID: 5, Name: "Marta Alves", Slug: "marta-alves", Bio: "bio", Website: &website, SocialLinks: `[{"type":"github","url":"https://github.com/marta"}]`, UserID: &martaID, AvatarID: &martaID},
|
||||
&entity.Author{ID: 6, Name: "Ines", Slug: "ines", SocialLinks: "[]", UserID: &inesID},
|
||||
&entity.Article{ID: 1, Title: "Mine", Slug: "mine", CreatedBy: 7},
|
||||
&entity.Entry{ID: 1, ContentTypeID: 1, Data: "{}", CreatedBy: 7, UpdatedBy: 7},
|
||||
&entity.Image{ID: 1, Path: "/a.png", Filename: "a.png", UploadedByID: 7},
|
||||
&entity.AuditEvent{ID: 1, Action: "article.edit", Category: "articles", ActorType: "user", ActorID: &martaID, ActorName: "marta", TargetType: "article", TargetID: "1", TargetName: "Mine", Outcome: "success", Source: "web", IP: &ip, UserAgent: &agent, Params: `{"title":"Mine"}`, Changes: `[{"field":"title","kind":"text","after":"Mine"}]`},
|
||||
&entity.AuditEvent{ID: 2, Action: "user.change_role", Category: "users", ActorType: "user", ActorID: &inesID, ActorName: "ines", TargetType: "user", TargetID: "7", TargetName: "marta", Outcome: "success", Source: "web", IP: &ip, Params: `{"to_name":"marta"}`, Changes: `[{"field":"role","kind":"text","after":"Writer"}]`},
|
||||
&entity.AuditEvent{ID: 4, Action: "author.edit", Category: "authors", ActorType: "user", ActorID: &martaID, ActorName: "marta", TargetType: "author", TargetID: "5", TargetName: "Marta Alves", Outcome: "success", Source: "web", Params: `{"bio":"Writes about lime"}`, Changes: `[{"field":"bio","kind":"text","after":"Writes about lime"}]`},
|
||||
&entity.AuditEvent{ID: 5, Action: "author.edit", Category: "authors", ActorType: "user", ActorID: &martaID, ActorName: "marta", TargetType: "author", TargetID: "6", TargetName: "Ines", Outcome: "success", Source: "web", Params: "{}", Changes: "[]"},
|
||||
&entity.AuditEvent{ID: 6, Action: "author.edit", Category: "authors", ActorType: "user", ActorID: &inesID, ActorName: "ines", TargetType: "author", TargetID: "5", TargetName: "Marta Alves", Outcome: "success", Source: "web", Params: `{"keep":"yes"}`, Changes: "[]"},
|
||||
&entity.AuditEvent{ID: 3, Action: "article.edit", Category: "articles", ActorType: "user", ActorID: &inesID, ActorName: "ines", TargetType: "article", TargetID: "1", TargetName: "Mine", Outcome: "success", Source: "web", IP: &ip, Params: `{"title":"Mine"}`, Changes: `[{"field":"title","kind":"text","after":"Mine"}]`},
|
||||
}
|
||||
if creditedArticles {
|
||||
rows = append(rows, &entity.ArticleAuthor{ArticleID: 1, AuthorID: 5, Position: 0})
|
||||
}
|
||||
for _, row := range rows {
|
||||
if err := db.Create(row).Error; err != nil {
|
||||
t.Fatalf("seed %T: %v", row, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func deleteAccountRowCount(t *testing.T, db *gorm.DB, model any, query string, args ...any) int64 {
|
||||
t.Helper()
|
||||
var total int64
|
||||
if err := db.Model(model).Where(query, args...).Count(&total).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
func TestAccountDeleteErasureContract(t *testing.T) {
|
||||
backends := []contractBackend{
|
||||
{name: "sqlite", open: openContractSQLite},
|
||||
{name: "mysql", open: openContractMySQL},
|
||||
{name: "postgres", open: openContractPostgres},
|
||||
}
|
||||
for _, backend := range backends {
|
||||
t.Run(backend.name, func(t *testing.T) {
|
||||
t.Run("The user, keys, author credit and events are handled in one operation", func(t *testing.T) {
|
||||
db := backend.open(t)
|
||||
seedDeleteAccountRows(t, db, true)
|
||||
|
||||
err := persistence.NewAccountEraser(db).Erase(user.Erasure{UserID: 7})
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if deleteAccountRowCount(t, db, &entity.User{}, "id = ?", 7) != 0 || deleteAccountRowCount(t, db, &entity.User{}, "id = ?", 8) != 1 {
|
||||
t.Error("only user 7 must be removed")
|
||||
}
|
||||
if deleteAccountRowCount(t, db, &entity.APIKey{}, "user_id = ?", 7) != 0 || deleteAccountRowCount(t, db, &entity.APIKey{}, "user_id = ?", 8) != 1 {
|
||||
t.Error("only the keys of user 7 must be removed")
|
||||
}
|
||||
var scrubbed entity.Author
|
||||
if err := db.First(&scrubbed, 5).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if scrubbed.Name != "Deleted user" || scrubbed.Slug != "deleted-user-5" || scrubbed.Bio != "" || scrubbed.UserID != nil || scrubbed.AvatarID != nil || scrubbed.Website != nil || scrubbed.SocialLinks != "[]" {
|
||||
t.Errorf("unexpected scrubbed author %+v", scrubbed)
|
||||
}
|
||||
var other entity.Author
|
||||
if err := db.First(&other, 6).Error; err != nil || other.Name != "Ines" || other.UserID == nil {
|
||||
t.Errorf("the other author must be untouched: %+v %v", other, err)
|
||||
}
|
||||
var own, targeted, unrelated entity.AuditEvent
|
||||
db.First(&own, 1)
|
||||
db.First(&targeted, 2)
|
||||
db.First(&unrelated, 3)
|
||||
if own.ActorName != "Deleted user" || own.IP != nil || own.UserAgent != nil || own.Params != "{}" || own.Changes != "[]" {
|
||||
t.Errorf("own event not anonymised: %+v", own)
|
||||
}
|
||||
if own.Action != "article.edit" || own.Outcome != "success" || own.CreatedAt.IsZero() {
|
||||
t.Errorf("the event itself must stay: %+v", own)
|
||||
}
|
||||
if targeted.TargetName != "Deleted user" || targeted.ActorName != "ines" || targeted.IP == nil || targeted.Params != "{}" || targeted.Changes != "[]" {
|
||||
t.Errorf("targeted event wrong: %+v", targeted)
|
||||
}
|
||||
if unrelated.ActorName != "ines" || unrelated.IP == nil || unrelated.Params != `{"title":"Mine"}` || unrelated.Changes == "[]" {
|
||||
t.Errorf("unrelated event must be untouched: %+v", unrelated)
|
||||
}
|
||||
var ownProfile, otherProfile, byOther entity.AuditEvent
|
||||
db.First(&ownProfile, 4)
|
||||
db.First(&otherProfile, 5)
|
||||
db.First(&byOther, 6)
|
||||
if ownProfile.TargetName != "Deleted user" || ownProfile.Params != "{}" || ownProfile.Changes != "[]" {
|
||||
t.Errorf("an author event on the own profile must lose the name and its content: %+v", ownProfile)
|
||||
}
|
||||
if otherProfile.TargetName != "Ines" || otherProfile.ActorName != "Deleted user" {
|
||||
t.Errorf("an author event on another person's profile keeps its target: %+v", otherProfile)
|
||||
}
|
||||
if byOther.TargetName != "Marta Alves" || byOther.Params != `{"keep":"yes"}` {
|
||||
t.Errorf("an event of another user on this author is not erased: %+v", byOther)
|
||||
}
|
||||
if deleteAccountRowCount(t, db, &entity.Article{}, "created_by = ?", 7) != 1 || deleteAccountRowCount(t, db, &entity.Entry{}, "created_by = ?", 7) != 1 || deleteAccountRowCount(t, db, &entity.Image{}, "uploaded_by_id = ?", 7) != 1 {
|
||||
t.Error("articles, entries and images keep the old id")
|
||||
}
|
||||
})
|
||||
t.Run("The avatar image row is deleted in the same operation", func(t *testing.T) {
|
||||
db := backend.open(t)
|
||||
seedDeleteAccountRows(t, db, true)
|
||||
if err := db.Create(&entity.Image{ID: 9, Path: "/avatar.png", Filename: "avatar.png", UploadedByID: 8}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
avatar := uint32(9)
|
||||
|
||||
err := persistence.NewAccountEraser(db).Erase(user.Erasure{UserID: 7, AvatarImageID: &avatar})
|
||||
|
||||
if err != nil || deleteAccountRowCount(t, db, &entity.Image{}, "id = ?", 9) != 0 || deleteAccountRowCount(t, db, &entity.Image{}, "id = ?", 1) != 1 {
|
||||
t.Errorf("expected only the avatar image to be gone, got %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("A scrubbed author whose deleted-user slug is taken gets a free suffix", func(t *testing.T) {
|
||||
db := backend.open(t)
|
||||
seedDeleteAccountRows(t, db, true)
|
||||
if err := db.Create(&entity.Author{ID: 20, Name: "Legacy", Slug: "deleted-user-5", SocialLinks: "[]"}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err := persistence.NewAccountEraser(db).Erase(user.Erasure{UserID: 7})
|
||||
|
||||
var scrubbed entity.Author
|
||||
db.First(&scrubbed, 5)
|
||||
if err != nil || scrubbed.Slug != "deleted-user-5-2" {
|
||||
t.Errorf("expected deleted-user-5-2, got %q %v", scrubbed.Slug, err)
|
||||
}
|
||||
})
|
||||
t.Run("A failure on the audit step restores the avatar image too", func(t *testing.T) {
|
||||
db := backend.open(t)
|
||||
seedDeleteAccountRows(t, db, true)
|
||||
if err := db.Create(&entity.Image{ID: 9, Path: "/avatar.png", Filename: "avatar.png", UploadedByID: 8}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Migrator().DropTable(&entity.AuditEvent{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
avatar := uint32(9)
|
||||
|
||||
err := persistence.NewAccountEraser(db).Erase(user.Erasure{UserID: 7, AvatarImageID: &avatar})
|
||||
|
||||
if err == nil || deleteAccountRowCount(t, db, &entity.Image{}, "id = ?", 9) != 1 {
|
||||
t.Errorf("expected a rollback, got %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("Concurrent self-deletions never leave the workspace without a verified Owner", func(t *testing.T) {
|
||||
db := backend.open(t)
|
||||
var owner entity.Role
|
||||
if err := db.Where("name = ?", "Owner").First(&owner).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const owners = 4
|
||||
for id := uint32(1); id <= owners; id++ {
|
||||
name := fmt.Sprintf("owner%d", id)
|
||||
row := &entity.User{ID: id, Username: name, Password: "x", Email: name + "@renew.example", IsVerified: true, RoleID: &owner.ID}
|
||||
if err := db.Create(row).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
eraser := persistence.NewAccountEraser(db)
|
||||
results := make(chan error, owners)
|
||||
var start sync.WaitGroup
|
||||
start.Add(1)
|
||||
for id := uint32(1); id <= owners; id++ {
|
||||
go func() {
|
||||
start.Wait()
|
||||
results <- eraser.Erase(user.Erasure{UserID: id, GuardRoleIDs: []uint32{owner.ID}})
|
||||
}()
|
||||
}
|
||||
start.Done()
|
||||
deleted := 0
|
||||
for i := 0; i < owners; i++ {
|
||||
err := <-results
|
||||
if err == nil {
|
||||
deleted++
|
||||
} else if !errors.Is(err, user.ErrAccountNotDeletable) {
|
||||
t.Errorf("a loser must answer ErrAccountNotDeletable, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
remaining := deleteAccountRowCount(t, db, &entity.User{}, "role_id = ? AND is_verified = ?", owner.ID, true)
|
||||
if remaining < 1 || int(remaining)+deleted != owners {
|
||||
t.Errorf("expected at least one Owner left and a consistent count, got %d remaining after %d deletions", remaining, deleted)
|
||||
}
|
||||
})
|
||||
t.Run("An author that credits no article is deleted", func(t *testing.T) {
|
||||
db := backend.open(t)
|
||||
seedDeleteAccountRows(t, db, false)
|
||||
|
||||
err := persistence.NewAccountEraser(db).Erase(user.Erasure{UserID: 7})
|
||||
|
||||
if err != nil || deleteAccountRowCount(t, db, &entity.Author{}, "id = ?", 5) != 0 || deleteAccountRowCount(t, db, &entity.Author{}, "id = ?", 6) != 1 {
|
||||
t.Errorf("expected only author 5 to be gone, got %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("The role holders are counted again inside the operation", func(t *testing.T) {
|
||||
db := backend.open(t)
|
||||
seedDeleteAccountRows(t, db, true)
|
||||
eraser := persistence.NewAccountEraser(db)
|
||||
|
||||
refused := eraser.Erase(user.Erasure{UserID: 7, GuardRoleIDs: []uint32{7}})
|
||||
allowed := eraser.Erase(user.Erasure{UserID: 7, GuardRoleIDs: []uint32{8}})
|
||||
|
||||
if !errors.Is(refused, user.ErrAccountNotDeletable) {
|
||||
t.Errorf("expected ErrAccountNotDeletable, got %v", refused)
|
||||
}
|
||||
if allowed != nil || deleteAccountRowCount(t, db, &entity.User{}, "id = ?", 7) != 0 {
|
||||
t.Errorf("expected the removal once another verified holder exists, got %v", allowed)
|
||||
}
|
||||
})
|
||||
t.Run("A refused re-check removes nothing", func(t *testing.T) {
|
||||
db := backend.open(t)
|
||||
seedDeleteAccountRows(t, db, true)
|
||||
|
||||
err := persistence.NewAccountEraser(db).Erase(user.Erasure{UserID: 7, GuardRoleIDs: []uint32{7}})
|
||||
|
||||
if !errors.Is(err, user.ErrAccountNotDeletable) || deleteAccountRowCount(t, db, &entity.User{}, "id = ?", 7) != 1 || deleteAccountRowCount(t, db, &entity.APIKey{}, "user_id = ?", 7) != 2 {
|
||||
t.Errorf("expected nothing removed, got %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("A failure half way rolls everything back", func(t *testing.T) {
|
||||
db := backend.open(t)
|
||||
seedDeleteAccountRows(t, db, true)
|
||||
if err := db.Migrator().DropTable(&entity.AuditEvent{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err := persistence.NewAccountEraser(db).Erase(user.Erasure{UserID: 7})
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected the missing events table to fail the operation")
|
||||
}
|
||||
var author entity.Author
|
||||
if db.First(&author, 5).Error != nil || author.Name != "Marta Alves" || author.UserID == nil {
|
||||
t.Errorf("the author must be restored: %+v", author)
|
||||
}
|
||||
if deleteAccountRowCount(t, db, &entity.User{}, "id = ?", 7) != 1 || deleteAccountRowCount(t, db, &entity.APIKey{}, "user_id = ?", 7) != 2 {
|
||||
t.Error("the user and the keys must be restored")
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/author"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAccountDeleteReservedAuthorSlug(t *testing.T) {
|
||||
t.Run("The deleted-user prefix is not a valid author slug", func(t *testing.T) {
|
||||
for slug, valid := range map[string]bool{"deleted-user-5": false, "deleted-user-": false, "deleted-user": true, "deleted-users-5": true, "marta": true} {
|
||||
if author.IsValidSlug(slug) != valid {
|
||||
t.Errorf("%q: expected valid=%v", slug, valid)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The reserved prefix is detected exactly", func(t *testing.T) {
|
||||
for slug, reserved := range map[string]bool{"deleted-user-": true, "deleted-user-7": true, "deleted-user": false, "": false, "deleted-users": false} {
|
||||
if author.IsReservedSlug(slug) != reserved {
|
||||
t.Errorf("%q: expected reserved=%v", slug, reserved)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A generated slug never lands on the reserved prefix", func(t *testing.T) {
|
||||
if got := author.GenerateSlug("Deleted User 5"); author.IsReservedSlug(got) || got != "author-deleted-user-5" {
|
||||
t.Errorf("unexpected slug %q", got)
|
||||
}
|
||||
if got := author.SlugWithSuffix("deleted-user", 2); author.IsReservedSlug(got) {
|
||||
t.Errorf("suffixed slug %q is reserved", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Creating an author with a reserved slug is refused", func(t *testing.T) {
|
||||
authors := plainAuthors()
|
||||
uc := useCases.NewCreateAuthorUseCase(authors, newMockUserRepo(), newMockImageRepo(), discardRecorder)
|
||||
|
||||
_, err := uc.Create(context.Background(), useCases.CreateAuthorCommand{Name: "Marta", Slug: "deleted-user-9"})
|
||||
|
||||
if !errors.Is(err, author.ErrInvalidSlug) || len(authors.authors) != 0 {
|
||||
t.Errorf("err %v created %d", err, len(authors.authors))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Creating an author named like the reserved prefix gets a safe slug", func(t *testing.T) {
|
||||
authors := plainAuthors()
|
||||
uc := useCases.NewCreateAuthorUseCase(authors, newMockUserRepo(), newMockImageRepo(), discardRecorder)
|
||||
|
||||
created, err := uc.Create(context.Background(), useCases.CreateAuthorCommand{Name: "Deleted User 9"})
|
||||
|
||||
if err != nil || created.Slug != "author-deleted-user-9" {
|
||||
t.Errorf("slug %q err %v", created.Slug, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Changing an author slug to a reserved one is refused", func(t *testing.T) {
|
||||
authors := plainAuthors()
|
||||
authors.seed(2, "B", "b")
|
||||
uc := useCases.NewUpdateAuthorUseCase(authors, newMockUserRepo(), newMockImageRepo(), discardRecorder)
|
||||
reserved := "deleted-user-2"
|
||||
|
||||
_, err := uc.Update(context.Background(), 2, useCases.AuthorCaller{UserID: 9, Grants: authorsEditAllGrants}, author.Patch{Slug: &reserved})
|
||||
|
||||
if !errors.Is(err, author.ErrInvalidSlug) {
|
||||
t.Errorf("err = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/api/middleware"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAccountExportDataEmptyCollectionsAreArrays(t *testing.T) {
|
||||
f := newExportFixture(t)
|
||||
for index := range f.events.events {
|
||||
f.events.events[index].Params = nil
|
||||
}
|
||||
|
||||
rec := f.export(t, exportMartaID)
|
||||
files := openExportArchive(t, rec)
|
||||
|
||||
for _, item := range exportList(t, exportJSON(t, files, "authored-content.json"), "articles") {
|
||||
authors, ok := item.(map[string]any)["authors"].([]any)
|
||||
if !ok || len(authors) != 0 {
|
||||
t.Errorf("uncredited article authors = %v, want empty list", item.(map[string]any)["authors"])
|
||||
}
|
||||
}
|
||||
events := exportList(t, exportJSON(t, files, "account-activity.json"), "events")
|
||||
if len(events) == 0 {
|
||||
t.Fatal("expected activity events")
|
||||
}
|
||||
for _, item := range events {
|
||||
params, ok := item.(map[string]any)["params"].(map[string]any)
|
||||
if !ok || len(params) != 0 {
|
||||
t.Errorf("event params = %v, want empty object", item.(map[string]any)["params"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func successLimiterStatus(t *testing.T, handler http.Handler) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||
return rec
|
||||
}
|
||||
|
||||
func newSuccessLimiterHandler(clock *exportClock, status *int) http.Handler {
|
||||
limited := middleware.NewUserSuccessLimiterAt(time.Minute, clock.now)
|
||||
return limited(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(*status)
|
||||
}))
|
||||
}
|
||||
|
||||
func TestUserSuccessLimiterStatusBoundary(t *testing.T) {
|
||||
cases := map[string]struct {
|
||||
status int
|
||||
blocked bool
|
||||
}{
|
||||
"399 keeps the slot": {399, true},
|
||||
"400 releases the slot": {400, false},
|
||||
"500 releases the slot": {500, false},
|
||||
"200 keeps the slot": {200, true},
|
||||
"302 keeps the slot": {302, true},
|
||||
"429 releases the slot": {429, false},
|
||||
"401 releases the slot": {401, false},
|
||||
"204 keeps the slot too": {204, true},
|
||||
}
|
||||
for name, tc := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
clock := &exportClock{current: time.Date(2026, time.October, 29, 9, 0, 0, 0, time.UTC)}
|
||||
status := tc.status
|
||||
handler := newSuccessLimiterHandler(clock, &status)
|
||||
|
||||
successLimiterStatus(t, handler)
|
||||
second := successLimiterStatus(t, handler)
|
||||
|
||||
if tc.blocked && second.Code != http.StatusTooManyRequests {
|
||||
t.Errorf("second call = %d, want 429", second.Code)
|
||||
}
|
||||
if !tc.blocked && second.Code != tc.status {
|
||||
t.Errorf("second call = %d, want handler status %d", second.Code, tc.status)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserSuccessLimiterWindowEdge(t *testing.T) {
|
||||
clock := &exportClock{current: time.Date(2026, time.October, 29, 9, 0, 0, 0, time.UTC)}
|
||||
status := http.StatusOK
|
||||
handler := newSuccessLimiterHandler(clock, &status)
|
||||
|
||||
successLimiterStatus(t, handler)
|
||||
clock.advance(time.Minute - time.Second)
|
||||
blocked := successLimiterStatus(t, handler)
|
||||
if blocked.Code != http.StatusTooManyRequests || blocked.Header().Get("Retry-After") != "1" {
|
||||
t.Errorf("just inside the window = %d retry-after %q, want 429 and 1", blocked.Code, blocked.Header().Get("Retry-After"))
|
||||
}
|
||||
clock.advance(time.Second)
|
||||
if edge := successLimiterStatus(t, handler); edge.Code != http.StatusOK {
|
||||
t.Errorf("exactly at the window = %d, want 200", edge.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,528 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/api/handlers/user"
|
||||
"RenewCMS/api/middleware"
|
||||
"RenewCMS/api/pkg/authtoken"
|
||||
validator "RenewCMS/api/pkg/validator"
|
||||
"RenewCMS/internal/domain/apikey"
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/domain/contenttype"
|
||||
"RenewCMS/internal/domain/entry"
|
||||
"RenewCMS/internal/domain/image"
|
||||
"RenewCMS/internal/domain/role"
|
||||
domainUser "RenewCMS/internal/domain/user"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
const (
|
||||
exportMartaID uint32 = 7
|
||||
exportInesID uint32 = 8
|
||||
exportUnverifID uint32 = 9
|
||||
exportTokenVer uint32 = 3
|
||||
exportSecret = "hashed:secret-hash"
|
||||
exportRoute = "/v1/user/me/export"
|
||||
)
|
||||
|
||||
var exportFiles = []string{"account-activity.json", "authored-content.json", "profile.json", "uploaded-media.json"}
|
||||
|
||||
type exportClock struct{ current time.Time }
|
||||
|
||||
func (c *exportClock) now() time.Time { return c.current }
|
||||
|
||||
func (c *exportClock) advance(d time.Duration) { c.current = c.current.Add(d) }
|
||||
|
||||
type exportAuditFake struct {
|
||||
events []audit.Event
|
||||
}
|
||||
|
||||
var _ audit.Repository = &exportAuditFake{}
|
||||
|
||||
func (f *exportAuditFake) Append(event audit.Event) (audit.Event, error) {
|
||||
f.events = append(f.events, event)
|
||||
return event, nil
|
||||
}
|
||||
|
||||
func (f *exportAuditFake) List(filter audit.Filter) (audit.Page, error) {
|
||||
items := []audit.Event{}
|
||||
for _, event := range f.events {
|
||||
if filter.Actor != nil {
|
||||
if event.Actor.Type != filter.Actor.Type || filter.Actor.ID != nil && (event.Actor.ID == nil || *event.Actor.ID != *filter.Actor.ID) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
items = append(items, event)
|
||||
}
|
||||
return audit.Page{Items: items, Total: len(items)}, nil
|
||||
}
|
||||
|
||||
func (f *exportAuditFake) Summary(audit.SummaryQuery) (audit.Summary, error) {
|
||||
return audit.Summary{}, nil
|
||||
}
|
||||
|
||||
func (f *exportAuditFake) DeleteOlderThan(time.Time) (int, error) { return 0, nil }
|
||||
|
||||
func (f *exportAuditFake) DeleteOldest(int, time.Time) (int, error) { return 0, nil }
|
||||
|
||||
func (f *exportAuditFake) SizeBytes() (int64, error) { return 0, nil }
|
||||
|
||||
type exportFixture struct {
|
||||
clock *exportClock
|
||||
users *mockUserRepo
|
||||
roles *mockRoleRepo
|
||||
authors *mockAuthorRepo
|
||||
articles *mockArticleRepo
|
||||
entries *mockEntryRepo
|
||||
images *mockImageRepo
|
||||
keys *mockAPIKeyRepo
|
||||
types *mockContentTypeRepo
|
||||
events *exportAuditFake
|
||||
recorder *recordingRecorder
|
||||
token *authtoken.Auth
|
||||
router *chi.Mux
|
||||
}
|
||||
|
||||
const exportDefaultCap = 2
|
||||
|
||||
func newExportFixture(t *testing.T) *exportFixture {
|
||||
t.Helper()
|
||||
return newExportFixtureWithCap(t, exportDefaultCap)
|
||||
}
|
||||
|
||||
func newExportFixtureWithCap(t *testing.T, maxConcurrent int) *exportFixture {
|
||||
t.Helper()
|
||||
validator.InitValidator()
|
||||
articles := newMockArticleRepo()
|
||||
f := &exportFixture{
|
||||
clock: &exportClock{current: time.Date(2026, time.October, 29, 9, 41, 0, 0, time.UTC)},
|
||||
users: newMockUserRepo(),
|
||||
roles: newSeededMockRoleRepo(),
|
||||
authors: newMockAuthorRepo(articles),
|
||||
articles: articles,
|
||||
entries: newMockEntryRepo(),
|
||||
images: newMockImageRepo(),
|
||||
keys: newMockAPIKeyRepo(),
|
||||
types: newMockContentTypeRepo(),
|
||||
events: &exportAuditFake{},
|
||||
recorder: &recordingRecorder{},
|
||||
token: authtoken.New([]byte("test-secret")),
|
||||
}
|
||||
editor := f.roles.byName(role.WriterName)
|
||||
created := time.Date(2026, time.March, 2, 10, 0, 0, 0, time.UTC)
|
||||
f.users.users[exportMartaID] = domainUser.User{
|
||||
ID: exportMartaID, Username: "marta", DisplayName: "Marta Alves", Email: "marta@renew.example",
|
||||
Password: exportSecret, PasswordResetCode: "reset-code-xyz", VerificationCode: "verification-code-xyz",
|
||||
EmailChangeCode: "email-change-code-xyz", PendingEmail: "marta.new@renew.example", TokenVersion: exportTokenVer,
|
||||
RoleID: editor.ID, IsVerified: true, CreatedAt: created, UpdatedAt: created,
|
||||
}
|
||||
f.users.users[exportInesID] = domainUser.User{
|
||||
ID: exportInesID, Username: "ines", DisplayName: "Ines Duarte", Email: "ines@renew.example",
|
||||
Password: "hashed:ines", RoleID: editor.ID, IsVerified: true, TokenVersion: exportTokenVer,
|
||||
}
|
||||
f.users.users[exportUnverifID] = domainUser.User{ID: exportUnverifID, Username: "newbie", Email: "newbie@renew.example", Password: "hashed:n", RoleID: editor.ID}
|
||||
f.users.nextID = exportUnverifID
|
||||
|
||||
f.types.types[1] = contenttype.ContentType{ID: 1, Key: "testimonial", Name: "Testimonial"}
|
||||
f.seedMartaContent()
|
||||
f.seedInesContent()
|
||||
|
||||
getUser := useCases.NewGetUserUseCase(f.users)
|
||||
handler := user.NewExportHandler(useCases.NewExportUserDataUseCase(
|
||||
f.users, f.roles, f.authors, f.keys, f.articles, f.entries, f.types, f.images, f.events, f.clock.now, f.recorder, maxConcurrent,
|
||||
))
|
||||
f.router = chi.NewRouter()
|
||||
f.router.Route("/v1/user", func(r chi.Router) {
|
||||
r.Use(middleware.RequireSession)
|
||||
r.Use(authtoken.Verifier(f.token))
|
||||
r.Use(middleware.RequireSignedIn)
|
||||
r.Use(authtoken.Authenticator)
|
||||
r.Use(middleware.RequireCurrentToken(getUser))
|
||||
r.Use(middleware.RequireVerifiedUser(getUser))
|
||||
r.With(middleware.NewUserSuccessLimiterAt(15*time.Minute, f.clock.now)).Get("/me/export", handler.Export)
|
||||
})
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *exportFixture) seedMartaContent() {
|
||||
stamp := time.Date(2026, time.April, 3, 8, 0, 0, 0, time.UTC)
|
||||
f.articles.articles[1] = article.Article{ID: 1, Title: "Limewash basics", Body: "Mix lime putty.", Slug: "limewash-basics", SiteID: 1, IsOnline: true, CreatedBy: exportMartaID, CreatedAt: stamp, UpdatedAt: stamp}
|
||||
f.articles.articles[2] = article.Article{ID: 2, Title: "Draft on tadelakt", Body: "Soap and polish.", Slug: "draft-tadelakt", SiteID: 1, CreatedBy: exportMartaID, CreatedAt: stamp, UpdatedAt: stamp}
|
||||
f.entries.entries[1] = entry.Entry{ID: 1, ContentTypeID: 1, Data: map[string]any{"quote": "Loved it"}, CreatedBy: exportMartaID, UpdatedBy: exportMartaID, CreatedAt: stamp, UpdatedAt: stamp}
|
||||
for i, name := range []string{"wall.png", "floor.jpg", "ceiling.webp"} {
|
||||
f.images.images[uint32(i+1)] = image.Image{ID: uint32(i + 1), Path: "/static/uploadedImages/stored-" + name, Filename: name, Size: 2048, ContentType: "image/png", SHA256: strings.Repeat("a", 63) + string(rune('1'+i)), UploadedByID: exportMartaID, CreatedAt: stamp}
|
||||
}
|
||||
f.keys.keys[1] = apikey.APIKey{ID: 1, UserID: exportMartaID, Name: "CI deploy", Prefix: "rcms_ab12", HashedKey: "sha-of-the-key", CreatedAt: stamp}
|
||||
for i := range 5 {
|
||||
event := audit.NewEvent(audit.ActionLogin, audit.CategoryAuth)
|
||||
event.ID = uint64(i + 1)
|
||||
event.CreatedAt = stamp.Add(time.Duration(i) * time.Hour)
|
||||
event.Actor = audit.Actor{Type: audit.ActorUser, ID: idPointer(exportMartaID), Name: "Marta Alves"}
|
||||
event.Target = audit.Target{Type: audit.TargetUser, ID: "7", Name: "Marta Alves"}
|
||||
event.Source = audit.SourceWeb
|
||||
f.events.events = append(f.events.events, event)
|
||||
}
|
||||
}
|
||||
|
||||
func (f *exportFixture) seedInesContent() {
|
||||
stamp := time.Date(2026, time.May, 3, 8, 0, 0, 0, time.UTC)
|
||||
f.articles.articles[3] = article.Article{ID: 3, Title: "Ines private plan", Body: "Secret ines text", Slug: "ines-plan", SiteID: 1, CreatedBy: exportInesID, CreatedAt: stamp, UpdatedAt: stamp}
|
||||
f.entries.entries[2] = entry.Entry{ID: 2, ContentTypeID: 1, Data: map[string]any{"quote": "Ines quote"}, CreatedBy: exportInesID, UpdatedBy: exportInesID, CreatedAt: stamp, UpdatedAt: stamp}
|
||||
f.images.images[10] = image.Image{ID: 10, Path: "/static/uploadedImages/ines.png", Filename: "ines.png", Size: 10, ContentType: "image/png", UploadedByID: exportInesID, CreatedAt: stamp}
|
||||
f.keys.keys[2] = apikey.APIKey{ID: 2, UserID: exportInesID, Name: "ines key", Prefix: "rcms_zz99", HashedKey: "ines-hash", CreatedAt: stamp}
|
||||
|
||||
targeted := audit.NewEvent(audit.ActionUserChangeRole, audit.CategoryUsers)
|
||||
targeted.ID = 100
|
||||
targeted.CreatedAt = stamp
|
||||
targeted.Actor = audit.Actor{Type: audit.ActorUser, ID: idPointer(exportMartaID), Name: "Marta Alves"}
|
||||
targeted.Target = audit.Target{Type: audit.TargetUser, ID: "8", Name: "Ines Duarte"}
|
||||
targeted.Source = audit.SourceWeb
|
||||
|
||||
inesOwn := audit.NewEvent(audit.ActionLogin, audit.CategoryAuth)
|
||||
inesOwn.ID = 101
|
||||
inesOwn.Actor = audit.Actor{Type: audit.ActorUser, ID: idPointer(exportInesID), Name: "Ines Duarte"}
|
||||
inesOwn.Source = audit.SourceWeb
|
||||
|
||||
anonymous := audit.NewEvent(audit.ActionLoginFailed, audit.CategoryAuth)
|
||||
anonymous.ID = 102
|
||||
anonymous.Actor = audit.AnonymousActor("marta")
|
||||
anonymous.Outcome = audit.OutcomeFailed
|
||||
anonymous.Source = audit.SourceWeb
|
||||
|
||||
f.events.events = append(f.events.events, targeted, inesOwn, anonymous)
|
||||
}
|
||||
|
||||
func (f *exportFixture) export(t *testing.T, userID uint32) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodGet, exportRoute, nil)
|
||||
req.AddCookie(displayNameCookie(t, f.token, userID, exportTokenVer))
|
||||
rec := httptest.NewRecorder()
|
||||
f.router.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func openExportArchive(t *testing.T, rec *httptest.ResponseRecorder) map[string][]byte {
|
||||
t.Helper()
|
||||
body := rec.Body.Bytes()
|
||||
reader, err := zip.NewReader(bytes.NewReader(body), int64(len(body)))
|
||||
if err != nil {
|
||||
t.Fatalf("response is not a zip: %v", err)
|
||||
}
|
||||
files := map[string][]byte{}
|
||||
for _, file := range reader.File {
|
||||
opened, err := file.Open()
|
||||
if err != nil {
|
||||
t.Fatalf("open %s: %v", file.Name, err)
|
||||
}
|
||||
content, err := io.ReadAll(opened)
|
||||
_ = opened.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", file.Name, err)
|
||||
}
|
||||
files[file.Name] = content
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
func exportJSON(t *testing.T, files map[string][]byte, name string) map[string]any {
|
||||
t.Helper()
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal(files[name], &decoded); err != nil {
|
||||
t.Fatalf("%s is not a JSON object: %v", name, err)
|
||||
}
|
||||
return decoded
|
||||
}
|
||||
|
||||
func exportList(t *testing.T, decoded map[string]any, key string) []any {
|
||||
t.Helper()
|
||||
list, ok := decoded[key].([]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected a list under %q, got %T", key, decoded[key])
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
func collectKeys(value any, into *[]string) {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
for key, nested := range typed {
|
||||
*into = append(*into, key)
|
||||
collectKeys(nested, into)
|
||||
}
|
||||
case []any:
|
||||
for _, nested := range typed {
|
||||
collectKeys(nested, into)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertExportStatusAndBody(t *testing.T, rec *httptest.ResponseRecorder, status int, body string) {
|
||||
t.Helper()
|
||||
if rec.Code != status || strings.TrimSpace(rec.Body.String()) != body {
|
||||
t.Fatalf("expected %d %q, got %d %q", status, body, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountExportDataEndpointHTTP(t *testing.T) {
|
||||
t.Run("A first export returns the archive", func(t *testing.T) {
|
||||
f := newExportFixture(t)
|
||||
|
||||
rec := f.export(t, exportMartaID)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if got := rec.Header().Get("Content-Disposition"); got != `attachment; filename="renewcms-export-marta-2026-10-29.zip"` {
|
||||
t.Errorf("Content-Disposition %q", got)
|
||||
}
|
||||
if rec.Header().Get("Content-Type") != "application/zip" || rec.Header().Get("Cache-Control") != "no-store" {
|
||||
t.Errorf("headers %v", rec.Header())
|
||||
}
|
||||
if rec.Header().Get("Content-Length") != strconv.Itoa(rec.Body.Len()) {
|
||||
t.Errorf("Content-Length %q, body %d", rec.Header().Get("Content-Length"), rec.Body.Len())
|
||||
}
|
||||
files := openExportArchive(t, rec)
|
||||
names := []string{}
|
||||
for name := range files {
|
||||
names = append(names, name)
|
||||
}
|
||||
slices.Sort(names)
|
||||
if !slices.Equal(names, exportFiles) {
|
||||
t.Fatalf("files %v", names)
|
||||
}
|
||||
|
||||
profile := exportJSON(t, files, "profile.json")
|
||||
if profile["username"] != "marta" || profile["email"] != "marta@renew.example" || profile["pending_email"] != "marta.new@renew.example" {
|
||||
t.Errorf("profile %v", profile)
|
||||
}
|
||||
if roleBlock, _ := profile["role"].(map[string]any); roleBlock["name"] != role.WriterName {
|
||||
t.Errorf("role %v", profile["role"])
|
||||
}
|
||||
if profile["author"] != nil {
|
||||
t.Errorf("author should be null, got %v", profile["author"])
|
||||
}
|
||||
keys := exportList(t, profile, "api_keys")
|
||||
if len(keys) != 1 || keys[0].(map[string]any)["prefix"] != "rcms_ab12" || keys[0].(map[string]any)["name"] != "CI deploy" {
|
||||
t.Errorf("api keys %v", keys)
|
||||
}
|
||||
|
||||
authored := exportJSON(t, files, "authored-content.json")
|
||||
if len(exportList(t, authored, "articles")) != 2 || len(exportList(t, authored, "entries")) != 1 {
|
||||
t.Errorf("authored %v", authored)
|
||||
}
|
||||
entryBlock := exportList(t, authored, "entries")[0].(map[string]any)
|
||||
if entryBlock["content_type"] != "testimonial" {
|
||||
t.Errorf("entry %v", entryBlock)
|
||||
}
|
||||
|
||||
media := exportJSON(t, files, "uploaded-media.json")
|
||||
references := exportList(t, media, "images")
|
||||
if len(references) != 3 || references[0].(map[string]any)["mime"] != "image/png" {
|
||||
t.Errorf("media %v", media)
|
||||
}
|
||||
|
||||
activity := exportJSON(t, files, "account-activity.json")
|
||||
if got := len(exportList(t, activity, "events")); got != 5 {
|
||||
t.Errorf("expected 5 events, got %d", got)
|
||||
}
|
||||
|
||||
if len(f.recorder.events) != 1 {
|
||||
t.Fatalf("expected one audit event, got %+v", f.recorder.events)
|
||||
}
|
||||
recorded := f.recorder.events[0]
|
||||
if recorded.Action != "user.export_data" || recorded.Category != audit.CategoryUsers || recorded.Target.ID != "7" {
|
||||
t.Errorf("event %+v", recorded)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The archive never contains secrets and is pretty-printed", func(t *testing.T) {
|
||||
f := newExportFixture(t)
|
||||
|
||||
files := openExportArchive(t, f.export(t, exportMartaID))
|
||||
|
||||
forbiddenKeys := []string{"password", "token_version", "hashed_key", "verification_code", "password_reset_code", "email_change_code"}
|
||||
forbiddenText := []string{exportSecret, "reset-code-xyz", "verification-code-xyz", "email-change-code-xyz", "sha-of-the-key"}
|
||||
for name, content := range files {
|
||||
var decoded any
|
||||
if err := json.Unmarshal(content, &decoded); err != nil {
|
||||
t.Fatalf("%s: %v", name, err)
|
||||
}
|
||||
var keys []string
|
||||
collectKeys(decoded, &keys)
|
||||
for _, key := range keys {
|
||||
if slices.Contains(forbiddenKeys, key) || strings.Contains(key, "code") || strings.Contains(key, "token") {
|
||||
t.Errorf("%s exposes key %q", name, key)
|
||||
}
|
||||
}
|
||||
for _, text := range forbiddenText {
|
||||
if bytes.Contains(content, []byte(text)) {
|
||||
t.Errorf("%s contains %q", name, text)
|
||||
}
|
||||
}
|
||||
if !bytes.Contains(content, []byte("\n ")) {
|
||||
t.Errorf("%s is not pretty-printed", name)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A second export within 15 minutes is refused", func(t *testing.T) {
|
||||
f := newExportFixture(t)
|
||||
f.export(t, exportMartaID)
|
||||
f.clock.advance(3 * time.Minute)
|
||||
listsBefore := f.images.uploaderLists
|
||||
|
||||
rec := f.export(t, exportMartaID)
|
||||
|
||||
assertExportStatusAndBody(t, rec, http.StatusTooManyRequests, "Too many requests. Please try again later.")
|
||||
if rec.Header().Get("Retry-After") != "720" {
|
||||
t.Errorf("Retry-After %q", rec.Header().Get("Retry-After"))
|
||||
}
|
||||
if f.images.uploaderLists != listsBefore || len(f.recorder.events) != 1 {
|
||||
t.Errorf("an archive was built or an event recorded: lists %d events %d", f.images.uploaderLists, len(f.recorder.events))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The slot is per user and expires", func(t *testing.T) {
|
||||
f := newExportFixture(t)
|
||||
f.export(t, exportMartaID)
|
||||
f.clock.advance(15 * time.Minute)
|
||||
f.export(t, exportInesID)
|
||||
f.clock.advance(time.Minute)
|
||||
|
||||
marta := f.export(t, exportMartaID)
|
||||
ines := f.export(t, exportInesID)
|
||||
|
||||
if marta.Code != http.StatusOK {
|
||||
t.Errorf("marta expected 200, got %d", marta.Code)
|
||||
}
|
||||
if ines.Code != http.StatusTooManyRequests {
|
||||
t.Errorf("ines expected 429, got %d", ines.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A failed export does not consume the slot", func(t *testing.T) {
|
||||
f := newExportFixture(t)
|
||||
f.images.fail.failNext("ListByUploader")
|
||||
|
||||
failed := f.export(t, exportMartaID)
|
||||
|
||||
if failed.Code != http.StatusInternalServerError || strings.HasPrefix(failed.Header().Get("Content-Type"), "application/zip") || failed.Header().Get("Content-Disposition") != "" {
|
||||
t.Errorf("expected a plain 500, got %d %v", failed.Code, failed.Header())
|
||||
}
|
||||
if strings.Contains(failed.Body.String(), "PK") || len(f.recorder.events) != 0 {
|
||||
t.Errorf("body %q events %d", failed.Body.String(), len(f.recorder.events))
|
||||
}
|
||||
|
||||
retried := f.export(t, exportMartaID)
|
||||
|
||||
if retried.Code != http.StatusOK {
|
||||
t.Errorf("expected 200 after the failure is gone, got %d", retried.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Only a real verified session may export", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
status int
|
||||
text string
|
||||
build func(f *exportFixture) *http.Request
|
||||
}{
|
||||
{"nobody (no cookie)", http.StatusUnauthorized, "You must be logged in to access this resource.", func(f *exportFixture) *http.Request {
|
||||
return httptest.NewRequest(http.MethodGet, exportRoute, nil)
|
||||
}},
|
||||
{"an API key bearer", http.StatusForbidden, "This action needs a real session.", func(f *exportFixture) *http.Request {
|
||||
req := httptest.NewRequest(http.MethodGet, exportRoute, nil)
|
||||
req.Header.Set("Authorization", "Bearer rcms_anything")
|
||||
return req
|
||||
}},
|
||||
{"an unverified user", http.StatusForbidden, "You must verify your account to access this resource.", func(f *exportFixture) *http.Request {
|
||||
req := httptest.NewRequest(http.MethodGet, exportRoute, nil)
|
||||
req.AddCookie(displayNameCookie(t, f.token, exportUnverifID, 0))
|
||||
return req
|
||||
}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
f := newExportFixture(t)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
f.router.ServeHTTP(rec, c.build(f))
|
||||
|
||||
assertExportStatusAndBody(t, rec, c.status, c.text)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Someone else's data is never included", func(t *testing.T) {
|
||||
f := newExportFixture(t)
|
||||
|
||||
files := openExportArchive(t, f.export(t, exportMartaID))
|
||||
|
||||
for name, content := range files {
|
||||
lowered := strings.ToLower(string(content))
|
||||
for _, leak := range []string{"ines", "duarte", "secret ines"} {
|
||||
if strings.Contains(lowered, leak) {
|
||||
t.Errorf("%s mentions %q", name, leak)
|
||||
}
|
||||
}
|
||||
}
|
||||
activity := exportJSON(t, files, "account-activity.json")
|
||||
for _, raw := range exportList(t, activity, "events") {
|
||||
event := raw.(map[string]any)
|
||||
if event["action"] == "auth.login_failed" || event["action"] == "user.change_role" {
|
||||
t.Errorf("unexpected event %v", event)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A user with a linked author profile", func(t *testing.T) {
|
||||
f := newExportFixture(t)
|
||||
f.authors.seedLinked(1, "Marta Alves", "marta-alves", exportMartaID)
|
||||
f.articles.articles[4] = article.Article{ID: 4, Title: "Credited piece", Body: "Co-written", Slug: "credited", SiteID: 1, CreatedBy: exportInesID, IsOnline: true}
|
||||
f.articles.credit(4, 1)
|
||||
f.articles.credit(3)
|
||||
|
||||
files := openExportArchive(t, f.export(t, exportMartaID))
|
||||
|
||||
profile := exportJSON(t, files, "profile.json")
|
||||
authorBlock, _ := profile["author"].(map[string]any)
|
||||
if authorBlock["slug"] != "marta-alves" {
|
||||
t.Errorf("author %v", profile["author"])
|
||||
}
|
||||
articles := exportList(t, exportJSON(t, files, "authored-content.json"), "articles")
|
||||
ids := []float64{}
|
||||
for _, raw := range articles {
|
||||
ids = append(ids, raw.(map[string]any)["id"].(float64))
|
||||
}
|
||||
if !slices.Contains(ids, 4) || len(ids) != 3 {
|
||||
t.Errorf("article ids %v", ids)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (f *exportAuditFake) AnonymiseUser(userID uint32, _ *uint32, name string) error {
|
||||
for i, event := range f.events {
|
||||
if event.Actor.Type == audit.ActorUser && event.Actor.ID != nil && *event.Actor.ID == userID {
|
||||
f.events[i].Actor.Name = name
|
||||
f.events[i].IP = nil
|
||||
f.events[i].UserAgent = nil
|
||||
}
|
||||
if event.Target.Type == audit.TargetUser && event.Target.ID == strconv.FormatUint(uint64(userID), 10) {
|
||||
f.events[i].Target.Name = name
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/article"
|
||||
entity "RenewCMS/internal/infrastructure/persistence/models"
|
||||
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
|
||||
"net/http"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestAccountExportDataReadsContract(t *testing.T) {
|
||||
backends := []contractBackend{
|
||||
{name: "sqlite", open: openContractSQLite},
|
||||
{name: "mysql", open: openContractMySQL},
|
||||
{name: "postgres", open: openContractPostgres},
|
||||
}
|
||||
for _, backend := range backends {
|
||||
t.Run(backend.name, func(t *testing.T) {
|
||||
t.Run("articles are those created by the user, drafts included, or published ones crediting the user's author, without soft-deleted ones", func(t *testing.T) {
|
||||
db := backend.open(t)
|
||||
seedExportRows(t, db)
|
||||
articles := persistence.NewArticleRepository(db)
|
||||
authorID := uint32(5)
|
||||
|
||||
mine, err := articles.ListForAccount(7, nil)
|
||||
credited, creditedErr := articles.ListForAccount(7, &authorID)
|
||||
|
||||
if err != nil || creditedErr != nil {
|
||||
t.Fatalf("errors %v %v", err, creditedErr)
|
||||
}
|
||||
if got := articleIDs(mine); !slices.Equal(got, []uint32{1, 8}) {
|
||||
t.Errorf("created by marta %v", got)
|
||||
}
|
||||
if got := articleIDs(credited); !slices.Equal(got, []uint32{1, 3, 8}) {
|
||||
t.Errorf("created or credited %v", got)
|
||||
}
|
||||
})
|
||||
t.Run("entries are those created by the user", func(t *testing.T) {
|
||||
db := backend.open(t)
|
||||
seedExportRows(t, db)
|
||||
|
||||
entries, err := persistence.NewEntryRepository(db).ListByCreator(7)
|
||||
|
||||
if err != nil || len(entries) != 1 || entries[0].ID != 1 {
|
||||
t.Errorf("entries %+v %v", entries, err)
|
||||
}
|
||||
})
|
||||
t.Run("images are those uploaded by the user without soft-deleted ones", func(t *testing.T) {
|
||||
db := backend.open(t)
|
||||
seedExportRows(t, db)
|
||||
|
||||
images, err := persistence.NewImageRepository(db).ListByUploader(7)
|
||||
|
||||
if err != nil || len(images) != 1 || images[0].ID != 1 {
|
||||
t.Errorf("images %+v %v", images, err)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func articleIDs(listed []article.Article) []uint32 {
|
||||
ids := make([]uint32, 0, len(listed))
|
||||
for _, item := range listed {
|
||||
ids = append(ids, item.ID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func seedExportRows(t *testing.T, db *gorm.DB) {
|
||||
t.Helper()
|
||||
rows := []any{
|
||||
&entity.Article{ID: 1, Title: "Mine", Slug: "mine", CreatedBy: 7},
|
||||
&entity.Article{ID: 2, Title: "Ines", Slug: "ines", CreatedBy: 8},
|
||||
&entity.Article{ID: 3, Title: "Credited", Slug: "credited", CreatedBy: 8, IsOnline: true},
|
||||
&entity.Article{ID: 7, Title: "Credited draft", Slug: "credited-draft", CreatedBy: 8},
|
||||
&entity.Article{ID: 8, Title: "Mine draft", Slug: "mine-draft", CreatedBy: 7},
|
||||
&entity.Article{ID: 4, Title: "Gone", Slug: "gone", CreatedBy: 7},
|
||||
&entity.Article{ID: 6, Title: "Credited gone", Slug: "credited-gone", CreatedBy: 8, IsOnline: true},
|
||||
&entity.ArticleAuthor{ArticleID: 3, AuthorID: 5},
|
||||
&entity.ArticleAuthor{ArticleID: 7, AuthorID: 5},
|
||||
&entity.ArticleAuthor{ArticleID: 6, AuthorID: 5},
|
||||
&entity.Entry{ID: 1, ContentTypeID: 1, Data: "{}", CreatedBy: 7, UpdatedBy: 7},
|
||||
&entity.Entry{ID: 2, ContentTypeID: 1, Data: "{}", CreatedBy: 8, UpdatedBy: 8},
|
||||
&entity.Image{ID: 1, Path: "/a.png", Filename: "a.png", UploadedByID: 7},
|
||||
&entity.Image{ID: 2, Path: "/b.png", Filename: "b.png", UploadedByID: 8},
|
||||
&entity.Image{ID: 3, Path: "/c.png", Filename: "c.png", UploadedByID: 7},
|
||||
}
|
||||
for _, row := range rows {
|
||||
if err := db.Create(row).Error; err != nil {
|
||||
t.Fatalf("seed %T: %v", row, err)
|
||||
}
|
||||
}
|
||||
for _, id := range []uint32{4, 6} {
|
||||
if err := db.Delete(&entity.Article{}, id).Error; err != nil {
|
||||
t.Fatalf("soft delete article %d: %v", id, err)
|
||||
}
|
||||
}
|
||||
if err := db.Delete(&entity.Image{}, 3).Error; err != nil {
|
||||
t.Fatalf("soft delete image: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountExportDataFilenameHTTP(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"Marta.Alves_2": "renewcms-export-marta-alves-2-2026-10-29.zip",
|
||||
"ÉLODIE": "renewcms-export-lodie-2026-10-29.zip",
|
||||
"***": "renewcms-export-user-2026-10-29.zip",
|
||||
"az09": "renewcms-export-az09-2026-10-29.zip",
|
||||
}
|
||||
for username, filename := range cases {
|
||||
t.Run(username, func(t *testing.T) {
|
||||
f := newExportFixture(t)
|
||||
stored := f.users.users[exportMartaID]
|
||||
stored.Username = username
|
||||
f.users.users[exportMartaID] = stored
|
||||
|
||||
rec := f.export(t, exportMartaID)
|
||||
|
||||
expected := `attachment; filename="` + filename + `"`
|
||||
if rec.Code != http.StatusOK || rec.Header().Get("Content-Disposition") != expected {
|
||||
t.Errorf("got %d %q, want %q", rec.Code, rec.Header().Get("Content-Disposition"), expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/domain/author"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func exportedArticleIDs(t *testing.T, f *exportFixture) []float64 {
|
||||
t.Helper()
|
||||
files := openExportArchive(t, f.export(t, exportMartaID))
|
||||
ids := []float64{}
|
||||
for _, raw := range exportList(t, exportJSON(t, files, "authored-content.json"), "articles") {
|
||||
ids = append(ids, raw.(map[string]any)["id"].(float64))
|
||||
}
|
||||
slices.Sort(ids)
|
||||
return ids
|
||||
}
|
||||
|
||||
func TestAccountExportDataArticleScope(t *testing.T) {
|
||||
t.Run("a draft by someone else credited to the user is excluded", func(t *testing.T) {
|
||||
f := newExportFixture(t)
|
||||
f.authors.seedLinked(1, "Marta Alves", "marta-alves", exportMartaID)
|
||||
f.articles.articles[4] = article.Article{ID: 4, Title: "Ines draft", CreatedBy: exportInesID}
|
||||
f.articles.credit(4, 1)
|
||||
|
||||
if ids := exportedArticleIDs(t, f); !slices.Equal(ids, []float64{1, 2}) {
|
||||
t.Errorf("article ids %v", ids)
|
||||
}
|
||||
})
|
||||
t.Run("a published article by someone else credited to the user is included", func(t *testing.T) {
|
||||
f := newExportFixture(t)
|
||||
f.authors.seedLinked(1, "Marta Alves", "marta-alves", exportMartaID)
|
||||
f.articles.articles[4] = article.Article{ID: 4, Title: "Ines published", CreatedBy: exportInesID, IsOnline: true}
|
||||
f.articles.credit(4, 1)
|
||||
|
||||
if ids := exportedArticleIDs(t, f); !slices.Equal(ids, []float64{1, 2, 4}) {
|
||||
t.Errorf("article ids %v", ids)
|
||||
}
|
||||
})
|
||||
t.Run("the user's own draft is included", func(t *testing.T) {
|
||||
f := newExportFixture(t)
|
||||
|
||||
if ids := exportedArticleIDs(t, f); !slices.Equal(ids, []float64{1, 2}) || f.articles.articles[2].IsOnline {
|
||||
t.Errorf("article ids %v", ids)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestAccountExportDataCoAuthorsAreReduced(t *testing.T) {
|
||||
f := newExportFixture(t)
|
||||
f.authors.seedLinked(1, "Marta Alves", "marta-alves", exportMartaID)
|
||||
coAuthor := f.authors.seed(2, "Dario Costa", "dario-costa")
|
||||
coAuthor.Bio = "Plasterer from Porto"
|
||||
coAuthor.Website = new(string)
|
||||
coAuthor.AvatarID = idPointer(9)
|
||||
coAuthor.SocialLinks = []author.SocialLink{{URL: "https://example.com/dario"}}
|
||||
f.authors.authors[2] = coAuthor
|
||||
f.articles.credit(1, 1, 2)
|
||||
|
||||
files := openExportArchive(t, f.export(t, exportMartaID))
|
||||
|
||||
var keys []string
|
||||
collectKeys(exportJSON(t, files, "authored-content.json"), &keys)
|
||||
for _, key := range keys {
|
||||
if slices.Contains([]string{"bio", "website", "social_links", "avatar_id"}, key) {
|
||||
t.Errorf("authored-content.json exposes %q", key)
|
||||
}
|
||||
}
|
||||
first := exportList(t, exportJSON(t, files, "authored-content.json"), "articles")[0].(map[string]any)
|
||||
credits := first["authors"].([]any)
|
||||
if len(credits) != 2 || credits[1].(map[string]any)["name"] != "Dario Costa" || credits[1].(map[string]any)["slug"] != "dario-costa" {
|
||||
t.Errorf("credits %v", credits)
|
||||
}
|
||||
profile := exportJSON(t, files, "profile.json")["author"].(map[string]any)
|
||||
if _, ok := profile["social_links"]; !ok {
|
||||
t.Errorf("profile.json author must stay complete: %v", profile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventConcernsOtherPerson(t *testing.T) {
|
||||
ownAuthor := idPointer(5)
|
||||
targeted := func(kind string, id uint32) audit.Event {
|
||||
event := audit.NewEvent("x", audit.CategoryUsers)
|
||||
event.Target = audit.Target{Type: kind, ID: strconv.Itoa(int(id))}
|
||||
return event
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
event audit.Event
|
||||
own *uint32
|
||||
want bool
|
||||
}{
|
||||
{"own author", targeted(audit.TargetAuthor, 5), ownAuthor, false},
|
||||
{"another author", targeted(audit.TargetAuthor, 6), ownAuthor, true},
|
||||
{"an author when the user has none", targeted(audit.TargetAuthor, 5), nil, true},
|
||||
{"an author target without an id", audit.Event{Target: audit.Target{Type: audit.TargetAuthor, Name: "Dario"}}, ownAuthor, true},
|
||||
{"the user themself", targeted(audit.TargetUser, 7), ownAuthor, false},
|
||||
{"another user", targeted(audit.TargetUser, 8), ownAuthor, true},
|
||||
{"an invite", targeted(audit.TargetInvite, 3), ownAuthor, true},
|
||||
{"an article", targeted(audit.TargetArticle, 3), ownAuthor, false},
|
||||
{"no target", audit.Event{}, nil, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := tc.event.ConcernsOtherPerson(7, tc.own); got != tc.want {
|
||||
t.Errorf("got %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountExportDataDropsEventsOnOtherAuthors(t *testing.T) {
|
||||
f := newExportFixture(t)
|
||||
f.authors.seedLinked(1, "Marta Alves", "marta-alves", exportMartaID)
|
||||
own := audit.NewEvent(audit.ActionAuthorEdit, audit.CategoryAuthors)
|
||||
own.ID, own.Actor = 200, audit.Actor{Type: audit.ActorUser, ID: idPointer(exportMartaID)}
|
||||
own.Target = audit.NewTarget(audit.TargetAuthor, 1, "Marta Alves")
|
||||
other := own
|
||||
other.ID = 201
|
||||
other.Target = audit.NewTarget(audit.TargetAuthor, 2, "Dario Costa")
|
||||
f.events.events = append(f.events.events, own, other)
|
||||
|
||||
files := openExportArchive(t, f.export(t, exportMartaID))
|
||||
|
||||
ids := []float64{}
|
||||
for _, raw := range exportList(t, exportJSON(t, files, "account-activity.json"), "events") {
|
||||
ids = append(ids, raw.(map[string]any)["id"].(float64))
|
||||
}
|
||||
if !slices.Contains(ids, 200) || slices.Contains(ids, 201) {
|
||||
t.Errorf("event ids %v", ids)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountExportDataEncodesStraightIntoTheArchive(t *testing.T) {
|
||||
f := newExportFixture(t)
|
||||
|
||||
files := openExportArchive(t, f.export(t, exportMartaID))
|
||||
|
||||
for name, content := range files {
|
||||
if !bytes.HasSuffix(content, []byte("}\n")) || bytes.HasSuffix(content, []byte("\n\n")) || !bytes.HasPrefix(content, []byte("{\n \"")) {
|
||||
t.Errorf("%s is not indented JSON ending with a single newline", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountExportDataConcurrencyCap(t *testing.T) {
|
||||
newBlocked := func(t *testing.T) (*exportFixture, chan struct{}, chan struct{}) {
|
||||
f := newExportFixtureWithCap(t, 1)
|
||||
entered := make(chan struct{}, 1)
|
||||
release := make(chan struct{})
|
||||
once := false
|
||||
f.images.onListByUploader = func() {
|
||||
if once {
|
||||
return
|
||||
}
|
||||
once = true
|
||||
entered <- struct{}{}
|
||||
<-release
|
||||
}
|
||||
return f, entered, release
|
||||
}
|
||||
t.Run("a build waits for a free slot and gives up when its request ends", func(t *testing.T) {
|
||||
f, entered, release := newBlocked(t)
|
||||
firstDone := make(chan *httptest.ResponseRecorder)
|
||||
go func() { firstDone <- f.export(t, exportMartaID) }()
|
||||
<-entered
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond)
|
||||
defer cancel()
|
||||
req := httptest.NewRequestWithContext(ctx, http.MethodGet, exportRoute, nil)
|
||||
req.AddCookie(displayNameCookie(t, f.token, exportInesID, exportTokenVer))
|
||||
waiting := httptest.NewRecorder()
|
||||
|
||||
f.router.ServeHTTP(waiting, req)
|
||||
|
||||
if waiting.Code != http.StatusServiceUnavailable {
|
||||
t.Errorf("waiting build = %d, want 503", waiting.Code)
|
||||
}
|
||||
close(release)
|
||||
if first := <-firstDone; first.Code != http.StatusOK {
|
||||
t.Errorf("first build = %d", first.Code)
|
||||
}
|
||||
if len(f.recorder.events) != 1 {
|
||||
t.Errorf("events %d, want only the first build's", len(f.recorder.events))
|
||||
}
|
||||
if retried := f.export(t, exportInesID); retried.Code != http.StatusOK {
|
||||
t.Errorf("refused user must keep the slot free, got %d", retried.Code)
|
||||
}
|
||||
})
|
||||
t.Run("the slot is released when a build fails", func(t *testing.T) {
|
||||
f := newExportFixtureWithCap(t, 1)
|
||||
f.images.fail.failNext("ListByUploader")
|
||||
|
||||
failed := f.export(t, exportMartaID)
|
||||
next := f.export(t, exportMartaID)
|
||||
|
||||
if failed.Code != http.StatusInternalServerError || next.Code != http.StatusOK {
|
||||
t.Errorf("failed %d then %d", failed.Code, next.Code)
|
||||
}
|
||||
})
|
||||
t.Run("the use case returns the context error", func(t *testing.T) {
|
||||
f, entered, release := newBlocked(t)
|
||||
usecase := useCases.NewExportUserDataUseCase(f.users, f.roles, f.authors, f.keys, f.articles, f.entries, f.types, f.images, f.events, f.clock.now, f.recorder, 1)
|
||||
f.images.onListByUploader = func() { entered <- struct{}{}; <-release }
|
||||
done := make(chan error)
|
||||
go func() { _, err := usecase.Export(context.Background(), exportMartaID); done <- err }()
|
||||
<-entered
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
_, err := usecase.Export(ctx, exportInesID)
|
||||
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Errorf("err %v", err)
|
||||
}
|
||||
close(release)
|
||||
if err := <-done; err != nil {
|
||||
t.Errorf("first build %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -69,14 +69,14 @@ func TestActivityDomain(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("The repository exposes no way to edit or delete one event", func(t *testing.T) {
|
||||
t.Run("The repository exposes no way to edit or delete one event, only to anonymise a user's events", func(t *testing.T) {
|
||||
repoType := reflect.TypeOf((*audit.Repository)(nil)).Elem()
|
||||
var names []string
|
||||
for i := 0; i < repoType.NumMethod(); i++ {
|
||||
names = append(names, repoType.Method(i).Name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
wanted := []string{"Append", "DeleteOlderThan", "DeleteOldest", "List", "SizeBytes", "Summary"}
|
||||
wanted := []string{"AnonymiseUser", "Append", "DeleteOlderThan", "DeleteOldest", "List", "SizeBytes", "Summary"}
|
||||
if !reflect.DeepEqual(names, wanted) {
|
||||
t.Errorf("expected methods %v, got %v", wanted, names)
|
||||
}
|
||||
|
||||
@@ -94,7 +94,7 @@ func TestActivityPersistence(t *testing.T) {
|
||||
}
|
||||
found[strings.Join(parts, ",")] = true
|
||||
}
|
||||
for _, wanted := range []string{"created_at", "actor_type,actor_id,created_at", "category,created_at"} {
|
||||
for _, wanted := range []string{"created_at", "actor_type,actor_id,created_at", "category,created_at", "target_type,target_id"} {
|
||||
if !found[wanted] {
|
||||
t.Errorf("missing index on (%s), have %v", wanted, found)
|
||||
}
|
||||
@@ -108,7 +108,7 @@ func TestActivityPersistence(t *testing.T) {
|
||||
names = append(names, repoType.Method(i).Name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
wanted := []string{"Append", "DeleteOlderThan", "DeleteOldest", "List", "SizeBytes", "Summary"}
|
||||
wanted := []string{"AnonymiseUser", "Append", "DeleteOlderThan", "DeleteOldest", "List", "SizeBytes", "Summary"}
|
||||
if !reflect.DeepEqual(names, wanted) {
|
||||
t.Errorf("expected %v, got %v", wanted, names)
|
||||
}
|
||||
|
||||
@@ -621,3 +621,5 @@ func mustMarshalChanges(t *testing.T, event audit.Event) []byte {
|
||||
}
|
||||
return encoded
|
||||
}
|
||||
|
||||
func (f *sizedEventsFake) AnonymiseUser(uint32, *uint32, string) error { return nil }
|
||||
@@ -185,3 +185,7 @@ func retentionDays(days int) useCases.UpdateActivityRetentionCommand {
|
||||
func retentionMaxGB(maxGB int) useCases.UpdateActivityRetentionCommand {
|
||||
return useCases.UpdateActivityRetentionCommand{MaxGB: &maxGB}
|
||||
}
|
||||
|
||||
func (p *activityProbeRepo) AnonymiseUser(userID uint32, ownAuthorID *uint32, name string) error {
|
||||
return p.inner.AnonymiseUser(userID, ownAuthorID, name)
|
||||
}
|
||||
@@ -50,9 +50,15 @@ func routerRoutes(t *testing.T) map[string]bool {
|
||||
}
|
||||
|
||||
var routesNotYetInTheCatalogue = []string{
|
||||
"DELETE /v1/auth/email/change",
|
||||
"POST /v1/auth/email/change",
|
||||
"POST /v1/auth/email/confirm",
|
||||
"POST /v1/auth/email/resend",
|
||||
"DELETE /v1/role/{id}",
|
||||
"DELETE /v1/user/{id}",
|
||||
"DELETE /v1/user/me",
|
||||
"GET /v1/user/lookup",
|
||||
"GET /v1/user/me/export",
|
||||
"GET /v1/permission",
|
||||
"GET /v1/role",
|
||||
"GET /v1/role/{id}",
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"RenewCMS/internal/domain/author"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"errors"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -52,6 +53,20 @@ func (m *mockArticleRepo) withAuthors(a article.Article) article.Article {
|
||||
return a
|
||||
}
|
||||
|
||||
func (m *mockArticleRepo) ListForAccount(creatorID uint32, authorID *uint32) ([]article.Article, error) {
|
||||
if err := m.fail.trigger("ListForAccount"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
listed := []article.Article{}
|
||||
for _, id := range m.sortedIds() {
|
||||
stored := m.articles[id]
|
||||
if stored.CreatedBy == creatorID || stored.IsOnline && authorID != nil && slices.Contains(m.credits[id], *authorID) {
|
||||
listed = append(listed, m.withAuthors(stored))
|
||||
}
|
||||
}
|
||||
return listed, nil
|
||||
}
|
||||
|
||||
func (m *mockArticleRepo) credit(articleID uint32, authorIDs ...uint32) {
|
||||
m.credits[articleID] = append([]uint32{}, authorIDs...)
|
||||
}
|
||||
|
||||
@@ -112,6 +112,7 @@ type mockEntryRepo struct {
|
||||
failUpdateOn int
|
||||
updatedIDs []uint32
|
||||
afterListPage func()
|
||||
fail failSwitch
|
||||
}
|
||||
|
||||
var _ entry.Repository = &mockEntryRepo{}
|
||||
@@ -183,6 +184,20 @@ func sortEntries(items []entry.Entry, order entry.Sort) {
|
||||
})
|
||||
}
|
||||
|
||||
func (m *mockEntryRepo) ListByCreator(creatorID uint32) ([]entry.Entry, error) {
|
||||
if err := m.fail.trigger("ListByCreator"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
listed := []entry.Entry{}
|
||||
for _, stored := range m.entries {
|
||||
if stored.CreatedBy == creatorID {
|
||||
listed = append(listed, stored)
|
||||
}
|
||||
}
|
||||
sort.Slice(listed, func(i, j int) bool { return listed[i].ID < listed[j].ID })
|
||||
return listed, nil
|
||||
}
|
||||
|
||||
func (m *mockEntryRepo) Update(record entry.Entry) (entry.Entry, error) {
|
||||
m.updateCalls++
|
||||
m.updatedIDs = append(m.updatedIDs, record.ID)
|
||||
|
||||
@@ -92,6 +92,27 @@ func TestGetCurrentUserHttp(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The profile carries pending_email, null when nothing is pending", func(t *testing.T) {
|
||||
token := authtoken.New([]byte("test-secret"))
|
||||
userRepo := newMockUserRepo()
|
||||
userRepo.users[7] = user.User{ID: 7, Username: "marta", Email: "marta@renew.example", IsVerified: true, RoleID: ownerRoleID}
|
||||
userRepo.users[8] = user.User{ID: 8, Username: "ines", Email: "ines@renew.example", IsVerified: true, RoleID: ownerRoleID, PendingEmail: "new@renew.example"}
|
||||
router := newGetCurrentUserRouter(userRepo, &stubHasher{}, token)
|
||||
|
||||
none := sendGetCurrentUserRequest(router, http.MethodGet, "/v1/auth/me", mintGetCurrentUserCookie(t, token, 7, 0))
|
||||
pending := sendGetCurrentUserRequest(router, http.MethodGet, "/v1/auth/me", mintGetCurrentUserCookie(t, token, 8, 0))
|
||||
|
||||
var noneBody, pendingBody map[string]any
|
||||
_ = json.Unmarshal(none.Body.Bytes(), &noneBody)
|
||||
_ = json.Unmarshal(pending.Body.Bytes(), &pendingBody)
|
||||
if value, present := noneBody["pending_email"]; !present || value != nil {
|
||||
t.Errorf("expected pending_email null, got %v (present %v)", value, present)
|
||||
}
|
||||
if pendingBody["pending_email"] != "new@renew.example" {
|
||||
t.Errorf("expected the pending address, got %v", pendingBody["pending_email"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("An unverified user still fetches their own profile, with is_verified false, not a 403", func(t *testing.T) {
|
||||
token := authtoken.New([]byte("test-secret"))
|
||||
userRepo := newMockUserRepo()
|
||||
|
||||
@@ -83,6 +83,8 @@ type mockImageRepo struct {
|
||||
onDelete func(id uint32)
|
||||
|
||||
storedNameLookups int
|
||||
uploaderLists int
|
||||
onListByUploader func()
|
||||
}
|
||||
|
||||
func newMockImageRepo() *mockImageRepo {
|
||||
@@ -114,6 +116,23 @@ func (m *mockImageRepo) Create(input imageDomain.Image) (imageDomain.Image, erro
|
||||
return img, nil
|
||||
}
|
||||
|
||||
func (m *mockImageRepo) ListByUploader(userID uint32) ([]imageDomain.Image, error) {
|
||||
m.uploaderLists++
|
||||
if m.onListByUploader != nil {
|
||||
m.onListByUploader()
|
||||
}
|
||||
if err := m.fail.trigger("ListByUploader"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
listed := []imageDomain.Image{}
|
||||
for _, img := range m.sortedImages() {
|
||||
if img.UploadedByID == userID {
|
||||
listed = append(listed, img)
|
||||
}
|
||||
}
|
||||
return listed, nil
|
||||
}
|
||||
|
||||
func (m *mockImageRepo) ListMissingMetadata() ([]imageDomain.Image, error) {
|
||||
if err := m.fail.trigger("ListMissingMetadata"); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -197,6 +197,12 @@ func TestSQLiteSchemaIsUntouchedByTheModelChanges(t *testing.T) {
|
||||
t.Errorf("a legacy user must read a NULL role_assigned_by_id: %v", row)
|
||||
}
|
||||
delete(row, "role_assigned_by_id")
|
||||
for _, column := range addedUserEmailChangeColumns {
|
||||
if value, added := row[column]; !added || (value != nil && column != "email_change_expiration") {
|
||||
t.Errorf("a legacy user must read an empty %s: %v", column, row)
|
||||
}
|
||||
delete(row, column)
|
||||
}
|
||||
}
|
||||
for _, row := range after.rows["invites"] {
|
||||
if row["role_id"] == nil {
|
||||
@@ -210,6 +216,8 @@ func TestSQLiteSchemaIsUntouchedByTheModelChanges(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
var addedUserEmailChangeColumns = []string{"pending_email", "email_change_code", "email_change_expiration"}
|
||||
|
||||
var addedArticleColumns = []string{"site_id", "created_by", "slug", "excerpt", "seo_title", "seo_description", "cover_image_id", "cover_image_alt", "social_title", "social_description", "social_image_id", "canonical_url", "noindex", "seo_dismissed"}
|
||||
|
||||
func assertSiteColumnAdded(t *testing.T, db *gorm.DB) {
|
||||
|
||||
@@ -61,6 +61,62 @@ func TestTransactionalEmailTemplates(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("render() produces the verify-new-email email's subject and link", func(t *testing.T) {
|
||||
repo := mailer.NewMailRepository(config.SmtpSettings{})
|
||||
|
||||
subject, body, err := repo.Render("verify-new-email", useCases.MailVerifyNewEmailData{
|
||||
Host: "https://cms.example.com",
|
||||
Username: "marta",
|
||||
VerificationCode: "abc",
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if subject != "Confirm your new RenewCMS email address" {
|
||||
t.Errorf("unexpected subject %q", subject)
|
||||
}
|
||||
if !strings.Contains(body, "https://cms.example.com/account/email/confirm?c=abc") || strings.Contains(body, "{{") {
|
||||
t.Errorf("expected the confirmation link and no unreplaced action, got %q", body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("render() produces the email-change-notice email's subject, new address and profile link", func(t *testing.T) {
|
||||
repo := mailer.NewMailRepository(config.SmtpSettings{})
|
||||
|
||||
subject, body, err := repo.Render("email-change-notice", useCases.MailEmailChangeNoticeData{
|
||||
Host: "https://cms.example.com",
|
||||
Username: "marta",
|
||||
NewEmail: "new@renew.example",
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if subject != "Your RenewCMS email address change was requested" {
|
||||
t.Errorf("unexpected subject %q", subject)
|
||||
}
|
||||
if !strings.Contains(body, "new@renew.example") || !strings.Contains(body, "https://cms.example.com/profile") || strings.Contains(body, "{{") {
|
||||
t.Errorf("expected the new address, the profile link and no unreplaced action, got %q", body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("render() produces the account-deleted email without personal data", func(t *testing.T) {
|
||||
repo := mailer.NewMailRepository(config.SmtpSettings{})
|
||||
|
||||
subject, body, err := repo.Render("account-deleted", useCases.MailAccountDeletedData{Host: "https://cms.example.com"})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if subject != "Your RenewCMS account has been deleted" {
|
||||
t.Errorf("unexpected subject %q", subject)
|
||||
}
|
||||
if !strings.Contains(body, "https://cms.example.com") || strings.Contains(body, "{{") || strings.Contains(body, "@renew") || strings.Contains(body, "<button") || strings.Contains(body, "<a ") {
|
||||
t.Errorf("expected the host only, no button, no address and no unreplaced action, got %q", body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("render() falls back to the old default subject for an unrecognized template name", func(t *testing.T) {
|
||||
repo := mailer.NewMailRepository(config.SmtpSettings{})
|
||||
|
||||
|
||||
@@ -124,7 +124,7 @@ func assertSevenKeys(t *testing.T, body []byte) map[string]json.RawMessage {
|
||||
func assertMeKeys(t *testing.T, body []byte) map[string]json.RawMessage {
|
||||
t.Helper()
|
||||
decoded := decodeKeys(t, body)
|
||||
expected := append(append([]string{}, displayNameResponseKeys...), "author", "access")
|
||||
expected := append(append([]string{}, displayNameResponseKeys...), "pending_email", "author", "access")
|
||||
if len(decoded) != len(expected) {
|
||||
t.Errorf("expected exactly %d keys, got %d: %s", len(expected), len(decoded), string(body))
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"errors"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -58,6 +60,7 @@ type mockUserRepo struct {
|
||||
beforeTransfer func()
|
||||
beforeClearVerification func()
|
||||
knownRoles *mockRoleRepo
|
||||
confirmLock sync.Mutex
|
||||
}
|
||||
|
||||
func newMockUserRepo() *mockUserRepo {
|
||||
@@ -80,6 +83,8 @@ func (m *mockUserRepo) sortedIds() []uint32 {
|
||||
}
|
||||
|
||||
func (m *mockUserRepo) Get(id uint32) (user.User, error) {
|
||||
m.confirmLock.Lock()
|
||||
defer m.confirmLock.Unlock()
|
||||
m.getCalls++
|
||||
if err := m.fail.trigger("Get"); err != nil {
|
||||
return user.User{}, err
|
||||
@@ -117,6 +122,81 @@ func (m *mockUserRepo) GetByEmail(email string) (user.User, error) {
|
||||
return user.User{}, user.ErrUserNotFound
|
||||
}
|
||||
|
||||
func (m *mockUserRepo) IsEmailInUse(email string, exceptUserId uint32) (bool, error) {
|
||||
m.confirmLock.Lock()
|
||||
defer m.confirmLock.Unlock()
|
||||
return m.emailInUse(email, exceptUserId)
|
||||
}
|
||||
|
||||
func (m *mockUserRepo) emailInUse(email string, exceptUserId uint32) (bool, error) {
|
||||
if err := m.fail.trigger("IsEmailInUse"); err != nil {
|
||||
return false, err
|
||||
}
|
||||
for id, stored := range m.users {
|
||||
if id != exceptUserId && strings.EqualFold(stored.Email, email) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (m *mockUserRepo) SetPendingEmailChange(userId uint32, pendingEmail string, code string, expiration time.Time) (user.User, error) {
|
||||
if err := m.fail.trigger("SetPendingEmailChange"); err != nil {
|
||||
return user.User{}, err
|
||||
}
|
||||
stored, ok := m.users[userId]
|
||||
if !ok {
|
||||
return user.User{}, user.ErrUserNotFound
|
||||
}
|
||||
stored.PendingEmail = pendingEmail
|
||||
stored.EmailChangeCode = code
|
||||
stored.EmailChangeExpiration = expiration
|
||||
m.users[userId] = stored
|
||||
return stored, nil
|
||||
}
|
||||
|
||||
func (m *mockUserRepo) ClearPendingEmailChange(userId uint32) (user.User, error) {
|
||||
if err := m.fail.trigger("ClearPendingEmailChange"); err != nil {
|
||||
return user.User{}, err
|
||||
}
|
||||
stored, ok := m.users[userId]
|
||||
if !ok {
|
||||
return user.User{}, user.ErrUserNotFound
|
||||
}
|
||||
stored.PendingEmail = ""
|
||||
stored.EmailChangeCode = ""
|
||||
stored.EmailChangeExpiration = time.Time{}
|
||||
m.users[userId] = stored
|
||||
return stored, nil
|
||||
}
|
||||
|
||||
func (m *mockUserRepo) ConfirmEmailChange(userId uint32, pendingEmail string, code string, now time.Time) (user.User, error) {
|
||||
m.confirmLock.Lock()
|
||||
defer m.confirmLock.Unlock()
|
||||
if err := m.fail.trigger("ConfirmEmailChange"); err != nil {
|
||||
return user.User{}, err
|
||||
}
|
||||
stored, ok := m.users[userId]
|
||||
if !ok {
|
||||
return user.User{}, user.ErrUserNotFound
|
||||
}
|
||||
if stored.PendingEmail != pendingEmail || stored.EmailChangeCode != code || !now.Before(stored.EmailChangeExpiration) {
|
||||
return user.User{}, user.ErrInvalidEmailChangeCode
|
||||
}
|
||||
taken, _ := m.emailInUse(pendingEmail, userId)
|
||||
if !taken {
|
||||
stored.Email = pendingEmail
|
||||
}
|
||||
stored.PendingEmail = ""
|
||||
stored.EmailChangeCode = ""
|
||||
stored.EmailChangeExpiration = time.Time{}
|
||||
m.users[userId] = stored
|
||||
if taken {
|
||||
return user.User{}, user.ErrEmailUnavailable
|
||||
}
|
||||
return stored, nil
|
||||
}
|
||||
|
||||
func (m *mockUserRepo) GetAll() ([]user.User, error) {
|
||||
m.getAllCalls++
|
||||
if err := m.fail.trigger("GetAll"); err != nil {
|
||||
|
||||
@@ -606,7 +606,7 @@ func TestUsersAPIPublicUserShape(t *testing.T) {
|
||||
w := newUsersAPIWorld(t)
|
||||
rec := w.do(t, uaAdmin, http.MethodGet, "/v1/auth/me", "")
|
||||
body := usersAPIDecode(t, rec)
|
||||
apiKeyAssertExactFields(t, body, "id", "username", "email", "is_verified", "created_at", "updated_at", "display_name", "author", "access")
|
||||
apiKeyAssertExactFields(t, body, "id", "username", "email", "is_verified", "created_at", "updated_at", "display_name", "pending_email", "author", "access")
|
||||
if usersAPIRoleName(t, rec) != "Admin" {
|
||||
t.Errorf("access.role must stay")
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -7,7 +7,7 @@ defineProps<{
|
||||
|
||||
<template>
|
||||
<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">
|
||||
<h2 class="text-base/5 font-semibold text-highlighted">
|
||||
{{ title }}
|
||||
@@ -18,7 +18,7 @@ defineProps<{
|
||||
</div>
|
||||
<div
|
||||
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" />
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
@@ -82,14 +82,3 @@ export function useActivity() {
|
||||
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,7 +2,8 @@ import { computed, onBeforeUnmount, ref, toValue, type MaybeRefOrGetter } from '
|
||||
import { t } from '@/i18n'
|
||||
import { useLocalizedToast } from '@/i18n/presentation'
|
||||
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 {
|
||||
allActivityFileName,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { ref } from 'vue'
|
||||
import { accountDeletedRedirect } from '@/lib/accountDeletion'
|
||||
import router from '@/router'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { ApiError, errorCodeOf, knownErrorCode } from '@/lib/apiError'
|
||||
import { filenameFromDisposition } from '@/lib/contentDisposition'
|
||||
import { missingPermissionCopy } from '@/lib/permissionCopy'
|
||||
import { useLocalizedToast } from '@/i18n/presentation'
|
||||
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> {
|
||||
body: T
|
||||
total: number
|
||||
@@ -36,6 +43,7 @@ export interface MeResult {
|
||||
verified: boolean
|
||||
username?: string
|
||||
email?: string
|
||||
pendingEmail?: string
|
||||
displayName: string
|
||||
access?: Access
|
||||
author?: MeAuthor
|
||||
@@ -98,6 +106,9 @@ if (typeof window !== 'undefined') {
|
||||
}
|
||||
|
||||
export function redirectToLogin() {
|
||||
if (authRedirectPending.value || accountDeletedRedirect.value) {
|
||||
return
|
||||
}
|
||||
const currentPath = router.currentRoute.value.fullPath
|
||||
useSessionStore().clear()
|
||||
authRedirectPending.value = true
|
||||
@@ -195,6 +206,7 @@ export function useApi() {
|
||||
const body = await readErrorBody(response)
|
||||
throw new ApiError(response.status, undefined, body, {
|
||||
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()
|
||||
}
|
||||
|
||||
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> {
|
||||
const body = await request<{
|
||||
id?: number
|
||||
is_verified: boolean
|
||||
username?: string
|
||||
email?: string
|
||||
pending_email?: string | null
|
||||
display_name?: string | null
|
||||
access?: { role: RoleRef; permissions: Grant[]; role_assigned_by?: unknown }
|
||||
author?: MeAuthor | null
|
||||
@@ -243,6 +267,7 @@ export function useApi() {
|
||||
verified: Boolean(body.is_verified),
|
||||
username: body.username,
|
||||
email: body.email,
|
||||
...(body.pending_email ? { pendingEmail: body.pending_email } : {}),
|
||||
displayName: body.display_name ?? '',
|
||||
...(body.access
|
||||
? {
|
||||
@@ -257,5 +282,5 @@ export function useApi() {
|
||||
}
|
||||
}
|
||||
|
||||
return { getMe, request, requestPaged, requestBlob }
|
||||
return { getMe, request, requestPaged, requestBlob, requestFile }
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { useRoute } from 'vue-router'
|
||||
import { ApiError } from '@/composables/useApi'
|
||||
import { useEntries } from '@/composables/useEntries'
|
||||
import { useUserLookup } from '@/composables/useUserLookup'
|
||||
import { namesWithDeletedFallback } from '@/lib/deletedUser'
|
||||
import type { ContentEntry, EntryListStatus } from '@/lib/contentTypes'
|
||||
import { usePagedQuery, type PagedOutcome } from '@/composables/usePagedQuery'
|
||||
import {
|
||||
@@ -35,12 +36,9 @@ export function useEntryList(typeKey: string) {
|
||||
if (ids.length === 0) {
|
||||
return
|
||||
}
|
||||
const found = await lookupUsers(ids).catch(() => [])
|
||||
if (current === lookupToken) {
|
||||
names.value = {
|
||||
...names.value,
|
||||
...Object.fromEntries(found.map((user) => [user.id, user.display_name])),
|
||||
}
|
||||
const found = await lookupUsers(ids).catch(() => null)
|
||||
if (found && current === lookupToken) {
|
||||
names.value = { ...names.value, ...namesWithDeletedFallback(ids, found) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ref, watch } from 'vue'
|
||||
import { useUserLookup } from '@/composables/useUserLookup'
|
||||
import { namesWithDeletedFallback } from '@/lib/deletedUser'
|
||||
import type { ContentEntry } from '@/lib/contentTypes'
|
||||
|
||||
function authorIds(entry: ContentEntry | null) {
|
||||
@@ -19,10 +20,7 @@ export function useEntryNames(entry: () => ContentEntry | null) {
|
||||
wanted.forEach((id) => requested.add(id))
|
||||
try {
|
||||
const users = await lookupUsers(wanted)
|
||||
names.value = {
|
||||
...names.value,
|
||||
...Object.fromEntries(users.map((user) => [user.id, user.display_name])),
|
||||
}
|
||||
names.value = { ...names.value, ...namesWithDeletedFallback(wanted, users) }
|
||||
} catch {
|
||||
wanted.forEach((id) => requested.delete(id))
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useApi } from '@/composables/useApi'
|
||||
const JSON_HEADERS = { 'Content-Type': 'application/json' }
|
||||
|
||||
export function useProfile() {
|
||||
const { request } = useApi()
|
||||
const { request, requestFile } = useApi()
|
||||
|
||||
function updateDisplayName(displayName: string) {
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -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 = ''
|
||||
})
|
||||
})
|
||||
@@ -1212,6 +1212,21 @@ export const messages = {
|
||||
'Registered the account {0}': 'Compte créé : {0}',
|
||||
'Verified the account {0}': 'Compte vérifié : {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}',
|
||||
'Cancelled the invite for {0}': 'Invitation annulé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}',
|
||||
"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.',
|
||||
'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
|
||||
|
||||
export type MessageKey = keyof typeof messages
|
||||
@@ -269,6 +269,12 @@ describe('The catalogue is complete in both languages', () => {
|
||||
'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',
|
||||
'invite.create',
|
||||
'invite.cancel',
|
||||
'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', () => {
|
||||
const roleTarget = (name: string) => ({ type: 'role', id: '4', 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', () => {
|
||||
const target = userTarget('Ines Duarte')
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -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
Reference in new issue
Block a user