From 80f7037d3ae981ba75e90ab98f305e62d22431db Mon Sep 17 00:00:00 2001 From: Florian Sylvain Date: Tue, 27 Oct 2026 03:08:15 +0100 Subject: [PATCH] feat: Users API, ownership transfer and removal of the legacy role string --- api/dependecyInjection.go | 14 +- api/handlers/auth/meHandler.go | 13 +- api/handlers/ownership/ownershipHandler.go | 68 ++ api/handlers/user/inviteHandler.go | 100 ++- api/handlers/user/roleHandler.go | 88 +++ api/pkg/apperr/messages.go | 5 + api/pkg/publicuser/publicUser.go | 2 - api/router.go | 48 +- docs/api.md | 73 +- docs/architecture.md | 9 +- features/activity-log-producers.feature | 1 + features/activity-log-producers.qa.md | 5 + features/get_current_user.feature | 1 + features/invitations.feature | 1 + features/invite_management.feature | 1 + .../roles_permissions_enforcement.feature | 1 + features/roles_permissions_foundation.feature | 1 + features/roles_permissions_roles_api.feature | 1 + features/roles_permissions_users_api.feature | 359 +++++++++ features/roles_permissions_users_api.qa.md | 129 ++++ features/schema_builtin_view.feature | 1 + features/user_response_field_hygiene.feature | 1 + features/user_roles.feature | 1 + features/users_row_actions.feature | 1 + features/users_screen.feature | 1 + internal/domain/audit/auditDomain.go | 107 +-- .../domain/builtintype/builtinTypeDomain.go | 3 +- internal/domain/invite/inviteDomain.go | 1 + internal/domain/role/roleDomain.go | 1 + .../domain/role/roleRepositoryInterface.go | 1 + internal/domain/user/userDomain.go | 11 +- .../domain/user/userRepositoryInterface.go | 2 + .../persistence/database/createdByBackfill.go | 11 +- .../database/inviteRoleBackfill.go | 28 + .../database/legacyUserRoleBackfill.go | 94 +++ .../persistence/database/migrate.go | 6 +- .../persistence/database/migrationMarker.go | 25 + .../persistence/database/userRoleBackfill.go | 33 - .../database/userRoleIDBackfill.go | 35 - .../persistence/mappers/inviteMapper.go | 2 + .../persistence/mappers/userMapper.go | 6 +- .../persistence/models/inviteModel.go | 3 +- .../models/migrationMarkerModel.go | 8 + .../persistence/models/userModel.go | 1 - .../repositories/roleRepository.go | 78 +- .../repositories/userRepository.go | 107 ++- .../infrastructure/useCases/AcceptInvite.go | 27 +- .../infrastructure/useCases/ChangeUserRole.go | 110 +++ .../infrastructure/useCases/CreateInvite.go | 83 +- .../infrastructure/useCases/CreateUser.go | 13 +- .../infrastructure/useCases/GetMyAccess.go | 20 +- .../useCases/ListUsersAndInvites.go | 41 +- .../infrastructure/useCases/RenewInvite.go | 46 +- .../useCases/TransferOwnership.go | 103 +++ internal/infrastructure/useCases/roleRef.go | 42 + .../infrastructure/useCases/roleSupport.go | 43 ++ internal/infrastructure/useCases/roleView.go | 29 +- test/accept_invitation_atomic_test.go | 3 +- test/accept_invitation_test.go | 12 +- test/activity_log_backend_http_test.go | 8 +- test/activity_log_producers_http_test.go | 4 +- test/activity_log_producers_test.go | 14 +- test/activity_usecases_test.go | 6 +- test/api_catalogue_drift_test.go | 2 + test/api_keys_http_test.go | 22 +- test/api_keys_test.go | 8 +- test/author_support_test.go | 9 +- test/authors_backend_articles_http_test.go | 2 +- test/cancel_pending_registration_http_test.go | 2 +- .../custom_content_types_support_http_test.go | 13 +- test/custom_content_types_support_test.go | 10 +- test/get_current_user_http_test.go | 12 +- test/hasher_test.go | 8 +- test/image_variants_env_test.go | 8 +- test/invitations_test.go | 37 +- test/invite_management_test.go | 48 +- ..._password_reset_rate_limiting_http_test.go | 2 +- test/login_logout_cookie_test.go | 2 +- test/media_get_http_test.go | 2 +- test/media_merge_hardening_test.go | 2 +- test/media_purge_http_test.go | 8 +- test/media_purge_qa_test.go | 9 +- test/mysql_server_test.go | 14 +- test/not_found_errors_test.go | 2 +- test/postgres_server_test.go | 14 +- ...ration_gate_and_verification_email_test.go | 12 +- .../registration_status_endpoint_http_test.go | 4 +- test/repository_contract_test.go | 2 +- test/role_hardening_test.go | 2 +- test/role_repository_gaps_test.go | 15 +- test/role_support_test.go | 66 +- test/roles_api_hardening_test.go | 6 +- test/roles_api_persistence_test.go | 8 +- test/roles_api_usecases_test.go | 3 +- test/roles_api_world_test.go | 19 +- test/roles_content_scope_http_test.go | 15 +- test/roles_content_scope_usecases_test.go | 6 +- test/roles_content_scope_world_test.go | 12 +- ...roles_permissions_enforcement_http_test.go | 33 +- test/roles_permissions_enforcement_test.go | 10 +- .../roles_permissions_foundation_http_test.go | 47 +- test/roles_permissions_foundation_test.go | 89 +-- test/runtime_settings_injection_http_test.go | 4 +- test/seo_data_support_test.go | 2 +- test/session_revocation_http_test.go | 4 +- test/site_acceptance_test.go | 4 +- test/site_activity_acceptance_test.go | 2 +- test/sqlite_schema_unchanged_test.go | 10 +- test/stale_token_invalidation_http_test.go | 2 +- ...y_client_ip_for_rate_limiting_http_test.go | 2 +- ...nverified_users_and_reregistration_test.go | 2 +- test/update_http_test.go | 10 +- test/use_case_baseline_test.go | 4 +- test/user_display_name_test.go | 10 +- test/user_response_field_hygiene_test.go | 4 +- test/user_roles_test.go | 192 ++--- test/user_test.go | 81 +- test/users_api_domain_test.go | 152 ++++ test/users_api_hardening_test.go | 311 ++++++++ test/users_api_http_test.go | 638 ++++++++++++++++ test/users_api_persistence_test.go | 420 ++++++++++ test/users_api_qa_test.go | 39 + test/users_api_usecases_test.go | 715 ++++++++++++++++++ test/users_api_world_test.go | 183 +++++ 124 files changed, 4644 insertions(+), 824 deletions(-) create mode 100644 api/handlers/ownership/ownershipHandler.go create mode 100644 api/handlers/user/roleHandler.go create mode 100644 features/roles_permissions_users_api.feature create mode 100644 features/roles_permissions_users_api.qa.md create mode 100644 internal/infrastructure/persistence/database/inviteRoleBackfill.go create mode 100644 internal/infrastructure/persistence/database/legacyUserRoleBackfill.go create mode 100644 internal/infrastructure/persistence/database/migrationMarker.go delete mode 100644 internal/infrastructure/persistence/database/userRoleBackfill.go delete mode 100644 internal/infrastructure/persistence/database/userRoleIDBackfill.go create mode 100644 internal/infrastructure/persistence/models/migrationMarkerModel.go create mode 100644 internal/infrastructure/useCases/ChangeUserRole.go create mode 100644 internal/infrastructure/useCases/TransferOwnership.go create mode 100644 internal/infrastructure/useCases/roleRef.go create mode 100644 test/users_api_domain_test.go create mode 100644 test/users_api_hardening_test.go create mode 100644 test/users_api_http_test.go create mode 100644 test/users_api_persistence_test.go create mode 100644 test/users_api_qa_test.go create mode 100644 test/users_api_usecases_test.go create mode 100644 test/users_api_world_test.go diff --git a/api/dependecyInjection.go b/api/dependecyInjection.go index 2124e9e..bde0e3d 100644 --- a/api/dependecyInjection.go +++ b/api/dependecyInjection.go @@ -103,6 +103,8 @@ type UseCases struct { CancelInviteUseCase *useCases.CancelInviteUseCase RenewInviteUseCase *useCases.RenewInviteUseCase ListUsersAndInvitesUseCase *useCases.ListUsersAndInvitesUseCase + ChangeUserRoleUseCase *useCases.ChangeUserRoleUseCase + TransferOwnershipUseCase *useCases.TransferOwnershipUseCase GetInviteUseCase *useCases.GetInviteUseCase AcceptInviteUseCase *useCases.AcceptInviteUseCase SendMailUseCase *useCases.SendMailUseCase @@ -406,7 +408,7 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings UpdateEntryUseCase: useCases.NewUpdateEntryUseCase(repos.ContentTypeRepo, repos.EntryRepo, repos.ImageRepo, time.Now), DeleteEntryUseCase: useCases.NewDeleteEntryUseCase(repos.ContentTypeRepo, repos.EntryRepo), GetUserUseCase: useCases.NewGetUserUseCase(repos.UserRepo), - CreateUserUseCase: useCases.NewCreateUserUseCase(repos.UserRepo, hasher, repos.MailRepo, settings.PublicURL, recorder), + CreateUserUseCase: useCases.NewCreateUserUseCase(repos.UserRepo, repos.RoleRepo, hasher, repos.MailRepo, settings.PublicURL, recorder), AuthenticateUserUseCase: useCases.NewAuthenticateUserUseCase(repos.UserRepo, hasher, recorder), VerifyUserUseCase: useCases.NewVerifyUserUseCase(repos.UserRepo, hasher, recorder), CancelRegistrationUseCase: useCases.NewCancelRegistrationUseCase(repos.UserRepo), @@ -414,12 +416,14 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings ResetPasswordUseCase: useCases.NewResetPasswordUseCase(repos.UserRepo, hasher, repos.APIKeyRepo, time.Now, recorder), UpdateUserProfileUseCase: useCases.NewUpdateUserProfileUseCase(repos.UserRepo, recorder), ListUsersUseCase: useCases.NewListUsersUseCase(repos.UserRepo), - CreateInviteUseCase: useCases.NewCreateInviteUseCase(repos.UserRepo, repos.InviteRepo, hasher, repos.MailRepo, settings.PublicURL, recorder), + CreateInviteUseCase: useCases.NewCreateInviteUseCase(repos.UserRepo, repos.InviteRepo, repos.RoleRepo, repos.ContentTypeRepo, hasher, repos.MailRepo, settings.PublicURL, recorder), CancelInviteUseCase: useCases.NewCancelInviteUseCase(repos.InviteRepo, recorder), - RenewInviteUseCase: useCases.NewRenewInviteUseCase(repos.UserRepo, repos.InviteRepo, hasher, repos.MailRepo, settings.PublicURL, recorder), - ListUsersAndInvitesUseCase: useCases.NewListUsersAndInvitesUseCase(repos.UserRepo, repos.InviteRepo), + RenewInviteUseCase: useCases.NewRenewInviteUseCase(repos.UserRepo, repos.InviteRepo, repos.RoleRepo, repos.ContentTypeRepo, hasher, repos.MailRepo, settings.PublicURL, recorder), + ListUsersAndInvitesUseCase: useCases.NewListUsersAndInvitesUseCase(repos.UserRepo, repos.InviteRepo, repos.RoleRepo), + ChangeUserRoleUseCase: useCases.NewChangeUserRoleUseCase(repos.UserRepo, repos.RoleRepo, repos.ContentTypeRepo, recorder), + TransferOwnershipUseCase: useCases.NewTransferOwnershipUseCase(repos.UserRepo, repos.RoleRepo, recorder), GetInviteUseCase: useCases.NewGetInviteUseCase(repos.InviteRepo, hasher), - AcceptInviteUseCase: useCases.NewAcceptInviteUseCase(repos.UserRepo, repos.InviteRepo, hasher, recorder), + AcceptInviteUseCase: useCases.NewAcceptInviteUseCase(repos.UserRepo, repos.InviteRepo, repos.RoleRepo, hasher, recorder), SendMailUseCase: useCases.NewSendMailUseCase(repos.MailRepo), CreateImageUseCase: useCases.NewCreateImageUseCase(repos.ImageRepo, imageStorage, recorder, imaging.NewMetadataStripper()), OpenMediaUseCase: useCases.NewOpenMediaUseCase(imageStorage), diff --git a/api/handlers/auth/meHandler.go b/api/handlers/auth/meHandler.go index 6d58a3e..198e4f9 100644 --- a/api/handlers/auth/meHandler.go +++ b/api/handlers/auth/meHandler.go @@ -32,16 +32,9 @@ type meAuthor struct { ArticleCount int `json:"article_count"` } -type meRole struct { - ID uint32 `json:"id"` - Name string `json:"name"` - BuiltIn bool `json:"built_in"` - Locked bool `json:"locked"` -} - type meAccess struct { - Role meRole `json:"role"` - Permissions []role.Grant `json:"permissions"` + Role useCases.RoleRef `json:"role"` + Permissions []role.Grant `json:"permissions"` } type meResponse struct { @@ -52,7 +45,7 @@ type meResponse struct { func toMeAccess(access useCases.Access) meAccess { return meAccess{ - Role: meRole{ID: access.Role.ID, Name: access.Role.Name, BuiltIn: access.Role.BuiltIn, Locked: access.Role.Locked}, + Role: useCases.NewRoleRef(access.Role), Permissions: access.Permissions, } } diff --git a/api/handlers/ownership/ownershipHandler.go b/api/handlers/ownership/ownershipHandler.go new file mode 100644 index 0000000..8e5e6db --- /dev/null +++ b/api/handlers/ownership/ownershipHandler.go @@ -0,0 +1,68 @@ +package ownership + +import ( + "RenewCMS/api/pkg/access" + "RenewCMS/api/pkg/apperr" + validator "RenewCMS/api/pkg/validator" + "RenewCMS/internal/domain/user" + "RenewCMS/internal/infrastructure/useCases" + "encoding/json" + "errors" + "net/http" +) + +type Handler struct { + TransferUseCase *useCases.TransferOwnershipUseCase +} + +type TransferRequest struct { + UserID uint32 `json:"user_id" validate:"required"` + ConfirmEmail string `json:"confirm_email" validate:"required"` +} + +func NewHandler(transfer *useCases.TransferOwnershipUseCase) *Handler { + return &Handler{TransferUseCase: transfer} +} + +func (h *Handler) Transfer(w http.ResponseWriter, r *http.Request) { + var request TransferRequest + 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 + } + + transferred, err := h.TransferUseCase.Transfer(r.Context(), useCases.TransferOwnershipCommand{ + Caller: access.CallerOf(r), + UserID: request.UserID, + ConfirmEmail: request.ConfirmEmail, + }) + if err != nil { + writeTransferError(w, err) + return + } + + body, err := json.Marshal(transferred) + if err != nil { + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + w.Header().Set("Cache-Control", "no-store") + _, _ = w.Write(body) +} + +func writeTransferError(w http.ResponseWriter, err error) { + switch { + case errors.Is(err, user.ErrInvalidTransfer): + http.Error(w, apperr.InvalidTransferErrorMessage, http.StatusBadRequest) + case errors.Is(err, user.ErrConfirmationMismatch): + http.Error(w, apperr.ConfirmationMismatchErrorMessage, http.StatusBadRequest) + case errors.Is(err, user.ErrRoleChanged): + http.Error(w, apperr.UserRoleChangedErrorMessage, http.StatusConflict) + default: + http.Error(w, "Internal server error", http.StatusInternalServerError) + } +} diff --git a/api/handlers/user/inviteHandler.go b/api/handlers/user/inviteHandler.go index a4f73a2..72b2d8d 100644 --- a/api/handlers/user/inviteHandler.go +++ b/api/handlers/user/inviteHandler.go @@ -1,10 +1,12 @@ package user import ( + "RenewCMS/api/pkg/access" "RenewCMS/api/pkg/apperr" "RenewCMS/api/pkg/claims" validator "RenewCMS/api/pkg/validator" "RenewCMS/internal/domain/invite" + "RenewCMS/internal/domain/role" "RenewCMS/internal/domain/user" "RenewCMS/internal/infrastructure/useCases" "encoding/json" @@ -24,35 +26,38 @@ type InviteHandler struct { } type CreateInviteRequest struct { - Email string `json:"email" validate:"required,inviteemail"` + Email string `json:"email" validate:"required,inviteemail"` + RoleID uint32 `json:"role_id"` } type inviteResponse struct { - ID uint32 `json:"id"` - Kind string `json:"kind"` - Email string `json:"email"` - Status string `json:"status"` - CreatedAt time.Time `json:"created_at"` - ExpiresAt time.Time `json:"expires_at"` + ID uint32 `json:"id"` + Kind string `json:"kind"` + Email string `json:"email"` + Status string `json:"status"` + Role useCases.RoleRef `json:"role"` + CreatedAt time.Time `json:"created_at"` + ExpiresAt time.Time `json:"expires_at"` } type inviteListItem struct { - ID uint32 `json:"id"` - Kind string `json:"kind"` - Email string `json:"email"` - Status string `json:"status"` - CreatedAt time.Time `json:"created_at"` + ID uint32 `json:"id"` + Kind string `json:"kind"` + Email string `json:"email"` + Role *useCases.RoleRef `json:"role"` + Status string `json:"status"` + CreatedAt time.Time `json:"created_at"` } type userListItem struct { - ID uint32 `json:"id"` - Kind string `json:"kind"` - Username string `json:"username"` - DisplayName string `json:"display_name"` - Email string `json:"email"` - Role string `json:"role"` - Status string `json:"status"` - CreatedAt time.Time `json:"created_at"` + ID uint32 `json:"id"` + Kind string `json:"kind"` + Username string `json:"username"` + DisplayName string `json:"display_name"` + Email string `json:"email"` + Role *useCases.RoleRef `json:"role"` + Status string `json:"status"` + CreatedAt time.Time `json:"created_at"` } func NewInviteHandler(createInvite *useCases.CreateInviteUseCase, cancelInvite *useCases.CancelInviteUseCase, renewInvite *useCases.RenewInviteUseCase, listUsersAndInvites *useCases.ListUsersAndInvitesUseCase) *InviteHandler { @@ -83,8 +88,10 @@ func (h *InviteHandler) CreateInvite(w http.ResponseWriter, r *http.Request) { } created, err := h.CreateInviteUseCase.CreateInvite(r.Context(), useCases.CreateInviteCommand{ + Caller: access.CallerOf(r), Email: request.Email, InvitedBy: userId, + RoleID: request.RoleID, }) if err != nil { writeCreateInviteError(w, err) @@ -94,12 +101,13 @@ func (h *InviteHandler) CreateInvite(w http.ResponseWriter, r *http.Request) { writeInviteResponse(w, http.StatusCreated, created) } -func writeInviteResponse(w http.ResponseWriter, status int, entry invite.Invite) { +func writeInviteResponse(w http.ResponseWriter, status int, entry useCases.InviteView) { body, err := json.Marshal(inviteResponse{ ID: entry.ID, Kind: useCases.EntryKindInvite, Email: entry.Email, Status: string(entry.StatusAt(time.Now())), + Role: entry.Role, CreatedAt: entry.CreatedAt, ExpiresAt: entry.ExpiresAt, }) @@ -144,7 +152,7 @@ func (h *InviteHandler) RenewInvite(w http.ResponseWriter, r *http.Request) { return } - renewed, err := h.RenewInviteUseCase.RenewInvite(r.Context(), useCases.RenewInviteCommand{ID: id, RenewedBy: userId}) + renewed, err := h.RenewInviteUseCase.RenewInvite(r.Context(), useCases.RenewInviteCommand{Caller: access.CallerOf(r), ID: id, RenewedBy: userId}) if err != nil { writeManageInviteError(w, err) return @@ -152,7 +160,26 @@ func (h *InviteHandler) RenewInvite(w http.ResponseWriter, r *http.Request) { writeInviteResponse(w, http.StatusOK, renewed) } +func writeRoleRefusal(w http.ResponseWriter, err error) bool { + var escalation role.EscalationError + switch { + case errors.As(err, &escalation): + w.Header().Set(apperr.EscalationHeader, escalation.Key) + http.Error(w, apperr.EscalationErrorMessage, http.StatusForbidden) + case errors.Is(err, role.ErrRoleLocked): + http.Error(w, apperr.RoleLockedErrorMessage, http.StatusForbidden) + case errors.Is(err, role.ErrUnknownRole): + http.Error(w, apperr.InvalidRoleErrorMessage, http.StatusBadRequest) + default: + return false + } + return true +} + func writeManageInviteError(w http.ResponseWriter, err error) { + if writeRoleRefusal(w, err) { + return + } switch { case errors.Is(err, invite.ErrInviteNotFound): http.Error(w, apperr.InviteNotFoundErrorMessage, http.StatusNotFound) @@ -168,6 +195,9 @@ func writeManageInviteError(w http.ResponseWriter, err error) { } func writeCreateInviteError(w http.ResponseWriter, err error) { + if writeRoleRefusal(w, err) { + return + } switch { case errors.Is(err, invite.ErrEmailHasAccount): http.Error(w, apperr.EmailHasAccountErrorMessage, http.StatusConflict) @@ -183,22 +213,20 @@ func writeCreateInviteError(w http.ResponseWriter, err error) { } func (h *InviteHandler) ListUsers(w http.ResponseWriter, _ *http.Request) { - entries := h.ListUsersAndInvitesUseCase.ListUsersAndInvites() - - items := make([]any, 0, len(entries)) - for _, entry := range entries { - if entry.Kind == useCases.EntryKindInvite { - items = append(items, inviteListItem{entry.ID, entry.Kind, entry.Email, entry.Status, entry.CreatedAt}) - continue - } - items = append(items, userListItem{entry.ID, entry.Kind, entry.Username, entry.DisplayName, entry.Email, entry.Role, entry.Status, entry.CreatedAt}) - } - - body, err := json.Marshal(items) + entries, err := h.ListUsersAndInvitesUseCase.ListUsersAndInvites() if err != nil { http.Error(w, "Internal server error", http.StatusInternalServerError) return } - w.Header().Set("Cache-Control", "no-store") - _, _ = w.Write(body) + + items := make([]any, 0, len(entries)) + for _, entry := range entries { + if entry.Kind == useCases.EntryKindInvite { + items = append(items, inviteListItem{entry.ID, entry.Kind, entry.Email, entry.Role, entry.Status, entry.CreatedAt}) + continue + } + items = append(items, userListItemOf(entry)) + } + + writeNoStoreJSON(w, http.StatusOK, items) } diff --git a/api/handlers/user/roleHandler.go b/api/handlers/user/roleHandler.go new file mode 100644 index 0000000..c83b61e --- /dev/null +++ b/api/handlers/user/roleHandler.go @@ -0,0 +1,88 @@ +package user + +import ( + "RenewCMS/api/pkg/access" + "RenewCMS/api/pkg/apperr" + validator "RenewCMS/api/pkg/validator" + "RenewCMS/internal/domain/role" + domain "RenewCMS/internal/domain/user" + "RenewCMS/internal/infrastructure/useCases" + "encoding/json" + "errors" + "net/http" + "strconv" + + "github.com/go-chi/chi/v5" +) + +type RoleHandler struct { + ChangeUserRoleUseCase *useCases.ChangeUserRoleUseCase +} + +type ChangeRoleRequest struct { + RoleID uint32 `json:"role_id" validate:"required"` +} + +func NewRoleHandler(changeRole *useCases.ChangeUserRoleUseCase) *RoleHandler { + return &RoleHandler{ChangeUserRoleUseCase: changeRole} +} + +func userListItemOf(entry useCases.DirectoryEntry) userListItem { + return userListItem{entry.ID, entry.Kind, entry.Username, entry.DisplayName, entry.Email, entry.Role, entry.Status, entry.CreatedAt} +} + +func writeNoStoreJSON(w http.ResponseWriter, status int, payload any) { + body, err := json.Marshal(payload) + if err != nil { + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(status) + _, _ = w.Write(body) +} + +func (h *RoleHandler) Change(w http.ResponseWriter, r *http.Request) { + targetID, err := strconv.ParseUint(chi.URLParam(r, "id"), 10, 32) + if err != nil { + http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest) + return + } + + var request ChangeRoleRequest + 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 + } + + changed, err := h.ChangeUserRoleUseCase.ChangeRole(r.Context(), useCases.ChangeUserRoleCommand{ + Caller: access.CallerOf(r), + TargetID: uint32(targetID), + RoleID: request.RoleID, + }) + if err != nil { + writeChangeRoleError(w, err) + return + } + writeNoStoreJSON(w, http.StatusOK, userListItemOf(changed)) +} + +func writeChangeRoleError(w http.ResponseWriter, err error) { + if writeRoleRefusal(w, err) { + return + } + switch { + case errors.Is(err, role.ErrOwnRole): + http.Error(w, apperr.OwnRoleErrorMessage, http.StatusForbidden) + case errors.Is(err, domain.ErrUserNotFound): + http.Error(w, apperr.UserNotFoundErrorMessage, http.StatusNotFound) + case errors.Is(err, domain.ErrRoleChanged): + http.Error(w, apperr.UserRoleChangedErrorMessage, http.StatusConflict) + default: + http.Error(w, "Internal server error", http.StatusInternalServerError) + } +} diff --git a/api/pkg/apperr/messages.go b/api/pkg/apperr/messages.go index d9d161f..86033ac 100644 --- a/api/pkg/apperr/messages.go +++ b/api/pkg/apperr/messages.go @@ -118,3 +118,8 @@ const InvalidScopeErrorMessage = "invalid_scope" const OwnerOnlyPermissionErrorMessage = "owner_only_permission" const DuplicatePermissionErrorMessage = "duplicate_permission" const UnknownContentTypeErrorMessage = "unknown_content_type" + +const InvalidRoleErrorMessage = "invalid_role" +const UserRoleChangedErrorMessage = "user_role_changed" +const InvalidTransferErrorMessage = "invalid_transfer" +const ConfirmationMismatchErrorMessage = "confirmation_mismatch" diff --git a/api/pkg/publicuser/publicUser.go b/api/pkg/publicuser/publicUser.go index daa311e..e7f4617 100644 --- a/api/pkg/publicuser/publicUser.go +++ b/api/pkg/publicuser/publicUser.go @@ -13,7 +13,6 @@ type PublicUser struct { CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` DisplayName string `json:"display_name"` - Role user.Role `json:"role"` } func New(u user.User) PublicUser { @@ -25,6 +24,5 @@ func New(u user.User) PublicUser { CreatedAt: u.CreatedAt, UpdatedAt: u.UpdatedAt, DisplayName: u.DisplayName, - Role: u.Role, } } diff --git a/api/router.go b/api/router.go index bf60797..5a5b5a7 100644 --- a/api/router.go +++ b/api/router.go @@ -13,6 +13,7 @@ import ( "RenewCMS/api/handlers/imagevariant" "RenewCMS/api/handlers/llms" "RenewCMS/api/handlers/media" + "RenewCMS/api/handlers/ownership" "RenewCMS/api/handlers/permission" rolehandler "RenewCMS/api/handlers/role" "RenewCMS/api/handlers/settings" @@ -164,23 +165,37 @@ func NewUserRouter(container *UseCases, rateLimit config.AuthRateLimitSettings, userHandler := user.NewHandler(container.UpdateUserProfileUseCase) lookupHandler := user.NewLookupHandler(container.LookupUserNamesUseCase) inviteHandler := user.NewInviteHandler(container.CreateInviteUseCase, container.CancelInviteUseCase, container.RenewInviteUseCase, container.ListUsersAndInvitesUseCase) + roleHandler := user.NewRoleHandler(container.ChangeUserRoleUseCase) - r.Use(authtoken.Verifier(container.Token)) - r.Use(container.apiKeyAuth()) - r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies)) - r.Use(authtoken.Authenticator) - r.Use(middleware.RequireCurrentToken(container.GetUserUseCase)) - r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase)) - r.Patch("/me", userHandler.UpdateMe) - r.With(container.require(role.KeyUsersView)).Get("/", inviteHandler.ListUsers) - r.Get("/lookup", lookupHandler.Lookup) r.Group(func(r chi.Router) { - r.Use(container.require(role.KeyUsersInvite)) - r.Use(middleware.TrustedProxyRealIP(trustedProxies)) - r.Use(middleware.NewRateLimiter(rateLimit.MaxAttempts, rateLimit.Window)) - r.Post("/invite", inviteHandler.CreateInvite) - r.Delete("/invite/{id}", inviteHandler.CancelInvite) - r.Post("/invite/{id}/renew", inviteHandler.RenewInvite) + r.Use(authtoken.Verifier(container.Token)) + r.Use(container.apiKeyAuth()) + r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies)) + r.Use(authtoken.Authenticator) + r.Use(middleware.RequireCurrentToken(container.GetUserUseCase)) + r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase)) + r.Patch("/me", userHandler.UpdateMe) + r.With(container.require(role.KeyUsersView)).Get("/", inviteHandler.ListUsers) + r.Get("/lookup", lookupHandler.Lookup) + r.Group(func(r chi.Router) { + r.Use(container.require(role.KeyUsersInvite)) + r.Use(middleware.TrustedProxyRealIP(trustedProxies)) + r.Use(middleware.NewRateLimiter(rateLimit.MaxAttempts, rateLimit.Window)) + r.Post("/invite", inviteHandler.CreateInvite) + r.Delete("/invite/{id}", inviteHandler.CancelInvite) + 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.Use(container.require(role.KeyUsersChangeRole)) + r.Put("/{id}/role", roleHandler.Change) }) return r } @@ -220,6 +235,8 @@ func NewSiteRouter(container *UseCases, trustedProxies []string) http.Handler { sitemapHandler := site.NewSitemapHandler(container.ListSitemapUseCase) + ownershipHandler := ownership.NewHandler(container.TransferOwnershipUseCase) + r.Get("/{key}/sitemap", sitemapHandler.Sitemap) r.Group(func(r chi.Router) { r.Use(authtoken.Verifier(container.Token)) @@ -241,6 +258,7 @@ func NewSiteRouter(container *UseCases, trustedProxies []string) http.Handler { r.With(container.require(role.KeySettingsManageSites)).Patch("/{id}", siteHandler.Update) r.With(container.require(role.KeySettingsManageSites)).Patch("/{id}/default", siteHandler.SetDefault) r.With(container.require(role.KeySettingsDeleteSite)).Delete("/{id}", siteHandler.Delete) + r.With(container.require(role.KeySystemTransferOwnership)).Post("/transfer-ownership", ownershipHandler.Transfer) }) return r } diff --git a/docs/api.md b/docs/api.md index dc93598..9e15e89 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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/, PATCH /v1/user/me, POST /v1/user/invite (owner only), DELETE /v1/user/invite/{id} (owner only), POST /v1/user/invite/{id}/renew (owner only), GET /v1/auth/me (with the linked `author`) | +| 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`) | | 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`, `invite.create`, `invite.cancel`, `invite.renew`, `invite.accept` | +| `users` | `user.register`, `user.verify`, `user.update_profile`, `user.change_role`, `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` | @@ -372,7 +372,8 @@ A role is `{ id, name, description, built_in, locked, version, grants, members, `grants` is the normalised effective list: catalogue order, unknown keys and owner-only keys on an unlocked role dropped, duplicates resolved to the most restrictive scope, and a per-type entry (`content.update:events`, scope `none` included) right after its wildcard. A response is always valid as the `grants` of a `PUT`. `members` counts -users by their effective role (the legacy `owner` and `admin` strings count for the built-in role of that name). +the users holding the role plus the open invites (pending or expired, never accepted ones) for it, counted in two +statements whatever the number of roles. A user whose `role_id` is 0 or names no role counts for nobody. `updated_at` (RFC 3339, UTC) and `updated_by` (`{ id, name }`, the display name else the username) are `null` for a role never edited since seeding, and `updated_by` is also `null` once the saving user is deleted. @@ -387,7 +388,7 @@ Refusals are plain text, one snake_case code per body: | 403 | `own_role` | Editing, deleting or moving members out of the caller's own role. | | 403 | `escalation` | The caller would grant, or touch a role holding, more than they hold. `X-Escalation-Key` names the first offending key, such as `content.publish` or `content.publish:events`. | | 404 | `role_not_found` | Unknown role id. | -| 409 | `role_name_taken`, `role_conflict`, `role_has_members`, `role_built_in` | Name already used (case-insensitive); stale `version`; the role still has members; deleting or moving members out of a built-in role. | +| 409 | `role_name_taken`, `role_conflict`, `role_has_members`, `role_built_in` | Name already used (case-insensitive); stale `version`; the role still has members (users or open invites); deleting or moving members out of a built-in role. | Names are trimmed and unique case-insensitively. The order of checks on a write is: permission, id, body, role lookup, locked, built-in rules, own role, existing role covered by the caller, version, grant validation, new grants covered @@ -397,13 +398,75 @@ at the same version and records nothing. Members see an edit on their next reque Activity: `role.create`, `role.edit`, `role.delete` and `role.reassign` in the `roles` category, target `{ type: "role", id, name }`. `role.edit` carries `name` and `description` changes and the params `added`, `removed`, -`changed` (`{ key, from, to }`) and `members`; `role.reassign` carries `from`, `to` and `moved` and is recorded only when +`changed` (`{ key, from, to }`) and `members`; `role.reassign` carries `from`, `to` and `moved` (users and open invites together) and is recorded only when `moved` is above 0. A refusal records nothing. `GET /v1/auth/me` lists per-type entries in `access.permissions` (scope `none` included), right after their wildcard. 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. +## Users, invites and ownership + +A user has exactly one stored role, `role_id`, and nothing else: there is no `role` string any more (the +`users.role` column of older databases stays in place, unused). `RoleRef` is the one role summary shape, +`{ 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`. + +| Route | Permission | Session only | Purpose | +| --- | --- | --- | --- | +| `GET /v1/user` | `users.view` | no | Users and open invites, newest first. A user is `{ id, kind: "user", username, display_name, email, role, status: "accepted", created_at }`; an invite is `{ id, kind: "invite", email, role, status, created_at }`. The roles are read with one `List` call. | +| `PUT /v1/user/{id}/role` | `users.change_role` | yes | Body `{"role_id": N}` (required). Answers 200 with the user item. | +| `POST /v1/user/invite` | `users.invite` | no | Body `{"email", "role_id"}`; `role_id` is optional and 0 or absent means the Writer role. Answers 201 `{ id, kind: "invite", email, status, role, created_at, expires_at }`. | +| `POST /v1/user/invite/{id}/renew` | `users.invite` | no | Renews an expired or pending invite; it keeps its role and the response carries it. | +| `DELETE /v1/user/invite/{id}` | `users.invite` | no | Cancels an invite (204). No coverage is needed: cancelling removes power. | +| `POST /v1/site/transfer-ownership` | `system.transfer_ownership` | yes | Body `{"user_id", "confirm_email"}` (both required). Answers 200 `{ "owner": { id, name }, "former_owner": { id, name, role } }`. | + +The no-escalation rule applies to people as it does to roles: the caller must cover (`GrantSet.Covers`) the target's +current role and the new role on a role change, the role of an invite on create and renew. An API key may create and +renew invites, and the check then uses the live role of the key's creator. Nobody can give, take away or invite to the +Owner role: `PUT /v1/user/{id}/role` to or from it, and an invite for it, are 403 `role_locked`; only the transfer moves +the Owner role. Changing the role of your own account is 403 `own_role`. Giving the role a user already holds answers +200 and writes and records nothing. A role change keeps `token_version`, sessions and keys: the next request is decided +by the new grants. An unverified user may be given a role. + +The order of checks on `PUT /v1/user/{id}/role` is: session, permission, id, body, target user, target holds a locked +role, `role_id` lookup, new role locked, own account, current role covered, new role covered, same role (no-op), write. +On `POST /v1/user/invite`: permission, rate limiter, body, inviter, role (default Writer), role locked, role covered, +email already has an account, pending invite, create, mail. On `POST /v1/user/invite/{id}/renew`: invite lookup (404), +already accepted (409), invite role covered, renew, mail. On `POST /v1/site/transfer-ownership`: session, permission, +body, target, `confirm_email`, write. Every refusal writes nothing, not even the deletion of an expired invite. + +| Status | Body | When | +| --- | --- | --- | +| 400 | `invalid_role` | `role_id` names no role (user change or invite), or the invite's role no longer exists on renew. | +| 400 | `invalid_transfer` | The transfer target is unknown, yourself, unverified, or not a user (an invite id is never valid). | +| 400 | `confirmation_mismatch` | `confirm_email` differs from the target's email (exact, case-sensitive match). | +| 403 | `role_locked` | The target holds the Owner role, or the role to give is the Owner role. | +| 403 | `own_role` | The target of a role change is the caller's own account. | +| 403 | `escalation` | A role to cover is above the caller's grants; `X-Escalation-Key` names the first offending key. | +| 403 | `missing_permission` | The gate, with `X-Missing-Permission`. A key on a session-only route answers 403 "This action needs a real session." first. | +| 404 | `user_not_found` | Unknown user id on `PUT /v1/user/{id}/role`. | +| 409 | `user_role_changed` | The target's role changed between read and write (a compare-and-swap on the role that was read), or the caller no longer holds the Owner role on a transfer. | + +Transferring ownership moves the Owner role to the target and gives the former owner the built-in Admin role in one +transaction; both keep `token_version`, cookies and API keys, so the former owner's next request is decided by the +Admin grants and the new owner's by the Owner grants. Exactly one verified user holds the Owner role afterwards, also +under two simultaneous transfers (one answers 200, the other 409). + +Accepting an invite (`POST /v1/auth/invite/accept`) creates the user with the role the invite names, read at accept +time; an invite whose `role_id` is 0 or names a deleted role answers the usual invalid-invite 404 and creates nothing. +Registration creates the first user with the built-in Owner role; when the Owner role is not seeded it answers 500. +A user whose `role_id` is 0 or names no role is denied on every gated route (403 `missing_permission`) and gets 500 on +`GET /v1/auth/me`; it is never promoted. + +Activity: `user.change_role` (category `users`, target the user, `changes` holds `{ field: "role", kind: "text", before, +after }` with the role names, params `from` and `to` hold the role ids) and `user.transfer_ownership` (category `users`, +`security: true`, target the new owner, params `from` and `to` hold the two user ids). `invite.create` and +`invite.accept` carry the params `role_id` and `role` (the role name); `invite.renew` is unchanged. A refusal and a +no-op record nothing; a storage failure at the write records the same event with outcome `failed`. + ## Content permissions Articles and the entries of custom content types are checked against the `content.*` permissions of the diff --git a/docs/architecture.md b/docs/architecture.md index 3cac978..e8ab100 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -15,7 +15,7 @@ api/ ──▶ internal/infrastructure/useCases ──▶ internal/domain | Domain | `internal/domain//` | Entities, `Err…` sentinel errors, `Repository` interfaces. Imports only the stdlib and other domain packages, with no GORM tags. | | Use cases | `internal/infrastructure/useCases/` | Application logic. Depends only on domain interfaces, never on persistence, mailer, GORM or `net/http`. | | Adapters | `internal/infrastructure/persistence/{models,mappers,repositories}`, `internal/infrastructure/mailer` | Implement a domain `Repository` (assert with `var _ domain.Repository = &X{}`). GORM structs stay in `models` and are converted by `mappers`. | -| Database drivers | `internal/infrastructure/persistence/database` | `database.Connect(Config{Driver, Source})` looks the driver up in a registry and returns a `Connection` (GORM handle, `NewBackup`, `Close`). A `Driver` implements `Open` and `NewBackup`, so a new database is one new file plus one registry entry. `database.Migrate` owns the `AutoMigrate` model list and the user-role backfill. Only `sqlite` exists today. | +| Database drivers | `internal/infrastructure/persistence/database` | `database.Connect(Config{Driver, Source})` looks the driver up in a registry and returns a `Connection` (GORM handle, `NewBackup`, `Close`). A `Driver` implements `Open` and `NewBackup`, so a new database is one new file plus one registry entry. `database.Migrate` owns the `AutoMigrate` model list and the role backfills (legacy `users.role` into `role_id` while that column exists, invites without a role to Admin, then `created_by`). Only `sqlite` exists today. | | Blob storage | `domain/image.Storage`, `internal/infrastructure/storage` | `Storage` (`Put`, `Open`, `Delete`) is the port for uploaded image bytes. `storage.Open(Config{Driver, Location})` picks the adapter; `local` is the only one. Repositories never touch files: use cases coordinate `Storage` and `image.Repository`. | | Delivery | `api/handlers//`, `api/router.go` | HTTP handlers receive use cases through `NewHandler(...)`. They never import persistence, mailer or GORM. | | Composition root | `api/dependecyInjection.go` | The only place that picks the database and storage drivers, builds repositories and use cases, and runs `database.Migrate` and the image metadata backfill at boot. | @@ -27,7 +27,8 @@ Nothing under `internal/` imports `api/`. | Package | Role | |---|---| -| `api/handlers/user` | Authenticated user endpoints: profile update (`UpdateMe`) and the users list with invitations (`CreateInvite`, `ListUsers`). | +| `api/handlers/user` | Authenticated user endpoints: profile update (`UpdateMe`), the users list with invitations (`CreateInvite`, `ListUsers`) and the role change (`RoleHandler.Change`). | +| `api/handlers/ownership` | `POST /v1/site/transfer-ownership`, built by `NewHandler(TransferOwnershipUseCase)` and mounted by `NewSiteRouter` in the session group. | | `api/handlers/media` | The media library under `/v1/media` (list, upload, rename, delete, merge, usage). Images belong to the library, not to an article: deleting an article never deletes an image, and an image's "used in" is read from article bodies each time. | | `api/pkg/upload` | Multipart `file` reading and the mapping of upload errors (415, 413, 400) shared by both upload routes. | | `api/pkg/publicuser` | `PublicUser`, the JSON shape of a user with no password, codes or token version. `publicuser.New` converts a domain `user.User`, so handlers never serialise the domain struct directly. | @@ -113,9 +114,9 @@ At boot, `BackfillImageMetadataUseCase` (through `image.Repository.ListMissingMe ### Roles and permissions -`internal/domain/role` holds the permission catalogue, the built-in roles, one exported constant per catalogue key (`roleKeys.go`, the only place a key is spelled in `api/`) and the pure rules over grants: `ValidateGrants`, `ParseTypedKey` and `Role.GrantSet()`. `Role.GrantSet()` is the single constructor of a `GrantSet` and the only place a stored grant becomes an effective one: it drops unknown keys, typed keys on non-content permissions or with an invalid type key, and owner-only permissions on a role that is not locked; it treats a toggle stored at `own` as `none`; and it resolves duplicate rows to the most restrictive scope. `ValidateGrants` rejects the same rows at write time (`ErrDuplicateGrant`, `ErrOwnerOnlyGrant`, `ErrInvalidScope`, `ErrUnknownPermission`), so a bad row is refused when written and ignored when read. A toggle permission only counts at scope `all`, a scoped one at `own` or `all`. A content permission can be narrowed or widened per content type with a key such as `content.update:blog`; the typed row replaces the wildcard for that type and a `none` row denies. A `GrantSet` is opaque: `ScopeFor(key, typeKey)` resolves wildcard and typed rows, `Permits(key, typeKey, callerID, createdBy)` is the one implementation of own and all (`created_by` 0 and caller 0 own nothing), and `Holds`, `HoldsFor` and `HoldsAll` answer the gate questions. Use cases, handlers and middleware never compare a caller id with `CreatedBy` and never read grant rows. `role.Repository` holds `List` (with every role's grants, in one pass), `Get`, `GetByName`, `Create`, `Save`, `Delete`, `ReassignMembers` and `RemoveTypedGrants`; there is no separate grant listing. `Save` is version checked: it updates the row only when the caller's version matches, bumps the version, writes `updated_at` and `updated_by`, replaces the grants in the same transaction and returns `ErrRoleConflict` otherwise. It never changes `built_in` or `locked`. `Delete` removes the role and its grants in one transaction and returns `ErrRoleHasMembers` when a user still has the role id; `ReassignMembers(from, to)` changes only `role_id` and returns the count; `RemoveTypedGrants(typeKey)` removes the five `content.*:{typeKey}` rows from every role and bumps the version of each affected role once. The role domain also owns `GrantSet.Covers(other, typeKeys)`, the single implementation of "the caller holds at least these grants" behind the no-escalation rule (it returns an `EscalationError` naming the first offending key), `Role.EffectiveGrants()` (the normalised list used by role responses, the audit diff and `/v1/auth/me`) and `DiffGrants`. The roles API (`/v1/role`) is served by `ListRolesUseCase`, `GetRoleUseCase`, `CreateRoleUseCase`, `UpdateRoleUseCase` (which also resets a built-in role), `DeleteRoleUseCase` and `ReassignRoleMembersUseCase`; they depend on `role.Repository`, `user.Repository`, `contenttype.Repository` and `audit.Recorder` only, and `api/handlers/role` maps their domain errors to the status and code. `DeleteContentTypeUseCase` and `CreateContentTypeUseCase` also take the role repository to clear per-type grants. `DeleteContentTypeUseCase` deletes the type and then clears its typed rows; if that cleanup fails the rows stay, which is safe because the role views (`GET /v1/role`, `GET /v1/role/{id}` and every write response) drop typed rows for content types that no longer exist, so a GET body stays a valid PUT body, and `CreateContentTypeUseCase` clears the rows of a key right after a successful create, before any role can match the new type. A failed create never touches the roles. The repository re-checks the name case-insensitively inside the `Create` and `Save` transactions (`ErrRoleNameTaken`), locks the role row in `Delete` before counting members (a no-op on SQLite) and guards the `ReassignMembers` update with an `EXISTS` on the target role, answering `ErrInvalidReassign` when the target vanished. +`internal/domain/role` holds the permission catalogue, the built-in roles, one exported constant per catalogue key (`roleKeys.go`, the only place a key is spelled in `api/`) and the pure rules over grants: `ValidateGrants`, `ParseTypedKey` and `Role.GrantSet()`. `Role.GrantSet()` is the single constructor of a `GrantSet` and the only place a stored grant becomes an effective one: it drops unknown keys, typed keys on non-content permissions or with an invalid type key, and owner-only permissions on a role that is not locked; it treats a toggle stored at `own` as `none`; and it resolves duplicate rows to the most restrictive scope. `ValidateGrants` rejects the same rows at write time (`ErrDuplicateGrant`, `ErrOwnerOnlyGrant`, `ErrInvalidScope`, `ErrUnknownPermission`), so a bad row is refused when written and ignored when read. A toggle permission only counts at scope `all`, a scoped one at `own` or `all`. A content permission can be narrowed or widened per content type with a key such as `content.update:blog`; the typed row replaces the wildcard for that type and a `none` row denies. A `GrantSet` is opaque: `ScopeFor(key, typeKey)` resolves wildcard and typed rows, `Permits(key, typeKey, callerID, createdBy)` is the one implementation of own and all (`created_by` 0 and caller 0 own nothing), and `Holds`, `HoldsFor` and `HoldsAll` answer the gate questions. Use cases, handlers and middleware never compare a caller id with `CreatedBy` and never read grant rows. `role.Repository` holds `List` (with every role's grants, in one pass), `Get`, `GetByName`, `Create`, `Save`, `Delete`, `ReassignMembers`, `RemoveTypedGrants` and `MemberCounts` (users grouped by `role_id` plus invites with `accepted_at` NULL grouped by `role_id`, two statements for any number of roles); no role use case scans `user.Repository.GetAll`. `user.Repository` gains `UpdateRole(id, from, to)`, a compare-and-swap on the role the use case read (`ErrRoleChanged`, `ErrUserNotFound`, `role.ErrRoleNotFound`), and `TransferOwnership(from, to, ownerRoleID, formerRoleID)`, which updates both rows in one transaction and fails whole when `from` no longer holds the Owner role (`ErrRoleChanged`) or `to` is missing or unverified (`ErrInvalidTransfer`). `invite.Invite` carries `RoleID`. `ChangeUserRoleUseCase`, `TransferOwnershipUseCase` and the amended invite use cases find the built-in roles with one helper, `builtInRole(roles, name)` (`GetByName`, then `built_in` must be true), and a user's role with `resolveRole`, which reads `role_id` only. `Delete` re-counts users and open invites inside its transaction and `ReassignMembers` moves users and open invites in one transaction and returns their sum. `Save` is version checked: it updates the row only when the caller's version matches, bumps the version, writes `updated_at` and `updated_by`, replaces the grants in the same transaction and returns `ErrRoleConflict` otherwise. It never changes `built_in` or `locked`. `Delete` removes the role and its grants in one transaction and returns `ErrRoleHasMembers` when a user still has the role id; `ReassignMembers(from, to)` changes only `role_id` and returns the count; `RemoveTypedGrants(typeKey)` removes the five `content.*:{typeKey}` rows from every role and bumps the version of each affected role once. The role domain also owns `GrantSet.Covers(other, typeKeys)`, the single implementation of "the caller holds at least these grants" behind the no-escalation rule (it returns an `EscalationError` naming the first offending key), `Role.EffectiveGrants()` (the normalised list used by role responses, the audit diff and `/v1/auth/me`) and `DiffGrants`. The roles API (`/v1/role`) is served by `ListRolesUseCase`, `GetRoleUseCase`, `CreateRoleUseCase`, `UpdateRoleUseCase` (which also resets a built-in role), `DeleteRoleUseCase` and `ReassignRoleMembersUseCase`; they depend on `role.Repository`, `user.Repository`, `contenttype.Repository` and `audit.Recorder` only, and `api/handlers/role` maps their domain errors to the status and code. `DeleteContentTypeUseCase` and `CreateContentTypeUseCase` also take the role repository to clear per-type grants. `DeleteContentTypeUseCase` deletes the type and then clears its typed rows; if that cleanup fails the rows stay, which is safe because the role views (`GET /v1/role`, `GET /v1/role/{id}` and every write response) drop typed rows for content types that no longer exist, so a GET body stays a valid PUT body, and `CreateContentTypeUseCase` clears the rows of a key right after a successful create, before any role can match the new type. A failed create never touches the roles. The repository re-checks the name case-insensitively inside the `Create` and `Save` transactions (`ErrRoleNameTaken`), locks the role row in `Delete` before counting members (a no-op on SQLite) and guards the `ReassignMembers` update with an `EXISTS` on the target role, answering `ErrInvalidReassign` when the target vanished. -Enforcement path. The first middleware that needs the signed-in user (`AuditContext`, `RequireCurrentToken`, `RequireVerifiedUser` or a permission gate) stores a request-scoped holder in the context (`access.WithCurrentUser`), so the user is loaded once per request; an API key adds the one load of its own authentication. `CheckPermissionUseCase` resolves the caller's role with one `role.Repository.Get` (or `GetByName` for the legacy owner and admin strings), builds the `GrantSet` from the grants the role already carries and never calls `List`. `RequireTypedPermission` gates a content route on the typed scope before any lookup (the type key is `article` for articles and the `{key}` of the URL for entries) and puts the caller (`access.Caller`: user id and grants) in the context. The optional `OptionalAccess` middleware does the same for the public article routes without ever refusing: no cookie, an unverified user, a stale token or an unresolvable role leave the caller anonymous, and only a role repository failure answers 500. The handler passes the caller to the use case as `useCases.ContentCaller`; the use case loads the article or entry, decides the scope with `Permits` right after the load and before any other lookup or validation, and returns a `role.MissingPermissionError`, which `access.WriteIfMissingPermission` writes as 403 with the base key in `X-Missing-Permission`. Reading follows the same helper: an article is visible when it is online or `content.read` permits its `created_by`, and an entry the caller may not read is `entry.ErrEntryNotFound`. A list under scope `own` is filtered before totals, search and paging. `CreateContentTypeUseCase` refuses the built-in type keys with `contenttype.ErrKeyTaken`. +Enforcement path. The first middleware that needs the signed-in user (`AuditContext`, `RequireCurrentToken`, `RequireVerifiedUser` or a permission gate) stores a request-scoped holder in the context (`access.WithCurrentUser`), so the user is loaded once per request; an API key adds the one load of its own authentication. `CheckPermissionUseCase` resolves the caller's role with one `role.Repository.Get` (a user whose `role_id` is 0 resolves to `ErrRoleNotFound`, an empty grant set), builds the `GrantSet` from the grants the role already carries and never calls `List`. `RequireTypedPermission` gates a content route on the typed scope before any lookup (the type key is `article` for articles and the `{key}` of the URL for entries) and puts the caller (`access.Caller`: user id and grants) in the context. The optional `OptionalAccess` middleware does the same for the public article routes without ever refusing: no cookie, an unverified user, a stale token or an unresolvable role leave the caller anonymous, and only a role repository failure answers 500. The handler passes the caller to the use case as `useCases.ContentCaller`; the use case loads the article or entry, decides the scope with `Permits` right after the load and before any other lookup or validation, and returns a `role.MissingPermissionError`, which `access.WriteIfMissingPermission` writes as 403 with the base key in `X-Missing-Permission`. Reading follows the same helper: an article is visible when it is online or `content.read` permits its `created_by`, and an entry the caller may not read is `entry.ErrEntryNotFound`. A list under scope `own` is filtered before totals, search and paging. `CreateContentTypeUseCase` refuses the built-in type keys with `contenttype.ErrKeyTaken`. Built-in reconciliation: `database.SeedBuiltInRoles` creates the three built-in roles when they are missing and never overwrites an edited one. On every start it adds the catalogue keys a built-in role lacks: always for Owner, at scope `all`, and for Admin and Writer only while their version is still 1. It never removes or downgrades a grant and never bumps the version. diff --git a/features/activity-log-producers.feature b/features/activity-log-producers.feature index 6f5ec39..9df0c33 100644 --- a/features/activity-log-producers.feature +++ b/features/activity-log-producers.feature @@ -1,4 +1,5 @@ Feature: Every real action of the back-office is recorded in the activity log from inside its use case + Superseded in part by roles_permissions_users_api: user.change_role and user.transfer_ownership (security) join the users category, and invite.create and invite.accept carry the params role_id and role (the role name). As the owner of a RenewCMS site I want articles, media, users and invites, and system tasks to leave an activity event diff --git a/features/activity-log-producers.qa.md b/features/activity-log-producers.qa.md index a99bfb8..96cf241 100644 --- a/features/activity-log-producers.qa.md +++ b/features/activity-log-producers.qa.md @@ -66,3 +66,8 @@ Go only, no SPA. Package `test` (black-box). New files: `test/activity_producers 42. Real update helper on Linux systemd (or the Docker QA flow): check, download until `ready`, restart; one event each, real asset names absent. Skip in a container. 43. Leave the scheduled purge due and wait for the hourly tick: a single `system.media_purge` event, source `system`, no ip or user agent. 44. Remove the database file's write access during one article edit: edit result unchanged, process log names the audit write failure. + +## Amendment from roles_permissions_users_api + +- `user.change_role` and `user.transfer_ownership` (security true) are recorded in the `users` category by `ChangeUserRoleUseCase` and `TransferOwnershipUseCase`. A refusal and a no-op record nothing; a storage failure at the write records a failed event. +- `invite.create` and `invite.accept` carry the params `role_id` and `role` (the role name). `invite.renew` is unchanged. A refused role (unknown, locked, above the caller) on create or renew records nothing. diff --git a/features/get_current_user.feature b/features/get_current_user.feature index ed4ca92..9022abd 100644 --- a/features/get_current_user.feature +++ b/features/get_current_user.feature @@ -1,4 +1,5 @@ Feature: A logged-in user fetches their own public profile without triggering the verified-account gate + Superseded in part by roles_permissions_users_api: GET /v1/auth/me has no top-level role key; the role is access.role only. The public user has seven fields: id, username, email, is_verified, created_at, updated_at and display_name. As the maintainer of RenewCMS I want the SPA to ask "who is currently logged in" via a single endpoint that answers from the diff --git a/features/invitations.feature b/features/invitations.feature index 45151e1..e8a8b9e 100644 --- a/features/invitations.feature +++ b/features/invitations.feature @@ -1,5 +1,6 @@ Feature: A verified user can invite another person by email, and the users list shows users and open invites Superseded in part by roles_permissions_enforcement: the owner and admin 403 clauses now answer 403 "missing_permission" with the header X-Missing-Permission, decided by the caller's role permissions. An Admin can now invite and write sites, and a role without the needed permission is refused on routes that were open to every verified user. + Superseded in part by roles_permissions_users_api: the invite body gains an optional role_id (absent or 0 means the Writer role), the invite response and the invite items of the users list carry role (a RoleRef, keys {id, name, built_in, locked}), the key lists of those shapes gain role, and the inviter must cover the role (403 escalation, role_locked, invalid_role). A user created by accepting an invite takes the role of the invite instead of Admin. As a member of the shared RenewCMS workspace I want to invite someone by email and see who has an account or a pending invite diff --git a/features/invite_management.feature b/features/invite_management.feature index c34a2dd..837b960 100644 --- a/features/invite_management.feature +++ b/features/invite_management.feature @@ -1,5 +1,6 @@ Feature: The owner can cancel a pending invite and renew a pending or expired one Superseded in part by roles_permissions_enforcement: the owner and admin 403 clauses now answer 403 "missing_permission" with the header X-Missing-Permission, decided by the caller's role permissions. An Admin can now invite and write sites, and a role without the needed permission is refused on routes that were open to every verified user. + Superseded in part by roles_permissions_users_api: invite items and the renew response carry role (a RoleRef), a renewed invite keeps its role, and a renew is refused with 403 escalation when the invite role is above the caller grants (cancelling needs no coverage). The key lists of those shapes gain role. As the owner of the shared RenewCMS workspace I want to cancel an invite I no longer want, or renew one that is stale or expired diff --git a/features/roles_permissions_enforcement.feature b/features/roles_permissions_enforcement.feature index bfa32dd..17ef6dd 100644 --- a/features/roles_permissions_enforcement.feature +++ b/features/roles_permissions_enforcement.feature @@ -1,4 +1,5 @@ Feature: Roles and permissions enforcement: permission checks replace the owner and admin gates + Superseded in part by roles_permissions_users_api: a role is resolved by role_id only. The lines about the legacy resolution ("else the built-in named by the legacy string", "no role_id and a legacy string other than owner or admin") are replaced by: role_id 0 or an unknown role_id is an unresolvable role, refused as missing_permission. As the owner of a RenewCMS site I want every owner and admin gate replaced by a check of the caller's role permissions diff --git a/features/roles_permissions_foundation.feature b/features/roles_permissions_foundation.feature index af9268f..f22eb54 100644 --- a/features/roles_permissions_foundation.feature +++ b/features/roles_permissions_foundation.feature @@ -1,5 +1,6 @@ Feature: Roles and permissions foundation: catalogue, built-in roles, backfills and read endpoints Superseded in part by roles_permissions_enforcement: the owner and admin 403 clauses now answer 403 "missing_permission" with the header X-Missing-Permission, decided by the caller's role permissions. An Admin can now invite and write sites, and a role without the needed permission is refused on routes that were open to every verified user. + Superseded in part by roles_permissions_users_api: the legacy users.role string is no longer read. The scenarios about the fallback to the built-in role named by the legacy string, "users gain a nullable role_id; the legacy string is unchanged" and the BackfillUserRoleIDs backfills are replaced by the migration of roles_permissions_users_api (legacy backfill while the column exists, invites to Admin) and by the rule that role_id 0 or an unknown role_id is denied, never promoted. As the owner of a RenewCMS site I want permissions to be a fixed catalogue and roles to be stored sets of (permission, scope) diff --git a/features/roles_permissions_roles_api.feature b/features/roles_permissions_roles_api.feature index 7be5f1e..003722b 100644 --- a/features/roles_permissions_roles_api.feature +++ b/features/roles_permissions_roles_api.feature @@ -1,4 +1,5 @@ Feature: Roles and permissions roles API: list, read, create, edit, delete and reassign roles, with write-time validation, no-escalation and activity + Superseded in part by roles_permissions_users_api: a user effective role is role_id only (user 10 is an Admin with a role_id, not a legacy admin), members count users plus open invites, and "Out of scope here" is now done: PUT /v1/user/{id}/role, invites with a role, pending-invite counting, role_has_members for invites and the ownership transfer exist. As the owner of a RenewCMS site I want to manage roles over /v1/role with the checks the design asks for diff --git a/features/roles_permissions_users_api.feature b/features/roles_permissions_users_api.feature new file mode 100644 index 0000000..88d6356 --- /dev/null +++ b/features/roles_permissions_users_api.feature @@ -0,0 +1,359 @@ +Feature: Roles and permissions users API: assign a role to a user, invite with a role, transfer ownership, and retire the legacy role string + + As the owner of a RenewCMS site + I want to give every user and every invite exactly one stored role, change it under the no-escalation rule, + and hand the Owner role to another member + So that Users shows real roles, nobody grants a role they do not cover, and the old owner/admin string is gone + + Background: + Given the spec of record is docs/design/screens/roles.md (sections 5, 7, 8, 9 and the tables "No-escalation feedback" and "HTTP status to copy") + and docs/design/routes.md "Roles and permissions" + And features/roles_permissions_foundation.feature provides the catalogue and the built-in roles, + features/roles_permissions_enforcement.feature the 403 contract (body "missing_permission\n", header X-Missing-Permission), + features/roles_permissions_content_scope.feature the GrantSet, and features/roles_permissions_roles_api.feature + the role routes, GrantSet.Covers, the escalation contract (body "escalation", header X-Escalation-Key) and the activity conventions + And "403" means status 403, body "missing_permission\n" and the header X-Missing-Permission, nothing stored, no audit event + And every other refusal is plain text from http.Error, one snake_case code, and writes nothing, not even the deletion of an expired invite + And success bodies are JSON with snake_case fields and Cache-Control "no-store" on the new routes + And the new refusals are: + | situation | status | body | extra header | + | user id unknown | 404 | user_not_found (existing apperr) | | + | role_id unknown (PUT role, invite) | 400 | invalid_role | | + | the target user holds the Owner role, or the role to give is the Owner role | 403 | role_locked (existing) | | + | change the role of your own account | 403 | own_role (existing) | | + | the current or the new role is above the caller's grants | 403 | escalation (existing) | X-Escalation-Key: first offending key | + | the target's role changed between read and write | 409 | user_role_changed | | + | transfer target unknown, self, unverified, or not a user | 400 | invalid_transfer | | + | confirm_email differs from the target's email | 400 | confirmation_mismatch | | + | delete a role that still has members, users or open invites | 409 | role_has_members (existing) | | + | an API key on a session-only route | 403 | apperr.SessionRequiredErrorMessage | | + And an undecodable body is 400 apperr.BodyErrorMessage, a failed validate tag is 400 with the validator text, a non-numeric id is 400 apperr.IdUint32ErrorMessage + And the request structs carry validate tags: PUT role "role_id" required, transfer "user_id" required and "confirm_email" required. + The invite body keeps "email" required,inviteemail and gains the optional "role_id" (absent or 0 means the Writer role) + And the routes are: + | route | key (role.Key constant) | session only | mounted in | + | PUT /v1/user/{id}/role | users.change_role | yes | NewUserRouter | + | POST /v1/user/invite | users.invite (unchanged) | no | NewUserRouter, with the rate limiter | + | POST /v1/user/invite/{id}/renew | users.invite (unchanged) | no | NewUserRouter, with the rate limiter | + | DELETE /v1/user/invite/{id} | users.invite (unchanged) | no | NewUserRouter, with the rate limiter | + | GET /v1/user | users.view (unchanged) | no | NewUserRouter | + | POST /v1/site/transfer-ownership | system.transfer_ownership | yes | NewSiteRouter, session group | + And the permission gate runs before the id, the body and any lookup. An API key on a session-only route gets 403 session_required before the gate + And handlers receive use cases only: ChangeUserRoleUseCase, TransferOwnershipUseCase, and the amended CreateInviteUseCase, RenewInviteUseCase, + ListUsersAndInvitesUseCase. They depend on domain interfaces only (user.Repository, invite.Repository, role.Repository, contenttype.Repository, audit.Recorder) + And the transfer handler lives in its own package (api/handlers/ownership), built by NewHandler(TransferOwnershipUseCase) and mounted by NewSiteRouter + And RoleRef is the one role summary shape {id, name, built_in, locked}. It is used by /auth/me access.role, by user items, by invite items and by the responses below + And a user's role is role_id, and nothing else. There is no fallback on a string + And the built-in roles are found by a single helper builtInRole(roles, name): GetByName, then built_in must be true, else role.ErrRoleNotFound + And the order of checks on PUT /v1/user/{id}/role is: session, gate users.change_role, id (400), body (400), load the target user (404), + the target holds a Locked role (403 role_locked), load role_id (400 invalid_role), the new role is Locked (403 role_locked), + the target is the caller (403 own_role), the target's current role covered by the caller (403 escalation), + the new role covered by the caller (403 escalation), same role (200, no write), write (409 user_role_changed on a stale read) + And the order of checks on POST /v1/user/invite is: gate users.invite, rate limiter, body (400), load the inviter, load the role (default Writer, 400 invalid_role), + the role is Locked (403 role_locked), the role covered by the caller (403 escalation), email already has an account (409), pending invite (409), + create, send the mail (the existing rollback on a send failure is unchanged) + And the order of checks on POST /v1/user/invite/{id}/renew is: the existing steps up to 404 and 409 (already accepted), + then the invite's role covered by the caller (403 escalation), then renew. DELETE /v1/user/invite/{id} needs no coverage + And the order of checks on POST /v1/site/transfer-ownership is: session, gate system.transfer_ownership, body (400), the target (400 invalid_transfer), + confirm_email (400 confirmation_mismatch), write (409 user_role_changed when the caller no longer holds the Owner role) + And the roles and people of the world (all verified, valid jwt cookie, token_version 0, role_id set, unless noted): + | role | grants | users | open invites | + | Owner | built-in, Locked, 34 keys | user 3 marta | none | + | Admin | built-in, 29 keys | 5 jonas, 10 lea, 12 sam (unverified) | none | + | Writer | built-in, 9 keys | 9 ines | invite 1 | + | Contributor | content.read=all, content.create, content.update=own | 41, 42, 43, 44 | invite 2 (expired), invite 3 (pending) | + | Intern | content.read=own | 46 | none | + | Content lead | content.read=all, content.update=all, content.publish=all, content.delete=all | 45 | none | + | People manager | users.view, users.invite, users.change_role, content.read=all, content.create, content.update=own, content.publish=own, schema.view | 31 | none | + | Viewer | users.view, roles.view | 32 | none | + | Inviter | users.invite | 33 | none | + And invite 1 is "new1@renew.example" for Writer, invite 2 is "old2@renew.example" for Contributor and expired, invite 3 is "new3@renew.example" for Contributor and pending, + invite 4 is accepted (user 9). Accepted invites are history and never count as members + And members, as counted for GET /v1/role, are: Owner 1, Admin 3, Writer 2, Contributor 6, Intern 1, Content lead 1, People manager 1, Viewer 1, Inviter 1 + + Scenario Outline: The gate decides who changes roles, invites and transfers + When sends + Then the result is + + Examples: + | caller | call | result | + | user 3 Owner | PUT /v1/user/41/role {"role_id": } | 200 | + | user 5 Admin | PUT /v1/user/41/role {"role_id": } | 200 | + | user 31 People manager | PUT /v1/user/41/role {"role_id": } | 200 | + | user 9 Writer | PUT /v1/user/41/role {"role_id": } | 403 users.change_role | + | user 9 Writer | PUT /v1/user/abc/role | 403 users.change_role (before the id is read) | + | user 32 Viewer | PUT /v1/user/999/role | 403 users.change_role (before any lookup) | + | user 5 Admin | PUT /v1/user/abc/role | 400 apperr.IdUint32ErrorMessage | + | API key created by user 5 | PUT /v1/user/41/role {"role_id": } | 403 apperr.SessionRequiredErrorMessage, nothing stored | + | no cookie | PUT /v1/user/41/role | 401 apperr.UnauthorizedErrorMessage | + | user 12 unverified Admin | PUT /v1/user/41/role | 403 apperr.VerificationRequiredErrorMessage | + | user 3 Owner | POST /v1/site/transfer-ownership {"user_id": 5, "confirm_email": } | 200 | + | user 5 Admin | POST /v1/site/transfer-ownership {"user_id": 10, "confirm_email": } | 403 system.transfer_ownership | + | user 9 Writer | POST /v1/site/transfer-ownership | 403 system.transfer_ownership (before the body) | + | API key created by user 3 | POST /v1/site/transfer-ownership {"user_id": 5, "confirm_email": } | 403 apperr.SessionRequiredErrorMessage, nothing stored | + | user 32 Viewer | POST /v1/user/invite {"email": "x@renew.example"} | 403 users.invite | + | user 33 Inviter | GET /v1/user | 403 users.view | + And the gate follows the role live: after Viewer gains users.change_role in the repository, the same cookie's next PUT passes the gate, with no re-login + + Scenario Outline: Changing a user's role + When sends PUT /v1/user//role with {"role_id": } + Then the result is + + Examples: + | caller | id | role | result | + | user 5 Admin | 41 | Writer | 200, the user item of user 41 with role Writer | + | user 5 Admin | 41 | Admin | 200 (an Admin covers Admin) | + | user 5 Admin | 10 | Contributor | 200 (demoting another Admin is allowed, Admin covers Admin) | + | user 3 Owner | 5 | Content lead | 200 | + | user 31 People manager | 41 | Intern | 200 (Contributor and Intern are both covered) | + | user 31 People manager | 41 | Writer | 403 escalation, X-Escalation-Key content.update (Writer updates at all, the caller at own) | + | user 31 People manager | 41 | Content lead | 403 escalation, X-Escalation-Key content.update | + | user 31 People manager | 45 | Intern | 403 escalation, X-Escalation-Key content.update (the current role Content lead is not covered) | + | user 31 People manager | 5 | Intern | 403 escalation, X-Escalation-Key content.update (the current role Admin is not covered) | + | user 5 Admin | 41 | Owner | 403 role_locked | + | user 3 Owner | 41 | Owner | 403 role_locked (one owner, transfer instead) | + | user 5 Admin | 3 | Admin | 403 role_locked (the owner cannot be changed) | + | user 3 Owner | 3 | Admin | 403 role_locked (the Owner role is checked before the own account) | + | user 5 Admin | 5 | Writer | 403 own_role | + | user 31 People manager | 31 | Intern | 403 own_role | + | user 5 Admin | 999| Writer | 404 user_not_found | + | user 5 Admin | 41 | 999 | 400 invalid_role | + | user 5 Admin | 41 | (no role_id) | 400 (validate tag required) | + | user 5 Admin | 41 | 0 | 400 (validate tag required) | + | user 5 Admin | 12 | Writer | 200 (an unverified user may be given a role) | + And a refused call changes no role_id, bumps no token_version and records no event + And the success body is the user item of GET /v1/user: id, kind "user", username, display_name, email, role (RoleRef), status "accepted", created_at + + Scenario: Giving the same role again is a no-op + When user 5 sends PUT /v1/user/41/role with the Contributor id + Then the status is 200 and the body is the unchanged user item + And no write happens and no event is recorded + And a caller who does not cover the Contributor role (a Viewer-like role with users.change_role only) still gets 403 escalation, because the current role is checked first + + Scenario: A role change takes effect at once and keeps sessions and keys + Given user 41 has a valid cookie and an API key, and holds the Contributor role + When user 5 moves user 41 to the Writer role + Then user 41's token_version is unchanged and the same cookie and the same API key still authenticate + And user 41's next request is decided by the Writer grants, with no re-login + And GET /v1/auth/me for user 41 shows access.role Writer + + Scenario: Two concurrent changes on the same user give one winner + Given user 5 and user 3 both read user 41 on the Contributor role + When both send a PUT with a different new role at the same time + Then exactly one answers 200 and the other 409 user_role_changed + And the stored role is the winner's + And the repository UpdateRole(id, from, to) is a compare-and-swap on the role the use case read + + Scenario: A role deleted meanwhile is an invalid role, not a dangling reference + Given the custom role "Temp" has no member and is deleted between the lookup and the write of a PUT + Then the answer is 400 invalid_role and user 41 keeps the Contributor role + + Scenario: The Owner role always has exactly one verified holder + Given two registrants hold the Owner role and neither is verified + When the first one verifies + Then the second one cannot be verified while a verified Owner exists, and the answer is the existing verification refusal + And the repository performs the check and the verification in one transaction, so two concurrent verifications leave one winner + Then no call of this feature leaves zero or two verified holders of the Owner role + And PUT role to or from the Owner is 403 role_locked, DELETE and edit of the Owner role stay role_locked, reassign from or to the Owner role stays refused + And only POST /v1/site/transfer-ownership moves the Owner role, in one transaction + + Scenario Outline: Inviting with a role + When sends POST /v1/user/invite with + Then the result is + + Examples: + | caller | body | result | + | user 5 Admin | {"email": "a@renew.example"} | 201, role is the Writer RoleRef (default), one "invitation" mail sent | + | user 5 Admin | {"email": "a@renew.example", "role_id": 0} | 201, role Writer | + | user 5 Admin | {"email": "a@renew.example", "role_id": } | 201, role Contributor | + | user 5 Admin | {"email": "a@renew.example", "role_id": } | 201, role Admin | + | user 3 Owner | {"email": "a@renew.example", "role_id": } | 201 | + | user 5 Admin | {"email": "a@renew.example", "role_id": } | 403 role_locked, no invite, no mail | + | user 5 Admin | {"email": "a@renew.example", "role_id": 999} | 400 invalid_role, no invite, no mail | + | user 31 People manager | {"email": "a@renew.example", "role_id": } | 201 | + | user 31 People manager | {"email": "a@renew.example"} | 403 escalation, X-Escalation-Key content.update (the default Writer is above the caller) | + | user 31 People manager | {"email": "a@renew.example", "role_id": } | 403 escalation, X-Escalation-Key content.update | + | user 33 Inviter | {"email": "a@renew.example"} | 403 escalation, X-Escalation-Key content.read | + | user 5 Admin | {"email": "old2@renew.example", "role_id": 999} | 400 invalid_role, and expired invite 2 is not deleted | + | user 5 Admin | {"email": "old2@renew.example", "role_id": } | 201, expired invite 2 is replaced by a new invite for Writer | + | user 5 Admin | {"email": "new3@renew.example"} | 409 apperr.PendingInviteErrorMessage (existing) | + | user 5 Admin | {"email": "ines@renew.example"} (user 9's address) | 409 apperr.EmailHasAccountErrorMessage (existing) | + | user 5 Admin | {"email": "not-an-email"} | 400 (validate tag inviteemail) | + And the invite response is {id, kind "invite", email, status, role (RoleRef), created_at, expires_at} + And an API key may still create invites, and the no-escalation check then uses the live role of the key's creator + + Scenario: Renewing keeps the invite's role + Given invite 2 is expired and for Contributor + When user 5 sends POST /v1/user/invite/2/renew + Then the status is 200, the invite is pending again, its role is still Contributor and the response carries the role + And a new code is mailed as before and the old code no longer works + + Scenario Outline: Renewing and cancelling against the no-escalation rule + When sends + Then the result is + + Examples: + | caller | call | result | + | user 31 People manager | POST /v1/user/invite/3/renew (Contributor) | 200 | + | user 31 People manager | POST /v1/user/invite/1/renew (Writer) | 403 escalation, X-Escalation-Key content.update, nothing renewed, no mail | + | user 31 People manager | DELETE /v1/user/invite/1 (Writer) | 204 (cancelling removes power, no coverage needed) | + | user 5 Admin | POST /v1/user/invite/999/renew | 404 apperr.InviteNotFoundErrorMessage (before any coverage check) | + | user 5 Admin | POST /v1/user/invite/4/renew (accepted) | 409 apperr.InviteAlreadyAcceptedErrorMessage | + + Scenario Outline: Accepting an invite creates a user with the invite's role + Given a pending invite for "new3@renew.example" for + When the client sends POST /v1/auth/invite/accept with its code, username "kim" and a valid password + Then the status is 200 and the stored user "kim" has the role_id of + And the user is verified and has no legacy role string + And GET /v1/auth/me for "kim" shows access.role with that role's grants + + Examples: + | role | + | Writer | + | Contributor | + | Admin | + And an invite whose role_id is 0 or names a role that no longer exists is refused as apperr.InviteInvalidErrorMessage, no user is created, and a failed invite.accept event is recorded + And the invite's role is read at accept time, so editing the role's grants before the accept changes what the new user gets + + Scenario: Open invites count as role members + When user 5 sends GET /v1/role + Then Writer has members 2 (user 9 and invite 1) and Contributor has members 6 (users 41-44, expired invite 2, pending invite 3) + And accepted invite 4 counts for nobody + And the members of a role are counted by role_id on users plus role_id on invites with accepted_at NULL, with no GetAll scan + + Scenario Outline: Deleting and moving roles that hold invites + Given the custom role "Temp" has no user and one open invite () + When user 5 sends DELETE /v1/role/ + Then the answer is 409 role_has_members and nothing is deleted + + Examples: + | state | + | pending | + | expired | + And after POST /v1/role//reassign {"target_role_id": } the answer is {"moved": 1}, the invite's role is Intern and DELETE answers 204 + And moved counts users and invites together, so Contributor reassigned to Intern answers {"moved": 6} + And the reassign event params moved is that total + And an accepted invite that still names the role does not block the delete + + Scenario: Transferring ownership + Given user 3 holds the Owner role and user 5 is a verified Admin with email "jonas@renew.example" + When user 3 sends POST /v1/site/transfer-ownership with {"user_id": 5, "confirm_email": "jonas@renew.example"} + Then the status is 200 and the body is {"owner": {"id": 5, "name": ...}, "former_owner": {"id": 3, "name": ..., "role": the Admin RoleRef}} + And user 5 holds the Owner role and user 3 holds the Admin role, in one transaction + And both users keep their token_version, cookies and API keys + And user 3's next request is decided by the Admin grants and user 5's by the Owner grants + And GET /v1/role shows Owner members 1 and Admin members 3 (jonas left Admin, marta joined it) + And a second POST by user 3 answers 403 missing_permission with X-Missing-Permission system.transfer_ownership + + Scenario Outline: Transfer refusals + When user 3 sends POST /v1/site/transfer-ownership with + Then the result is + + Examples: + | body | result | + | {} | 400 (validate tag required) | + | {"user_id": 5} | 400 (validate tag required on confirm_email) | + | {"user_id": 999, "confirm_email": "x@renew.example"} | 400 invalid_transfer | + | {"user_id": 3, "confirm_email": } | 400 invalid_transfer (yourself) | + | {"user_id": 12, "confirm_email": } | 400 invalid_transfer (unverified) | + | {"user_id": 5, "confirm_email": "JONAS@renew.example"} | 400 confirmation_mismatch (exact match) | + | {"user_id": 5, "confirm_email": ""} | 400 (validate tag required) | + And a refused call changes no role_id and records no event + And an invite id is never a valid target, only members can be chosen + + Scenario: Two simultaneous transfers give one new owner + Given user 3 sends a transfer to user 5 and a transfer to user 10 at the same time + Then exactly one answers 200 and the other 409 user_role_changed + And exactly one verified user holds the Owner role afterwards + And the repository TransferOwnership(from, to, ownerRoleID, formerRoleID) updates both rows in one transaction and fails the whole transaction when from no longer holds the Owner role + + Scenario: The list of users and invites shows roles + When user 5 sends GET /v1/user + Then user items have exactly the keys id, kind, username, display_name, email, role, status, created_at, and role is a RoleRef + And invite items have exactly the keys id, kind, email, role, status, created_at, and role is the invite's RoleRef + And user 3's role is {"id": , "name": "Owner", "built_in": true, "locked": true} + And the roles are read with one role List call, not one per row + And a user whose role cannot be resolved shows role null + And no item carries password, password_reset_code, verification_code, verification_expiration, password_reset_expiration, token_version, code, hashed_code, invited_by or accepted_at + + Scenario: The public user shape has no role string + Then PublicUser is exactly id, username, email, is_verified, created_at, updated_at and display_name, in every response that includes a user + And GET /v1/auth/me keeps access.role as its only role field, and holds the keys id, username, email, is_verified, created_at, updated_at, display_name, author and access + And POST /v1/auth/login, /verify, /register and /invite/accept carry no role key + + Scenario: The legacy role string is gone from the code + Then the type user.Role, the constants user.RoleOwner and user.RoleAdmin and the field user.User.Role do not exist + And resolveRole(role_id) is the only resolution: role_id 0 gives role.ErrRoleNotFound, and legacyBuiltInNames does not exist + And CheckPermission gives an empty GrantSet for role.ErrRoleNotFound, as before, and GetMyAccess returns the error + And ListUsersAndInvites, memberCounts, CreateUser, AcceptInvite, the GORM User model, the user mapper and the user repository read and write no role string + And userRepository.Create writes role_id, with NULL when the domain role_id is 0 + And the builtin "user" content type keeps a field "role", now a text field that is not required and has no options + + Scenario: Registration creates the Owner + Given the user repository is empty + When the client sends POST /v1/auth/register with a valid body + Then the stored user has the role_id of the built-in Owner role and is unverified, as before + And when the Owner role is not seeded the answer is 500 "Internal server error" and no user is created + + Scenario: A user without a usable role is denied, never promoted + Given user 13 has role_id 0 and user 14 has role_id 99 (no such role) + Then a gated route answers 403 missing_permission for both + And GET /v1/auth/me answers 500 "Internal server error" for both + And neither is counted in any role's members and both show role null in GET /v1/user + + Scenario Outline: The migration gives every user and every invite a role_id + Given a database that predates this slice, with a users.role column holding and no role_id + When Migrate runs + Then the user has the role_id of + And running Migrate again changes nothing + And a user created after the upgrade with no role_id is never promoted by a later Migrate, and neither is an invite without a role_id + And the legacy steps only touch rows whose legacy users.role value still decides them, and are skipped when the column is absent + + Examples: + | legacy | role | + | "owner" (lowest id of the owners) | Owner | + | "owner" (any other id) | Admin | + | "admin" | Admin | + | "" (lowest id) | Owner | + | "" (any other id) | Admin | + | "something-else" | Writer | + And a user that already has a role_id keeps it, even when the legacy string disagrees + And every existing invite without a role_id gets the Admin role, accepted ones included + And the backfill of created_by attributes orphans to the lowest user id holding the Owner role (no longer "role = 'owner'") + And a fresh database never has a users.role column, and the legacy steps are skipped when the column is absent + And a database that still has the column keeps it, untouched and unused (never dropped by this slice) + And the steps run in this order: AutoMigrate, seed built-in roles, legacy role backfill, invite role backfill, created_by backfill, the rest + + Scenario: The member counts are one bulk query + Then role.Repository gains MemberCounts() (map[uint32]int, error): users grouped by role_id plus invites with accepted_at NULL grouped by role_id, two statements for any number of roles + And roleViewer.views and DeleteRole use it, and no role use case calls user.Repository.GetAll + And Delete re-counts users and open invites inside its transaction and returns role.ErrRoleHasMembers + And ReassignMembers(from, to) moves users and open invites in one transaction and returns their sum + And the persistence repository keeps the SQL portable across SQLite, MySQL/MariaDB and PostgreSQL + + Scenario: Activity events for role assignment and ownership + Given the audit recorder is enabled + When user 5 moves user 41 from Contributor to Writer + Then one user.change_role event is recorded in category users, outcome success, actor user 5, target {"type": "user", "id": "41", "name": the username} + And its changes hold {"field": "role", "kind": "text", "before": "Contributor", "after": "Writer"} and its params from and to hold the two role ids + When user 3 transfers the Owner role to user 5 + Then one user.transfer_ownership event is recorded in category users, security true, actor user 3, target user 5, params from 3 and to 5 + When user 5 creates an invite for Contributor and the invitee accepts it + Then invite.create and invite.accept carry the params role_id and role (the role name), and invite.renew is unchanged + And a refusal (403, 404, 400, 409) records nothing, a no-op records nothing + And a storage failure at the write records the same event with outcome failed and a failure reason, through recordFailure + And audit gains ActionUserChangeRole "user.change_role", ActionUserTransferOwnership "user.transfer_ownership", FieldRole "role", ParamRole "role" and ParamRoleID "role_id" + And GET /v1/activity?category=users returns the new events to a caller whose activity.view is all + + Scenario: Layering holds + Then internal/domain/user and internal/domain/invite import the stdlib and other domain packages only + And the use cases import no persistence, mailer, models, gorm, net/http or api package + And api/router.go uses role.KeyUsersChangeRole and role.KeySystemTransferOwnership, and no string equal to a catalogue key + And no handler imports persistence, mailer, models or gorm + And the handlers validate with validate tags and validator.Validate.Struct, never by hand + + Scenario: Out of scope here + Then there is no SPA change (slices 5-7), and the SPA that reads a role string is adapted there + And there is no DELETE /v1/user/{id}, "Remove user" has no route yet + And the physical users.role column is not dropped + And TestRouterRoutesAreInTheCatalogue was already failing and is reported, not fixed (the two new routes are only added to its exemption list) diff --git a/features/roles_permissions_users_api.qa.md b/features/roles_permissions_users_api.qa.md new file mode 100644 index 0000000..7ffc2f7 --- /dev/null +++ b/features/roles_permissions_users_api.qa.md @@ -0,0 +1,129 @@ +# QA procedure: roles_permissions_users_api + +Package: `test` (black-box). New fakes need unique names, for example `usersAPIRoleRepo`, `usersAPIUserRepo`, `usersAPIInviteRepo`, `usersAPIRecorder`. +Reuse `mintCookie`, `assertGate`, `assertRefusal`, `assertEscalation`, the API key minting helper, `grantsOf`, `openMigratedRolesDB` +and the fakes of `test/role_support_test.go`, `test/roles_api_world_test.go` and `test/roles_content_scope_world_test.go`. +Do not copy any scope-rank comparison into tests: call `GrantSet.Covers` and the exported helpers. +No live server, no real SQLite file, no real SMTP. + +## Deferred to later slices + +- 5-7: SPA (Users: role badges, Change role dialog, role picker in the invite dialog, Transfer ownership dialogs, profile role card, role-aware shell). + The SPA reacts to the status, the body code and the X-Missing-Permission / X-Escalation-Key headers. +- A user-removal route, which must refuse the Owner. +- Dropping the physical users.role column (a later release). + +## Design notes for the user + +- docs/design names POST /v1/invite and GET /v1/me. The code routes are POST /v1/user/invite and GET /v1/auth/me and stay. POST /v1/site/transfer-ownership is the design's route. +- docs/design names the events user.role_changed and site.ownership_transferred. The code convention is user.change_role and user.transfer_ownership (category users). +- The design copy for "Owner can't be changed or removed" and "There is one owner" maps onto the existing 403 role_locked. The copy of the change-role dialog should key on that code. +- New codes with no design copy: invalid_role, user_role_changed, invalid_transfer, confirmation_mismatch. +- The design shows "Assigned by Marta Alves" on the profile role card. This slice stores no assigner. It is a design question for slice 7. +- Agents never edit docs/design: these items are for you to relay to Claude Design. + +## Inventory (route, key, session only, checks after the gate, Owner / Admin / Writer) + +| Route | Key | Session only | After the gate | Owner / Admin / Writer | +|---|---|---|---|---| +| PUT /v1/user/{id}/role | users.change_role | yes | id, body, user 404, Locked current, role 400, Locked new, own account, covers current, covers new, no-op, CAS write | ok / ok / 403 | +| POST /v1/user/invite | users.invite | no | body, role (default Writer) 400, Locked, covers, email has account, pending, create, mail | ok / ok / 403 | +| POST /v1/user/invite/{id}/renew | users.invite | no | id, 404, 409, covers the invite's role, renew, mail | ok / ok / 403 | +| DELETE /v1/user/invite/{id} | users.invite | no | id, 404, delete | ok / ok / 403 | +| GET /v1/user | users.view | no | none, roles read once | ok / ok / 403 | +| POST /v1/site/transfer-ownership | system.transfer_ownership | yes | body, target 400, confirm_email 400, CAS transaction | ok / 403 / 403 | +| POST /v1/auth/invite/accept | public | no | invite's role read at accept | as before | +| POST /v1/auth/register | public | no | creates the Owner (role_id) | as before | +| GET /v1/auth/me | none | as before | no top-level role; access.role only | as before | +| GET /v1/role, DELETE /v1/role/{id}, POST /v1/role/{id}/reassign | as before | as before | members include open invites; reassign moves invites | as before | + +## Test infrastructure first + +1. Role fake (extends `mockRoleRepo`): adds `MemberCounts` (reads the shared user and invite fakes), `Delete` and `ReassignMembers` that count open invites, call counters for `List`, `Get`, `GetByName`, `MemberCounts`. Remove nothing else. +2. User fake: `GetAll`, `Create` (stores role_id), `UpdateRole(id, from, to)` compare-and-swap (`user.ErrRoleChanged`, `user.ErrUserNotFound`, `role.ErrRoleNotFound` when the new role is gone), `TransferOwnership(from, to, ownerRoleID, formerRoleID)` in one step, a hook between read and write for race tests, a switchable failure per method. Users 3, 5, 9, 10, 12 (unverified), 13 (role_id 0), 14 (role_id 99), 31, 32, 33, 41-46. +3. Invite fake: carries `RoleID`, `GetAll`, `Create`, `Renew`, `AcceptWithUser` (creates the user with the invite's role_id), invites 1-4 of the Background. +4. Content type fake with "events" and "venues", a recording `audit.Recorder`, a mail fake that records template, recipient and data. +5. HTTP harness: `validator.InitValidator()`, a fresh `chi.NewRouter()` mirroring `NewUserRouter`, the transfer route of `NewSiteRouter` and `NewAuthRouter` in api/router.go (Verifier, apiKeyAuth equivalent, AuditContext, Authenticator, RequireCurrentToken, RequireVerifiedUser, RequireSession on session-only routes, `RequirePermission(...)` with the key constants), over real use cases and the fakes. +6. Helper `userItem(t, rec)` and `roleRefOf(t, body)` to assert shapes, and `seededUser(id, builtInName)` so fixtures set `RoleID` instead of a string. + +## A. Domain (no fakes) + +7. `user.User` has no `Role` field and `user.Role`, `RoleOwner`, `RoleAdmin` do not exist (hygiene test over the source tree). `invite.Invite` carries `RoleID`. +8. New sentinels exist and map with `errors.Is`: `user.ErrRoleChanged`, `user.ErrInvalidTransfer`, `invite.ErrInvalidRole` (or `role.ErrUnknownRole`), `user.ErrConfirmationMismatch`. Escalation is read with `errors.As` on `role.EscalationError`. +9. `internal/domain/user` and `internal/domain/invite` import only the stdlib and domain packages. `audit` constants `ActionUserChangeRole`, `ActionUserTransferOwnership`, `FieldRole`, `ParamRole`, `ParamRoleID` exist. +10. `GrantSet.Covers` rows used by this slice (People manager against Writer, Content lead, Admin, Intern, Contributor): first violation key `content.update` for the first three, none for Intern and Contributor. + +## B. Use cases (fakes only) + +11. `ChangeUserRole`: every row of the "Changing a user's role" Outline with `errors.Is` / `errors.As`. Assert the order with counters: for a refusal before the write, `UpdateRole` is never called. The no-op row: no write, no event. Race: the hook changes the target's role between read and write, expect `user.ErrRoleChanged`. Role deleted between lookup and write: `role.ErrRoleNotFound` maps to invalid_role. Events: change_role has changes role before/after names and params from/to; refusals and no-ops record nothing; a repository failure records a failed event. +12. `TransferOwnership`: happy path (former owner gets the Admin role found by `builtInRole`, new owner gets the Owner role id, event with security true), all refusals of the Outline, a CAS failure, a repository failure (failed event, nobody changed), exactly one Owner afterwards. +13. `CreateInvite`: every row of the "Inviting with a role" Outline. A refused call does not delete an expired invite and does not send a mail. The default role is the built-in Writer found by `builtInRole`. The stored invite has `RoleID`. invite.create params role_id and role. Existing failure paths (email has account, pending, mail failure rolls back) unchanged. +14. `RenewInvite`: keeps the role, covers check after 404 and 409, no mail on a refusal. `CancelInvite`: no coverage check. +15. `AcceptInvite`: creates the user with the invite's `RoleID` and no legacy string. `RoleID` 0 or unknown role gives `invite.ErrInviteInvalid`, no user, failed event. Grants edited before the accept are what the user gets. +16. `CreateUser`: registration stores the Owner `RoleID`. Unseeded Owner role: error and no user. +17. `ListUsersAndInvites`: role per row from one `List` call (counter), `null` for an unresolvable user, invite rows carry their role. +18. `GetMyAccess`, `CheckPermission`, `resolveRole`: `RoleID` 0 gives `ErrRoleNotFound`, empty GrantSet in `CheckPermission`, an error in `GetMyAccess`. No legacy fallback rows remain. +19. `ListRoles` / `GetRole` / `DeleteRole` / `UpdateRole`: members come from `MemberCounts` (counter 1, `GetAll` counter 0), include open invites, exclude accepted ones. `DeleteRole` is blocked by a pending or an expired invite. `ReassignRoleMembers` returns users plus invites and records `moved` with the total. +20. Activity: the new actions and params validate through `audit.Event.Validate()`. + +## C. HTTP + +21. Gate matrix: every row of the first Outline, one subtest per row, stores reset per row. API key on session-only routes gets `apperr.SessionRequiredErrorMessage` before the gate. Live-role scenario with the same cookie. +22. Each Outline of the feature: change role, invite with a role, renew and cancel, accept, transfer refusals. Assert status, exact body text, headers (`X-Missing-Permission`, `X-Escalation-Key`), the stores after the call and the recorder. +23. Order: for a Writer, `PUT /v1/user/abc/role` and the transfer with no body are 403 before 400. For user 31 an unknown `role_id` is 400 before an escalation 403 on a higher role, and a Locked current role is 403 before an unknown `role_id`. For the invite, a refused role leaves expired invite 2 in place. +24. Shapes: PUT returns the user item (RoleRef), invite create returns role, transfer returns owner and former_owner, `GET /v1/user` keys exactly as in the feature, `PublicUser` has 7 keys in login, verify, register, accept and `/auth/me`, with `access.role` intact. +25. Live effect: after PUT, the target's next request on a gated route, and its API key, see the new grants with the same cookie. After the transfer, user 3 is refused `system.transfer_ownership` and holds the Admin grants; user 5's key holds the Owner grants. +26. Concurrency: the race scenarios with a hook (one 200, one 409 user_role_changed). +27. Loads per request: a gated request loads the user at most once and the role once, `MemberCounts` 0 times on the permission path. +28. Regression: the foundation, enforcement, content-scope and roles-api suites stay green after fixtures move to `RoleID`. + +## D. Persistence and migration (`openMigratedRolesDB`) + +29. `UserRepository`: `Create` writes `role_id` (NULL for 0), the mapper has no role string; `UpdateRole` is a compare-and-swap (two calls with the same `from`: one wins) and returns `ErrRoleChanged` / `ErrUserNotFound` / `role.ErrRoleNotFound`; `TransferOwnership` updates both rows in one transaction, rolls both back when `from` is no longer the owner or `to` is unverified or missing, and two concurrent transfers give one winner; failure paths (DB closed). +30. `InviteRepository`: `role_id` column on `invites` after `Migrate`, `Create` / `Renew` keep it, `AcceptWithUser` stores the user's `role_id` and marks the invite accepted in one transaction. +31. `RoleRepository.MemberCounts`: users (verified or not), pending and expired invites count, accepted invites do not, NULL role_id users do not; a role with none is absent or 0; at most 2 SQL statements for any number of roles (count with a GORM callback). `Delete` refuses with `ErrRoleHasMembers` for a user or an open invite and succeeds for an accepted one. `ReassignMembers` moves users and open invites and returns the sum. +32. Migration: build a legacy database with `ALTER TABLE users ADD COLUMN role VARCHAR(32) NOT NULL DEFAULT ''` plus rows for each Outline row, run `Migrate`: role_ids as in the feature, invites get Admin, `BackfillCreatedBy` finds the owner by role_id, a second `Migrate` is a no-op, the column is still there. A fresh database has no `users.role` column and `Migrate` succeeds. `Migrate` with no users succeeds. +33. `sqlite_schema_unchanged_test.go`: the expected schema gains `invites.role_id` and loses `users.role` on a fresh database. + +## Existing tests that must change (recheck with `go vet -tags nofrontend ./test/...`) + +| File (cluster) | Change | +|---|---| +| Every fixture that builds `user.User{Role: user.RoleOwner|RoleAdmin}` or `"owner"`/`"admin"`: 44 files, about 217 occurrences, for example `user_roles_test.go`, `invitations_test.go`, `invite_management_test.go`, `get_current_user_http_test.go`, `api_keys_*_test.go`, `media_purge_http_test.go`, `update_http_test.go`, `activity_log_backend_http_test.go`, `session_revocation_http_test.go`, `custom_content_types_support*_test.go`, `image_variants_env_test.go`, `author_support_test.go`, `seo_data_support_test.go`, `mysql_server_test.go`, `postgres_server_test.go` | Set `RoleID` to the seeded id through `seededUser`. Delete the `Role` field assignments. Assertions on a `role` string become assertions on `RoleRef` or are removed | +| `roles_permissions_foundation_test.go`, `roles_permissions_foundation_http_test.go` | Delete the "falls back to the built-in role named by the legacy role" rows and `BackfillUserRoleIDs` scenarios. Replace with the role_id 0 and role_id 99 denial rows and the new migration tests | +| `roles_permissions_enforcement*_test.go` (legacy rows, "no role_id and a legacy string other than owner or admin") | Replace with the role_id 0 rows | +| `roles_api_world_test.go`, `roles_api_usecases_test.go`, `roles_api_http_test.go`, `roles_api_http_write_test.go`, `roles_api_persistence_test.go`, `roles_api_hardening_test.go`, `roles_api_domain_test.go` | User 10 (role_id 0, legacy admin) gets `RoleID` Admin; Admin members stay correct; add `MemberCounts`, invites in `members`, and `GetAll` counter 0 | +| `role_support_test.go`, `roles_content_scope_world_test.go`, `roles_content_scope_support_test.go`, `role_repository_gaps_test.go`, `role_hardening_test.go` | `mockRoleRepo` gains `MemberCounts`; `Delete` and `ReassignMembers` include open invites; the user fake gains `UpdateRole` and `TransferOwnership`; the invite fake gains `RoleID` | +| `user_response_field_hygiene` tests (and any `publicuser` shape test) | Eight keys become seven (no `role`) | +| `user_display_name_test.go`, `get_current_user_http_test.go` | `/auth/me` top-level `role` assertions removed; `access.role` kept | +| `activity_usecases_test.go`, `activity_domain_test.go` | New actions, params and field | +| tests of the builtin `user` type (`schema_builtin_view` tests, `builtin_type` tests) | The `role` field is text, not required, no options | +| `sqlite_schema_unchanged_test.go` | See item 33 | +| `api_catalogue_drift_test.go:52` | Add `PUT /v1/user/{id}/role` and `POST /v1/site/transfer-ownership` to `routesNotYetInTheCatalogue`. The pre-existing failure of `TestRouterRoutesAreInTheCatalogue` is not this slice's: report, do not fix | +| `web/src` Vitest specs and kits that mock `role: 'owner'` | Untouched here (the SPA is slices 5-7). They stay green because `/v1` is mocked. Report them as "not run against the new API" | + +## Existing features and docs to amend + +- features/user_roles.feature: "role is exactly owner or admin", "AcceptInvite creates role admin", "/auth/me returns role", "users list role", "no endpoint transfers a role": add "superseded by roles_permissions_users_api" notes in place. +- features/invitations.feature and features/invite_management.feature: invite body, item and response keys gain `role`; the item key lists. +- features/get_current_user.feature and features/user_response_field_hygiene.feature: no `role` key; seven public fields. +- features/roles_permissions_foundation.feature: the legacy fallback scenarios, "users gain a nullable role_id; the legacy string is unchanged", the backfill scenarios. +- features/roles_permissions_enforcement.feature: lines on the legacy resolution and the unresolvable role. +- features/roles_permissions_roles_api.feature: the Background line "else the built-in named by the legacy owner/admin string", user 10 as legacy admin, and "Out of scope here" (pending-invite counting and role_has_members for invites: now done). +- features/schema_builtin_view.feature: the builtin user type's role field. +- features/activity-log-producers.feature and .qa.md: the two actions and the invite params. +- features/users_screen.feature and features/users_row_actions.feature (SPA): note that the `role` string they read becomes a RoleRef, to be handled in slices 5-7. +- docs/api.md: Users and invites table, new routes, shapes, codes, headers, activity, members of a role, `/auth/me` and the seven public fields. docs/architecture.md: repository methods. web/src/data/apiCatalogue.ts and the generated llms docs (`task build:llms`): the two new routes and the changed shapes. +- docs/configuration.md: no new env var. +- docs/design: for you to relay, not edited by agents (see Design notes). + +## Manual verification + +- Copy a real database from a pre-slice-4 build, run `task dev:api` on the copy: every user has a role_id, every invite an Admin role_id, the `users.role` column is still there, the activity log and created_by are intact. +- As the Owner: invite with the default (Writer) and with Contributor, accept in a real browser from the mailed link, check the new user's role and `/auth/me`. Real SMTP delivery is a manual item. +- As an Admin: change a Contributor to Writer and then to Admin, try Owner (role_locked) and your own role (own_role). As a People manager: try a Writer (escalation with the key in X-Escalation-Key). +- Transfer ownership between two real sessions: the old owner's tab keeps working as Admin without re-login, the new owner's tab gains Owner permissions, the old owner's API key is an Admin key. +- Delete a role that has only an expired invite (409), move members, delete (204). +- Run the migration on MySQL/MariaDB and PostgreSQL with Docker (the Docker QA steps run for real here): the legacy backfill, the invite `role_id`, the CAS update and the transfer transaction behave like SQLite. +- Known risk to check by hand: starting the previous binary on an upgraded database is not supported (post-upgrade users have `users.role` empty). +- No secret appears in any scenario. Env vars are referenced by name only (JWT_SECRET, SMTP_HOST). diff --git a/features/schema_builtin_view.feature b/features/schema_builtin_view.feature index ce4bc88..1950412 100644 --- a/features/schema_builtin_view.feature +++ b/features/schema_builtin_view.feature @@ -1,4 +1,5 @@ Feature: Read-only view of a built-in content type + Superseded in part by roles_permissions_users_api: the builtin user type role field is a plain optional text field with no options (the role is served as a RoleRef by the users API). As an owner or admin I want to open a built-in type from the Schema list and read its fields So that I can see the rules RenewCMS applies without being able to change them diff --git a/features/user_response_field_hygiene.feature b/features/user_response_field_hygiene.feature index aac5ab3..f83134a 100644 --- a/features/user_response_field_hygiene.feature +++ b/features/user_response_field_hygiene.feature @@ -1,4 +1,5 @@ Feature: API responses never expose a user's secret fields + Superseded in part by roles_permissions_users_api: the public user has seven fields (id, username, email, is_verified, created_at, updated_at, display_name); the role key is gone from every response that includes a user. As the maintainer of RenewCMS I want every HTTP response that includes user data to carry only public fields, never a diff --git a/features/user_roles.feature b/features/user_roles.feature index af19d70..590373e 100644 --- a/features/user_roles.feature +++ b/features/user_roles.feature @@ -1,5 +1,6 @@ Feature: Users have a role, owner or admin, and only the owner can invite Superseded in part by roles_permissions_enforcement: the owner and admin 403 clauses now answer 403 "missing_permission" with the header X-Missing-Permission, decided by the caller's role permissions. An Admin can now invite and write sites, and a role without the needed permission is refused on routes that were open to every verified user. + Superseded in part by roles_permissions_users_api: a user has one stored role, role_id, and the user.Role type with the "owner" and "admin" strings no longer exists. AcceptInvite creates the user with the role of the invite (Writer by default, set by the inviter), not Admin. GET /v1/auth/me carries no top-level role (access.role only), the users list shows role as a RoleRef {id, name, built_in, locked} or null, and PUT /v1/user/{id}/role and POST /v1/site/transfer-ownership now exist, so "no endpoint transfers a role" no longer holds. As the person who registered the workspace I want my account to be the owner and everyone I invite to be an admin diff --git a/features/users_row_actions.feature b/features/users_row_actions.feature index c8dc3b5..d09b443 100644 --- a/features/users_row_actions.feature +++ b/features/users_row_actions.feature @@ -1,4 +1,5 @@ Feature: Users row actions: Cancel invite and Renew invite + Note from roles_permissions_users_api: invite and user items carry role as a RoleRef, and renewing an invite above the caller grants is 403 escalation. The screen is adapted in the SPA slices 5-7. As the owner of a RenewCMS workspace I want to cancel a pending invite and renew an expired one from the Users list diff --git a/features/users_screen.feature b/features/users_screen.feature index 904e5a2..69614e1 100644 --- a/features/users_screen.feature +++ b/features/users_screen.feature @@ -1,4 +1,5 @@ Feature: Users screen and InviteUserModal + Note from roles_permissions_users_api: the role the users list returns is now a RoleRef {id, name, built_in, locked} (or null), not a string. The screen is adapted in the SPA slices 5-7. As a verified RenewCMS user in the shared workspace I want /users to list everyone who has an account or an open invite, and let me invite someone diff --git a/internal/domain/audit/auditDomain.go b/internal/domain/audit/auditDomain.go index 938ca32..d1f408b 100644 --- a/internal/domain/audit/auditDomain.go +++ b/internal/domain/audit/auditDomain.go @@ -60,57 +60,59 @@ const ( ) const ( - ActionLogin = "auth.login" - ActionLoginFailed = "auth.login_failed" - ActionLogout = "auth.logout" - ActionPasswordResetRequest = "auth.password_reset_requested" - ActionPasswordResetDone = "auth.password_reset_completed" - ActionAPIKeyCreate = "api_key.create" - ActionAPIKeyRevoke = "api_key.revoke" - ActionRetentionChanged = "system.activity_retention_changed" - ActionCleanup = "system.activity_cleanup" - ActionArticleCreate = "article.create" - ActionArticleEdit = "article.edit" - ActionArticlePublish = "article.publish" - ActionArticleUnpublish = "article.unpublish" - ActionArticleDelete = "article.delete" - ActionSiteCreate = "site.create" - ActionSiteEdit = "site.edit" - ActionSiteSetDefault = "site.set_default" - ActionSiteDelete = "site.delete" - ActionAuthorCreate = "author.create" - ActionAuthorEdit = "author.edit" - ActionAuthorDelete = "author.delete" - ActionRoleCreate = "role.create" - ActionRoleEdit = "role.edit" - ActionRoleDelete = "role.delete" - ActionRoleReassign = "role.reassign" - ActionMediaUpload = "media.upload" - ActionMediaRename = "media.rename" - ActionMediaDelete = "media.delete" - ActionMediaMerge = "media.merge" - ActionMediaPurge = "media.purge" - ActionVariantCreate = "media.variant_create" - ActionVariantUpdate = "media.variant_update" - ActionVariantDelete = "media.variant_delete" - ActionVariantEnable = "media.variant_enable" - ActionVariantDisable = "media.variant_disable" - ActionVariantPurge = "media.variant_purge" - ActionVariantGenerateAll = "media.variant_generate_all" - ActionVariantPurgeImage = "media.variant_purge_image" - ActionVariantPurgeAll = "media.variant_purge_all" - ActionVariantPurgeUnused = "media.variant_purge_unused" - ActionUserRegister = "user.register" - ActionUserVerify = "user.verify" - ActionUserUpdateProfile = "user.update_profile" - ActionInviteCreate = "invite.create" - ActionInviteCancel = "invite.cancel" - ActionInviteRenew = "invite.renew" - ActionInviteAccept = "invite.accept" - ActionMediaPurgeScheduled = "system.media_purge" - ActionUpdateChecked = "system.update_checked" - ActionUpdateDownloaded = "system.update_downloaded" - ActionRestartRequested = "system.restart_requested" + ActionLogin = "auth.login" + ActionLoginFailed = "auth.login_failed" + ActionLogout = "auth.logout" + ActionPasswordResetRequest = "auth.password_reset_requested" + ActionPasswordResetDone = "auth.password_reset_completed" + ActionAPIKeyCreate = "api_key.create" + ActionAPIKeyRevoke = "api_key.revoke" + ActionRetentionChanged = "system.activity_retention_changed" + ActionCleanup = "system.activity_cleanup" + ActionArticleCreate = "article.create" + ActionArticleEdit = "article.edit" + ActionArticlePublish = "article.publish" + ActionArticleUnpublish = "article.unpublish" + ActionArticleDelete = "article.delete" + ActionSiteCreate = "site.create" + ActionSiteEdit = "site.edit" + ActionSiteSetDefault = "site.set_default" + ActionSiteDelete = "site.delete" + ActionAuthorCreate = "author.create" + ActionAuthorEdit = "author.edit" + ActionAuthorDelete = "author.delete" + ActionRoleCreate = "role.create" + ActionRoleEdit = "role.edit" + ActionRoleDelete = "role.delete" + ActionRoleReassign = "role.reassign" + ActionMediaUpload = "media.upload" + ActionMediaRename = "media.rename" + ActionMediaDelete = "media.delete" + ActionMediaMerge = "media.merge" + ActionMediaPurge = "media.purge" + ActionVariantCreate = "media.variant_create" + ActionVariantUpdate = "media.variant_update" + ActionVariantDelete = "media.variant_delete" + ActionVariantEnable = "media.variant_enable" + ActionVariantDisable = "media.variant_disable" + ActionVariantPurge = "media.variant_purge" + ActionVariantGenerateAll = "media.variant_generate_all" + ActionVariantPurgeImage = "media.variant_purge_image" + ActionVariantPurgeAll = "media.variant_purge_all" + ActionVariantPurgeUnused = "media.variant_purge_unused" + ActionUserRegister = "user.register" + ActionUserVerify = "user.verify" + ActionUserUpdateProfile = "user.update_profile" + ActionUserChangeRole = "user.change_role" + ActionUserTransferOwnership = "user.transfer_ownership" + ActionInviteCreate = "invite.create" + ActionInviteCancel = "invite.cancel" + ActionInviteRenew = "invite.renew" + ActionInviteAccept = "invite.accept" + ActionMediaPurgeScheduled = "system.media_purge" + ActionUpdateChecked = "system.update_checked" + ActionUpdateDownloaded = "system.update_downloaded" + ActionRestartRequested = "system.restart_requested" ) const ( @@ -157,7 +159,10 @@ const ( ParamChanged = "changed" ParamMembers = "members" ParamMoved = "moved" + ParamRole = "role" + ParamRoleID = "role_id" FieldTitle = "title" + FieldRole = "role" FieldOnline = "online" FieldBody = "body" FieldFilename = "filename" diff --git a/internal/domain/builtintype/builtinTypeDomain.go b/internal/domain/builtintype/builtinTypeDomain.go index 00f0f33..1a8cfaf 100644 --- a/internal/domain/builtintype/builtinTypeDomain.go +++ b/internal/domain/builtintype/builtinTypeDomain.go @@ -3,7 +3,6 @@ package builtintype import ( "RenewCMS/internal/domain/article" "RenewCMS/internal/domain/contenttype" - "RenewCMS/internal/domain/user" "errors" ) @@ -136,6 +135,6 @@ func userFields() []contenttype.Field { text("username", "Username").required(), text("display_name", "Display name"), text("email", "Email").required(), - of("role", "Role", contenttype.TypeSelect).required().options(string(user.RoleOwner), string(user.RoleAdmin)), + text("role", "Role"), ) } diff --git a/internal/domain/invite/inviteDomain.go b/internal/domain/invite/inviteDomain.go index 6dadd12..e20182c 100644 --- a/internal/domain/invite/inviteDomain.go +++ b/internal/domain/invite/inviteDomain.go @@ -31,6 +31,7 @@ type Invite struct { HashedCode string `json:"hashed_code"` ExpiresAt time.Time `json:"expires_at"` InvitedBy uint32 `json:"invited_by"` + RoleID uint32 `json:"role_id"` AcceptedAt *time.Time `json:"accepted_at"` CreatedAt time.Time `json:"created_at"` } diff --git a/internal/domain/role/roleDomain.go b/internal/domain/role/roleDomain.go index decee4a..587d288 100644 --- a/internal/domain/role/roleDomain.go +++ b/internal/domain/role/roleDomain.go @@ -27,6 +27,7 @@ var ( ErrNotBuiltIn = errors.New("role is not built in") ErrInvalidReassign = errors.New("invalid reassign target") ErrEscalation = errors.New("grants more than the caller holds") + ErrUnknownRole = errors.New("unknown role") ) type Scope string diff --git a/internal/domain/role/roleRepositoryInterface.go b/internal/domain/role/roleRepositoryInterface.go index 3da0f54..254b705 100644 --- a/internal/domain/role/roleRepositoryInterface.go +++ b/internal/domain/role/roleRepositoryInterface.go @@ -9,4 +9,5 @@ type Repository interface { Delete(id uint32) error ReassignMembers(from uint32, to uint32) (int, error) RemoveTypedGrants(typeKey string) (int, error) + MemberCounts() (map[uint32]int, error) } diff --git a/internal/domain/user/userDomain.go b/internal/domain/user/userDomain.go index bda013e..11b9e18 100644 --- a/internal/domain/user/userDomain.go +++ b/internal/domain/user/userDomain.go @@ -19,6 +19,9 @@ var ErrUsernameTaken = errors.New("username taken") var ErrInvalidLookup = errors.New("invalid user lookup") var ErrVerificationEmailNotSent = errors.New("verification email not sent") 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") const MaxLookupIDs = 100 @@ -31,13 +34,6 @@ func ValidatePassword(password string) error { return nil } -type Role string - -const ( - RoleOwner Role = "owner" - RoleAdmin Role = "admin" -) - type User struct { ID uint32 `json:"id"` Username string `json:"username"` @@ -46,7 +42,6 @@ type User struct { Email string `json:"email"` DisplayName string `json:"display_name"` IsVerified bool `json:"is_verified"` - Role Role `json:"role"` RoleID uint32 `json:"role_id"` VerificationCode string `json:"verification_code"` VerificationExpiration time.Time `json:"verification_expiration"` diff --git a/internal/domain/user/userRepositoryInterface.go b/internal/domain/user/userRepositoryInterface.go index c315e85..d6c4c17 100644 --- a/internal/domain/user/userRepositoryInterface.go +++ b/internal/domain/user/userRepositoryInterface.go @@ -16,4 +16,6 @@ type Repository interface { ClearVerificationCode(userId uint32) (User, error) ResetPassword(userId uint32, hashedPassword string) (User, error) UpdateDisplayName(userId uint32, displayName string) (User, error) + UpdateRole(id uint32, from uint32, to uint32) (User, error) + TransferOwnership(from uint32, to uint32, ownerRoleID uint32, formerRoleID uint32) error } diff --git a/internal/infrastructure/persistence/database/createdByBackfill.go b/internal/infrastructure/persistence/database/createdByBackfill.go index eaa57fa..eaf4356 100644 --- a/internal/infrastructure/persistence/database/createdByBackfill.go +++ b/internal/infrastructure/persistence/database/createdByBackfill.go @@ -1,7 +1,7 @@ package database import ( - domainUser "RenewCMS/internal/domain/user" + "RenewCMS/internal/domain/role" entity "RenewCMS/internal/infrastructure/persistence/models" "gorm.io/gorm" @@ -9,8 +9,15 @@ import ( func BackfillCreatedBy(db *gorm.DB) error { return db.Transaction(func(tx *gorm.DB) error { + ownerRoleID, seeded, err := builtInRoleID(tx, role.OwnerName) + if err != nil { + return err + } + if !seeded { + return nil + } var owner entity.User - result := tx.Where("role = ?", string(domainUser.RoleOwner)).Order("id asc").Limit(1).Find(&owner) + result := tx.Where("role_id = ?", ownerRoleID).Order("id asc").Limit(1).Find(&owner) if result.Error != nil { return result.Error } diff --git a/internal/infrastructure/persistence/database/inviteRoleBackfill.go b/internal/infrastructure/persistence/database/inviteRoleBackfill.go new file mode 100644 index 0000000..8c8fe4a --- /dev/null +++ b/internal/infrastructure/persistence/database/inviteRoleBackfill.go @@ -0,0 +1,28 @@ +package database + +import ( + "RenewCMS/internal/domain/role" + entity "RenewCMS/internal/infrastructure/persistence/models" + + "gorm.io/gorm" +) + +func BackfillInviteRoles(db *gorm.DB) error { + if !legacyRoleColumnExists(db) { + return nil + } + return db.Transaction(func(tx *gorm.DB) error { + done, err := markerApplied(tx, inviteRolesMarker) + if err != nil || done { + return err + } + adminID, err := requireBuiltInRoleID(tx, role.AdminName) + if err != nil { + return err + } + if err := tx.Model(&entity.Invite{}).Where("role_id IS NULL OR role_id = 0").UpdateColumn("role_id", adminID).Error; err != nil { + return err + } + return applyMarker(tx, inviteRolesMarker) + }) +} diff --git a/internal/infrastructure/persistence/database/legacyUserRoleBackfill.go b/internal/infrastructure/persistence/database/legacyUserRoleBackfill.go new file mode 100644 index 0000000..d82f339 --- /dev/null +++ b/internal/infrastructure/persistence/database/legacyUserRoleBackfill.go @@ -0,0 +1,94 @@ +package database + +import ( + "RenewCMS/internal/domain/role" + entity "RenewCMS/internal/infrastructure/persistence/models" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +const ( + usersTable = "users" + legacyRoleColumn = "role" + legacyOwnerValue = "owner" + legacyAdminValue = "admin" + legacyUnsetValue = "" + unassignedRoleCond = "(role_id IS NULL OR role_id = 0)" +) + +func legacyRoleColumnExists(db *gorm.DB) bool { + return db.Migrator().HasColumn(usersTable, legacyRoleColumn) +} + +func requireBuiltInRoleID(tx *gorm.DB, name string) (uint32, error) { + id, found, err := builtInRoleID(tx, name) + if err != nil { + return 0, err + } + if !found { + return 0, role.ErrRoleNotFound + } + return id, nil +} + +func BackfillLegacyUserRoles(db *gorm.DB) error { + if !legacyRoleColumnExists(db) { + return nil + } + return db.Transaction(func(tx *gorm.DB) error { + done, err := markerApplied(tx, legacyUserRolesMarker) + if err != nil || done { + return err + } + ownerID, err := requireBuiltInRoleID(tx, role.OwnerName) + if err != nil { + return err + } + adminID, err := requireBuiltInRoleID(tx, role.AdminName) + if err != nil { + return err + } + writerID, err := requireBuiltInRoleID(tx, role.WriterName) + if err != nil { + return err + } + legacy := clause.Column{Name: legacyRoleColumn} + assign := func(roleID uint32, condition string, args ...any) error { + return tx.Table(usersTable).Where(unassignedRoleCond).Where(condition, args...).UpdateColumn("role_id", roleID).Error + } + var owners []uint32 + if err := tx.Table(usersTable).Where(unassignedRoleCond).Where("? = ?", legacy, legacyOwnerValue).Order("id asc").Pluck("id", &owners).Error; err != nil { + return err + } + if len(owners) > 0 { + if err := assign(ownerID, "id = ?", owners[0]); err != nil { + return err + } + } + if err := assign(adminID, "? = ?", legacy, legacyOwnerValue); err != nil { + return err + } + if err := assign(adminID, "? = ?", legacy, legacyAdminValue); err != nil { + return err + } + if len(owners) == 0 { + var lowest []uint32 + if err := tx.Model(&entity.User{}).Order("id asc").Limit(1).Pluck("id", &lowest).Error; err != nil { + return err + } + if len(lowest) > 0 { + if err := assign(ownerID, "? = ? AND id = ?", legacy, legacyUnsetValue, lowest[0]); err != nil { + return err + } + } + } + if err := assign(adminID, "? = ?", legacy, legacyUnsetValue); err != nil { + return err + } + if err := assign(writerID, "1 = 1"); err != nil { + return err + } + return applyMarker(tx, legacyUserRolesMarker) + }) +} diff --git a/internal/infrastructure/persistence/database/migrate.go b/internal/infrastructure/persistence/database/migrate.go index cd49650..a86ed9d 100644 --- a/internal/infrastructure/persistence/database/migrate.go +++ b/internal/infrastructure/persistence/database/migrate.go @@ -7,7 +7,7 @@ import ( ) func Migrate(db *gorm.DB, publicURL string) error { - err := tableOptionsFor(db).AutoMigrate(&models.Article{}, &models.Image{}, &models.User{}, &models.Invite{}, &models.APIKey{}, &models.PurgeSettings{}, &models.AuditEvent{}, &models.AuditSettings{}, &models.ImageVariant{}, &models.ImageVariantFile{}, &models.ImageVariantRejectionDay{}, &models.Site{}, &models.Author{}, &models.ArticleAuthor{}, &models.ContentType{}, &models.Entry{}, &models.RevokedSession{}, &models.Role{}, &models.RolePermission{}) + err := tableOptionsFor(db).AutoMigrate(&models.Article{}, &models.Image{}, &models.User{}, &models.Invite{}, &models.APIKey{}, &models.PurgeSettings{}, &models.AuditEvent{}, &models.AuditSettings{}, &models.ImageVariant{}, &models.ImageVariantFile{}, &models.ImageVariantRejectionDay{}, &models.Site{}, &models.Author{}, &models.ArticleAuthor{}, &models.ContentType{}, &models.Entry{}, &models.RevokedSession{}, &models.Role{}, &models.RolePermission{}, &models.MigrationMarker{}) if err != nil { return err } @@ -17,10 +17,10 @@ func Migrate(db *gorm.DB, publicURL string) error { if err := SeedBuiltInRoles(db); err != nil { return err } - if err := BackfillUserRoles(db); err != nil { + if err := BackfillLegacyUserRoles(db); err != nil { return err } - if err := BackfillUserRoleIDs(db); err != nil { + if err := BackfillInviteRoles(db); err != nil { return err } if err := BackfillCreatedBy(db); err != nil { diff --git a/internal/infrastructure/persistence/database/migrationMarker.go b/internal/infrastructure/persistence/database/migrationMarker.go new file mode 100644 index 0000000..2585474 --- /dev/null +++ b/internal/infrastructure/persistence/database/migrationMarker.go @@ -0,0 +1,25 @@ +package database + +import ( + entity "RenewCMS/internal/infrastructure/persistence/models" + "time" + + "gorm.io/gorm" +) + +const ( + legacyUserRolesMarker = "legacy_user_roles" + inviteRolesMarker = "invite_roles" +) + +func markerApplied(tx *gorm.DB, name string) (bool, error) { + var count int64 + if err := tx.Model(&entity.MigrationMarker{}).Where("name = ?", name).Count(&count).Error; err != nil { + return false, err + } + return count > 0, nil +} + +func applyMarker(tx *gorm.DB, name string) error { + return tx.Create(&entity.MigrationMarker{Name: name, AppliedAt: time.Now()}).Error +} diff --git a/internal/infrastructure/persistence/database/userRoleBackfill.go b/internal/infrastructure/persistence/database/userRoleBackfill.go deleted file mode 100644 index e3793fb..0000000 --- a/internal/infrastructure/persistence/database/userRoleBackfill.go +++ /dev/null @@ -1,33 +0,0 @@ -package database - -import ( - domainUser "RenewCMS/internal/domain/user" - entity "RenewCMS/internal/infrastructure/persistence/models" - - "gorm.io/gorm" -) - -func BackfillUserRoles(db *gorm.DB) error { - return db.Transaction(func(tx *gorm.DB) error { - var firstUser entity.User - result := tx.Where("role = ''").Order("id asc").Limit(1).Find(&firstUser) - if result.Error != nil { - return result.Error - } - if result.RowsAffected == 0 { - return nil - } - - var lowest entity.User - if err := tx.Order("id asc").First(&lowest).Error; err != nil { - return err - } - if lowest.Role == "" { - if err := tx.Model(&entity.User{}).Where("id = ?", lowest.ID).Update("role", string(domainUser.RoleOwner)).Error; err != nil { - return err - } - } - - return tx.Model(&entity.User{}).Where("role = ''").Update("role", string(domainUser.RoleAdmin)).Error - }) -} diff --git a/internal/infrastructure/persistence/database/userRoleIDBackfill.go b/internal/infrastructure/persistence/database/userRoleIDBackfill.go deleted file mode 100644 index b555495..0000000 --- a/internal/infrastructure/persistence/database/userRoleIDBackfill.go +++ /dev/null @@ -1,35 +0,0 @@ -package database - -import ( - "RenewCMS/internal/domain/role" - domainUser "RenewCMS/internal/domain/user" - entity "RenewCMS/internal/infrastructure/persistence/models" - - "gorm.io/gorm" -) - -var legacyRoleNames = map[domainUser.Role]string{ - domainUser.RoleOwner: role.OwnerName, - domainUser.RoleAdmin: role.AdminName, -} - -func BackfillUserRoleIDs(db *gorm.DB) error { - return db.Transaction(func(tx *gorm.DB) error { - for legacy, name := range legacyRoleNames { - roleID, found, err := builtInRoleID(tx, name) - if err != nil { - return err - } - if !found { - continue - } - err = tx.Model(&entity.User{}). - Where("role = ? AND (role_id IS NULL OR role_id = 0)", string(legacy)). - UpdateColumn("role_id", roleID).Error - if err != nil { - return err - } - } - return nil - }) -} diff --git a/internal/infrastructure/persistence/mappers/inviteMapper.go b/internal/infrastructure/persistence/mappers/inviteMapper.go index 5782eee..8fef02d 100644 --- a/internal/infrastructure/persistence/mappers/inviteMapper.go +++ b/internal/infrastructure/persistence/mappers/inviteMapper.go @@ -12,6 +12,7 @@ func InviteToDomain(m models.Invite) invite.Invite { HashedCode: m.HashedCode, ExpiresAt: m.ExpiresAt, InvitedBy: m.InvitedBy, + RoleID: nullableIDToDomain(m.RoleID), AcceptedAt: m.AcceptedAt, CreatedAt: m.CreatedAt, } @@ -24,6 +25,7 @@ func InviteToModel(i invite.Invite) models.Invite { HashedCode: i.HashedCode, ExpiresAt: i.ExpiresAt, InvitedBy: i.InvitedBy, + RoleID: DomainToNullableID(i.RoleID), AcceptedAt: i.AcceptedAt, CreatedAt: i.CreatedAt, } diff --git a/internal/infrastructure/persistence/mappers/userMapper.go b/internal/infrastructure/persistence/mappers/userMapper.go index e2641c2..103d9d0 100644 --- a/internal/infrastructure/persistence/mappers/userMapper.go +++ b/internal/infrastructure/persistence/mappers/userMapper.go @@ -26,7 +26,7 @@ func nullableIDToDomain(value *uint32) uint32 { return *value } -func domainToNullableID(value uint32) *uint32 { +func DomainToNullableID(value uint32) *uint32 { if value == 0 { return nil } @@ -42,7 +42,6 @@ func UserToDomain(m models.User) user.User { Email: m.Email, DisplayName: m.DisplayName, IsVerified: m.IsVerified, - Role: user.Role(m.Role), RoleID: nullableIDToDomain(m.RoleID), VerificationCode: nullableStringToDomain(m.VerificationCode), VerificationExpiration: m.VerificationExpiration, @@ -61,8 +60,7 @@ func UserToModel(a user.User) models.User { Email: a.Email, DisplayName: a.DisplayName, IsVerified: a.IsVerified, - Role: string(a.Role), - RoleID: domainToNullableID(a.RoleID), + RoleID: DomainToNullableID(a.RoleID), VerificationCode: DomainToNullableString(a.VerificationCode), VerificationExpiration: a.VerificationExpiration, PasswordResetExpiration: a.PasswordResetExpiration, diff --git a/internal/infrastructure/persistence/models/inviteModel.go b/internal/infrastructure/persistence/models/inviteModel.go index a720d00..c7befab 100644 --- a/internal/infrastructure/persistence/models/inviteModel.go +++ b/internal/infrastructure/persistence/models/inviteModel.go @@ -7,7 +7,8 @@ type Invite struct { Email string `gorm:"index;not null;size:255"` HashedCode string `gorm:"not null"` ExpiresAt time.Time - InvitedBy uint32 `gorm:"not null"` + InvitedBy uint32 `gorm:"not null"` + RoleID *uint32 `gorm:"index"` AcceptedAt *time.Time CreatedAt time.Time `gorm:"autoCreateTime"` } diff --git a/internal/infrastructure/persistence/models/migrationMarkerModel.go b/internal/infrastructure/persistence/models/migrationMarkerModel.go new file mode 100644 index 0000000..2f4bea8 --- /dev/null +++ b/internal/infrastructure/persistence/models/migrationMarkerModel.go @@ -0,0 +1,8 @@ +package models + +import "time" + +type MigrationMarker struct { + Name string `gorm:"primaryKey;size:64"` + AppliedAt time.Time `gorm:"not null"` +} diff --git a/internal/infrastructure/persistence/models/userModel.go b/internal/infrastructure/persistence/models/userModel.go index 3698f0a..36eef65 100644 --- a/internal/infrastructure/persistence/models/userModel.go +++ b/internal/infrastructure/persistence/models/userModel.go @@ -12,7 +12,6 @@ type User struct { Email string `gorm:"unique;not null;size:255"` DisplayName string `gorm:"not null;default:'';size:255"` IsVerified bool `gorm:"default=false;not null"` - Role string `gorm:"not null;default:'';size:32"` RoleID *uint32 `gorm:"index"` VerificationCode *string `gorm:"unique;size:255"` VerificationExpiration time.Time diff --git a/internal/infrastructure/persistence/repositories/roleRepository.go b/internal/infrastructure/persistence/repositories/roleRepository.go index 6d91637..7180d1d 100644 --- a/internal/infrastructure/persistence/repositories/roleRepository.go +++ b/internal/infrastructure/persistence/repositories/roleRepository.go @@ -145,8 +145,8 @@ func (r *RoleRepository) Delete(id uint32) error { if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&entity.Role{}, id).Error; err != nil { return mapNotFoundErr(err, role.ErrRoleNotFound) } - var members int64 - if err := tx.Model(&entity.User{}).Where("role_id = ?", id).Count(&members).Error; err != nil { + members, err := countMembers(tx, id) + if err != nil { return err } if members > 0 { @@ -159,24 +159,68 @@ func (r *RoleRepository) Delete(id uint32) error { }) } -func (r *RoleRepository) ReassignMembers(from uint32, to uint32) (int, error) { - moved := r.db.Model(&entity.User{}). - Where("role_id = ? AND EXISTS (SELECT 1 FROM roles WHERE id = ?)", from, to). - UpdateColumn("role_id", to) - if moved.Error != nil { - return 0, moved.Error - } - if moved.RowsAffected > 0 { - return int(moved.RowsAffected), nil - } - var targets int64 - if err := r.db.Model(&entity.Role{}).Where("id = ?", to).Count(&targets).Error; err != nil { +func countMembers(tx *gorm.DB, id uint32) (int64, error) { + var users, invites int64 + if err := tx.Model(&entity.User{}).Where("role_id = ?", id).Count(&users).Error; err != nil { return 0, err } - if targets == 0 { - return 0, role.ErrInvalidReassign + if err := tx.Model(&entity.Invite{}).Where("role_id = ? AND accepted_at IS NULL", id).Count(&invites).Error; err != nil { + return 0, err } - return 0, nil + return users + invites, nil +} + +type memberRow struct { + RoleID uint32 + Members int +} + +func (r *RoleRepository) MemberCounts() (map[uint32]int, error) { + var users, invites []memberRow + if err := r.db.Model(&entity.User{}).Select("role_id, COUNT(*) AS members").Where("role_id IS NOT NULL").Group("role_id").Scan(&users).Error; err != nil { + return nil, err + } + if err := r.db.Model(&entity.Invite{}).Select("role_id, COUNT(*) AS members").Where("role_id IS NOT NULL AND accepted_at IS NULL").Group("role_id").Scan(&invites).Error; err != nil { + return nil, err + } + counts := map[uint32]int{} + for _, row := range append(users, invites...) { + if row.RoleID != 0 { + counts[row.RoleID] += row.Members + } + } + return counts, nil +} + +func (r *RoleRepository) ReassignMembers(from uint32, to uint32) (int, error) { + moved := 0 + err := r.db.Transaction(func(tx *gorm.DB) error { + target := "EXISTS (SELECT 1 FROM roles WHERE id = ?)" + users := tx.Model(&entity.User{}).Where("role_id = ? AND "+target, from, to).UpdateColumn("role_id", to) + if users.Error != nil { + return users.Error + } + invites := tx.Model(&entity.Invite{}).Where("role_id = ? AND accepted_at IS NULL AND "+target, from, to).UpdateColumn("role_id", to) + if invites.Error != nil { + return invites.Error + } + moved = int(users.RowsAffected + invites.RowsAffected) + if moved > 0 { + return nil + } + var targets int64 + if err := tx.Model(&entity.Role{}).Where("id = ?", to).Count(&targets).Error; err != nil { + return err + } + if targets == 0 { + return role.ErrInvalidReassign + } + return nil + }) + if err != nil { + return 0, err + } + return moved, nil } func (r *RoleRepository) RemoveTypedGrants(typeKey string) (int, error) { diff --git a/internal/infrastructure/persistence/repositories/userRepository.go b/internal/infrastructure/persistence/repositories/userRepository.go index 4a41e8e..f2e01e0 100644 --- a/internal/infrastructure/persistence/repositories/userRepository.go +++ b/internal/infrastructure/persistence/repositories/userRepository.go @@ -1,12 +1,14 @@ package persistence import ( + "RenewCMS/internal/domain/role" domainUser "RenewCMS/internal/domain/user" "RenewCMS/internal/infrastructure/persistence/mappers" entity "RenewCMS/internal/infrastructure/persistence/models" "time" "gorm.io/gorm" + "gorm.io/gorm/clause" ) type UserRepository struct { @@ -34,7 +36,7 @@ func (u *UserRepository) Create(user domainUser.User) (domainUser.User, error) { PasswordResetCode: mappers.DomainToNullableString(user.PasswordResetCode), Email: user.Email, IsVerified: user.IsVerified, - Role: string(user.Role), + RoleID: mappers.DomainToNullableID(user.RoleID), VerificationCode: mappers.DomainToNullableString(user.VerificationCode), VerificationExpiration: user.VerificationExpiration, } @@ -145,14 +147,17 @@ func (u *UserRepository) UpdatePasswordResetCode(userId uint32, code string, exp func (u *UserRepository) ClearVerificationCode(userId uint32) (domainUser.User, error) { var localUser entity.User - err := u.db.Model(&entity.User{}).First(&localUser, userId).Error - if err != nil { - return domainUser.User{}, mapNotFoundErr(err, domainUser.ErrUserNotFound) - } - - localUser.IsVerified = true - localUser.VerificationCode = mappers.DomainToNullableString("") - err = u.db.Save(&localUser).Error + err := u.db.Transaction(func(tx *gorm.DB) error { + if err := tx.Model(&entity.User{}).First(&localUser, userId).Error; err != nil { + return mapNotFoundErr(err, domainUser.ErrUserNotFound) + } + if err := refuseSecondVerifiedOwner(tx, localUser); err != nil { + return err + } + localUser.IsVerified = true + localUser.VerificationCode = mappers.DomainToNullableString("") + return tx.Save(&localUser).Error + }) if err != nil { return domainUser.User{}, err } @@ -160,6 +165,26 @@ func (u *UserRepository) ClearVerificationCode(userId uint32) (domainUser.User, return mappers.UserToDomain(localUser), nil } +func refuseSecondVerifiedOwner(tx *gorm.DB, candidate entity.User) error { + if candidate.RoleID == nil { + return nil + } + var roles []entity.Role + err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ? AND built_in = ? AND name = ?", *candidate.RoleID, true, role.OwnerName).Find(&roles).Error + if err != nil || len(roles) == 0 { + return err + } + var others int64 + err = tx.Model(&entity.User{}).Where("role_id = ? AND is_verified = ? AND id <> ?", *candidate.RoleID, true, candidate.ID).Count(&others).Error + if err != nil { + return err + } + if others > 0 { + return domainUser.ErrInvalidVerificationCode + } + return nil +} + func (u *UserRepository) ResetPassword(userId uint32, hashedPassword string) (domainUser.User, error) { var localUser entity.User err := u.db.Model(&entity.User{}).First(&localUser, userId).Error @@ -195,4 +220,68 @@ func (u *UserRepository) UpdateDisplayName(userId uint32, displayName string) (d return mappers.UserToDomain(localUser), nil } +func (u *UserRepository) UpdateRole(id uint32, from uint32, to 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) + if from == 0 { + query = query.Where("(role_id IS NULL OR role_id = 0)") + } else { + query = query.Where("role_id = ?", from) + } + result := query.Update("role_id", to) + if result.Error != nil { + return result.Error + } + if result.RowsAffected > 0 { + return nil + } + return u.explainRoleRefusal(tx, id, to) + }) + if err != nil { + return domainUser.User{}, err + } + return u.Get(id) +} + +func (u *UserRepository) explainRoleRefusal(tx *gorm.DB, id uint32, to uint32) error { + var users int64 + if err := tx.Model(&entity.User{}).Where("id = ?", id).Count(&users).Error; err != nil { + return err + } + if users == 0 { + return domainUser.ErrUserNotFound + } + var roles int64 + if err := tx.Model(&entity.Role{}).Where("id = ?", to).Count(&roles).Error; err != nil { + return err + } + if roles == 0 { + return role.ErrRoleNotFound + } + return domainUser.ErrRoleChanged +} + +func (u *UserRepository) TransferOwnership(from uint32, to uint32, ownerRoleID uint32, formerRoleID uint32) error { + if from == to { + return domainUser.ErrInvalidTransfer + } + return u.db.Transaction(func(tx *gorm.DB) error { + demoted := tx.Model(&entity.User{}).Where("id = ? AND role_id = ?", from, ownerRoleID).Update("role_id", formerRoleID) + if demoted.Error != nil { + return demoted.Error + } + if demoted.RowsAffected == 0 { + return domainUser.ErrRoleChanged + } + promoted := tx.Model(&entity.User{}).Where("id = ? AND is_verified = ?", to, true).Update("role_id", ownerRoleID) + if promoted.Error != nil { + return promoted.Error + } + if promoted.RowsAffected == 0 { + return domainUser.ErrInvalidTransfer + } + return nil + }) +} + var _ domainUser.Repository = &UserRepository{} diff --git a/internal/infrastructure/useCases/AcceptInvite.go b/internal/infrastructure/useCases/AcceptInvite.go index ae77711..6e30c21 100644 --- a/internal/infrastructure/useCases/AcceptInvite.go +++ b/internal/infrastructure/useCases/AcceptInvite.go @@ -4,6 +4,7 @@ import ( "RenewCMS/internal/domain/audit" "RenewCMS/internal/domain/hash" "RenewCMS/internal/domain/invite" + "RenewCMS/internal/domain/role" "RenewCMS/internal/domain/user" "context" "errors" @@ -14,6 +15,7 @@ import ( type AcceptInviteUseCase struct { userRepository user.Repository inviteRepository invite.Repository + roles role.Repository hasher hash.Hasher recorder audit.Recorder } @@ -24,8 +26,16 @@ type AcceptInviteCommand struct { Password string } -func NewAcceptInviteUseCase(userRepository user.Repository, inviteRepository invite.Repository, hasher hash.Hasher, recorder audit.Recorder) *AcceptInviteUseCase { - return &AcceptInviteUseCase{userRepository, inviteRepository, hasher, recorder} +func NewAcceptInviteUseCase(userRepository user.Repository, inviteRepository invite.Repository, roles role.Repository, hasher hash.Hasher, recorder audit.Recorder) *AcceptInviteUseCase { + return &AcceptInviteUseCase{userRepository, inviteRepository, roles, hasher, recorder} +} + +func (a *AcceptInviteUseCase) invitedRole(pending invite.Invite) (role.Role, error) { + assigned, err := assignableRole(a.roles, pending.RoleID) + if errors.Is(err, role.ErrUnknownRole) || errors.Is(err, role.ErrRoleLocked) { + return role.Role{}, invite.ErrInviteInvalid + } + return assigned, err } func (a *AcceptInviteUseCase) AcceptInvite(ctx context.Context, command AcceptInviteCommand) (user.User, error) { @@ -39,11 +49,18 @@ func (a *AcceptInviteUseCase) AcceptInvite(ctx context.Context, command AcceptIn } event.Target = inviteTarget(pending) - created, err := a.accept(command, pending, now) + assigned, err := a.invitedRole(pending) if err != nil { a.recordRefusal(ctx, event, command, err) return user.User{}, err } + + created, err := a.accept(command, pending, assigned, now) + if err != nil { + a.recordRefusal(ctx, event, command, err) + return user.User{}, err + } + event = inviteRoleParams(event, assigned) event.Actor = audit.UserActor(created) a.recorder.Record(ctx, event) return created, nil @@ -54,7 +71,7 @@ func (a *AcceptInviteUseCase) recordRefusal(ctx context.Context, event audit.Eve recordFailure(ctx, a.recorder, event, cause) } -func (a *AcceptInviteUseCase) accept(command AcceptInviteCommand, pending invite.Invite, now time.Time) (user.User, error) { +func (a *AcceptInviteUseCase) accept(command AcceptInviteCommand, pending invite.Invite, assigned role.Role, now time.Time) (user.User, error) { if a.emailHasAccount(pending.Email) { if _, err := findUsableInvite(a.inviteRepository, a.hasher, command.Code, now); err != nil { return user.User{}, err @@ -80,7 +97,7 @@ func (a *AcceptInviteUseCase) accept(command AcceptInviteCommand, pending invite Password: hashedPassword, Email: pending.Email, IsVerified: true, - Role: user.RoleAdmin, + RoleID: assigned.ID, }) if errors.Is(err, invite.ErrInviteNotFound) { return user.User{}, invite.ErrInviteInvalid diff --git a/internal/infrastructure/useCases/ChangeUserRole.go b/internal/infrastructure/useCases/ChangeUserRole.go new file mode 100644 index 0000000..4580f02 --- /dev/null +++ b/internal/infrastructure/useCases/ChangeUserRole.go @@ -0,0 +1,110 @@ +package useCases + +import ( + "RenewCMS/internal/domain/audit" + "RenewCMS/internal/domain/contenttype" + "RenewCMS/internal/domain/role" + "RenewCMS/internal/domain/user" + "context" + "errors" +) + +type ChangeUserRoleUseCase struct { + users user.Repository + roles role.Repository + contentTypes contenttype.Repository + recorder audit.Recorder +} + +type ChangeUserRoleCommand struct { + Caller ContentCaller + TargetID uint32 + RoleID uint32 +} + +func NewChangeUserRoleUseCase(users user.Repository, roles role.Repository, contentTypes contenttype.Repository, recorder audit.Recorder) *ChangeUserRoleUseCase { + return &ChangeUserRoleUseCase{users: users, roles: roles, contentTypes: contentTypes, recorder: recorder} +} + +var roleChangeRefusals = []error{user.ErrRoleChanged, user.ErrUserNotFound, role.ErrRoleNotFound, role.ErrUnknownRole} + +func (u *ChangeUserRoleUseCase) ChangeRole(ctx context.Context, command ChangeUserRoleCommand) (DirectoryEntry, error) { + target, err := u.users.Get(command.TargetID) + if err != nil { + return DirectoryEntry{}, err + } + current, held, err := u.heldRole(target) + if err != nil { + return DirectoryEntry{}, err + } + if held && current.Locked { + return DirectoryEntry{}, role.ErrRoleLocked + } + next, err := assignableRole(u.roles, command.RoleID) + if err != nil { + return DirectoryEntry{}, err + } + if err := u.checkCoverage(command, target, current, held, next); err != nil { + return DirectoryEntry{}, err + } + if held && current.ID == next.ID { + ref := NewRoleRef(next) + return userEntry(target, &ref), nil + } + return u.write(ctx, target, current, next) +} + +func (u *ChangeUserRoleUseCase) heldRole(target user.User) (role.Role, bool, error) { + current, err := resolveRole(u.roles, target) + if errors.Is(err, role.ErrRoleNotFound) { + return role.Role{}, false, nil + } + if err != nil { + return role.Role{}, false, err + } + return current, true, nil +} + +func (u *ChangeUserRoleUseCase) checkCoverage(command ChangeUserRoleCommand, target user.User, current role.Role, held bool, next role.Role) error { + if target.ID == command.Caller.UserID { + return role.ErrOwnRole + } + typeKeys, err := allowedTypeKeys(u.contentTypes) + if err != nil { + return err + } + if held { + if err := ensureCoveredBy(command.Caller, typeKeys, current); err != nil { + return err + } + } + return ensureCoveredBy(command.Caller, typeKeys, next) +} + +func (u *ChangeUserRoleUseCase) write(ctx context.Context, target user.User, current role.Role, next role.Role) (DirectoryEntry, error) { + event := audit.NewEvent(audit.ActionUserChangeRole, audit.CategoryUsers) + event.Target = audit.NewTarget(audit.TargetUser, target.ID, target.Username) + updated, err := u.users.UpdateRole(target.ID, target.RoleID, next.ID) + if errors.Is(err, role.ErrRoleNotFound) { + return DirectoryEntry{}, role.ErrUnknownRole + } + if err != nil { + u.recordWriteFailure(ctx, event, err) + return DirectoryEntry{}, err + } + event.Params[audit.ParamFrom] = current.ID + event.Params[audit.ParamTo] = next.ID + event.Changes = audit.AppendTextChange(event.Changes, audit.FieldRole, current.Name, next.Name) + u.recorder.Record(ctx, event) + ref := NewRoleRef(next) + return userEntry(updated, &ref), nil +} + +func (u *ChangeUserRoleUseCase) recordWriteFailure(ctx context.Context, event audit.Event, err error) { + for _, refusal := range roleChangeRefusals { + if errors.Is(err, refusal) { + return + } + } + recordFailure(ctx, u.recorder, event, err) +} diff --git a/internal/infrastructure/useCases/CreateInvite.go b/internal/infrastructure/useCases/CreateInvite.go index 038e6cf..8e21e6a 100644 --- a/internal/infrastructure/useCases/CreateInvite.go +++ b/internal/infrastructure/useCases/CreateInvite.go @@ -2,9 +2,11 @@ package useCases import ( "RenewCMS/internal/domain/audit" + "RenewCMS/internal/domain/contenttype" "RenewCMS/internal/domain/hash" "RenewCMS/internal/domain/invite" "RenewCMS/internal/domain/mail" + "RenewCMS/internal/domain/role" "RenewCMS/internal/domain/user" "context" "errors" @@ -15,52 +17,98 @@ import ( type CreateInviteUseCase struct { userRepository user.Repository inviteRepository invite.Repository + roles role.Repository + contentTypes contenttype.Repository hasher hash.Hasher mailer inviteMailer recorder audit.Recorder } type CreateInviteCommand struct { + Caller ContentCaller Email string InvitedBy uint32 + RoleID uint32 } -func NewCreateInviteUseCase(userRepository user.Repository, inviteRepository invite.Repository, hasher hash.Hasher, mailRepository mail.Repository, publicURL string, recorder audit.Recorder) *CreateInviteUseCase { - return &CreateInviteUseCase{userRepository, inviteRepository, hasher, inviteMailer{mailRepository, publicURL}, recorder} +func NewCreateInviteUseCase(userRepository user.Repository, inviteRepository invite.Repository, roles role.Repository, contentTypes contenttype.Repository, hasher hash.Hasher, mailRepository mail.Repository, publicURL string, recorder audit.Recorder) *CreateInviteUseCase { + return &CreateInviteUseCase{userRepository, inviteRepository, roles, contentTypes, hasher, inviteMailer{mailRepository, publicURL}, recorder} } -func (g *CreateInviteUseCase) CreateInvite(ctx context.Context, command CreateInviteCommand) (invite.Invite, error) { +var inviteRoleRefusals = []error{role.ErrUnknownRole, role.ErrRoleLocked, role.ErrEscalation} + +func isInviteRoleRefusal(err error) bool { + for _, refusal := range inviteRoleRefusals { + if errors.Is(err, refusal) { + return true + } + } + return false +} + +func inviteRoleParams(event audit.Event, assigned role.Role) audit.Event { + event.Params[audit.ParamRoleID] = assigned.ID + event.Params[audit.ParamRole] = assigned.Name + return event +} + +func (g *CreateInviteUseCase) CreateInvite(ctx context.Context, command CreateInviteCommand) (InviteView, error) { event := audit.NewEvent(audit.ActionInviteCreate, audit.CategoryUsers) - created, err := g.create(command) + created, assigned, err := g.create(command) if err != nil { - recordFailure(ctx, g.recorder, event, err) - return invite.Invite{}, err + if !isInviteRoleRefusal(err) { + recordFailure(ctx, g.recorder, event, err) + } + return InviteView{}, err } event.Target = inviteTarget(created) - g.recorder.Record(ctx, event) - return created, nil + g.recorder.Record(ctx, inviteRoleParams(event, assigned)) + return InviteView{Invite: created, Role: NewRoleRef(assigned)}, nil } -func (g *CreateInviteUseCase) create(command CreateInviteCommand) (invite.Invite, error) { +func (g *CreateInviteUseCase) assignedRole(command CreateInviteCommand) (role.Role, error) { + if command.RoleID == 0 { + return builtInRole(g.roles, role.WriterName) + } + return assignableRole(g.roles, command.RoleID) +} + +func (g *CreateInviteUseCase) grantableRole(command CreateInviteCommand) (role.Role, error) { + assigned, err := g.assignedRole(command) + if err != nil { + return role.Role{}, err + } + if err := ensureCovered(command.Caller, g.contentTypes, assigned); err != nil { + return role.Role{}, err + } + return assigned, nil +} + +func (g *CreateInviteUseCase) create(command CreateInviteCommand) (invite.Invite, role.Role, error) { email := strings.ToLower(command.Email) now := time.Now() inviter, err := g.userRepository.Get(command.InvitedBy) if err != nil { - return invite.Invite{}, err + return invite.Invite{}, role.Role{}, err + } + + assigned, err := g.grantableRole(command) + if err != nil { + return invite.Invite{}, role.Role{}, err } if g.emailHasAccount(email) { - return invite.Invite{}, invite.ErrEmailHasAccount + return invite.Invite{}, role.Role{}, invite.ErrEmailHasAccount } if err := g.discardOpenInvite(email, now); err != nil { - return invite.Invite{}, err + return invite.Invite{}, role.Role{}, err } rawCode, hashedCode, err := newInviteCode(g.hasher) if err != nil { - return invite.Invite{}, err + return invite.Invite{}, role.Role{}, err } createdInvite, err := g.inviteRepository.Create(invite.Invite{ @@ -68,20 +116,21 @@ func (g *CreateInviteUseCase) create(command CreateInviteCommand) (invite.Invite HashedCode: hashedCode, ExpiresAt: now.Add(invite.Lifetime), InvitedBy: command.InvitedBy, + RoleID: assigned.ID, CreatedAt: now, }) if err != nil { - return invite.Invite{}, err + return invite.Invite{}, role.Role{}, err } if sendErr := g.mailer.send(email, rawCode, inviter); sendErr != nil { if deleteErr := g.inviteRepository.Delete(createdInvite.ID); deleteErr != nil { - return invite.Invite{}, errors.Join(sendErr, deleteErr) + return invite.Invite{}, role.Role{}, errors.Join(sendErr, deleteErr) } - return invite.Invite{}, sendErr + return invite.Invite{}, role.Role{}, sendErr } - return createdInvite, nil + return createdInvite, assigned, nil } func (g *CreateInviteUseCase) emailHasAccount(email string) bool { diff --git a/internal/infrastructure/useCases/CreateUser.go b/internal/infrastructure/useCases/CreateUser.go index dae1b3e..dc30bf7 100644 --- a/internal/infrastructure/useCases/CreateUser.go +++ b/internal/infrastructure/useCases/CreateUser.go @@ -4,6 +4,7 @@ import ( "RenewCMS/internal/domain/audit" "RenewCMS/internal/domain/hash" "RenewCMS/internal/domain/mail" + "RenewCMS/internal/domain/role" "RenewCMS/internal/domain/user" "context" "errors" @@ -15,6 +16,7 @@ import ( type CreateUserUseCase struct { userRepository user.Repository + roles role.Repository hasher hash.Hasher mailRepository mail.Repository publicURL string @@ -32,8 +34,8 @@ type MailValidationData struct { VerificationCode string } -func NewCreateUserUseCase(userRepository user.Repository, hasher hash.Hasher, mailRepository mail.Repository, publicURL string, recorder audit.Recorder) *CreateUserUseCase { - return &CreateUserUseCase{userRepository, hasher, mailRepository, publicURL, recorder} +func NewCreateUserUseCase(userRepository user.Repository, roles role.Repository, hasher hash.Hasher, mailRepository mail.Repository, publicURL string, recorder audit.Recorder) *CreateUserUseCase { + return &CreateUserUseCase{userRepository, roles, hasher, mailRepository, publicURL, recorder} } func (g *CreateUserUseCase) CreateUser(ctx context.Context, createUser CreateUserCommand) (user.User, error) { @@ -59,6 +61,11 @@ func (g *CreateUserUseCase) create(createUser CreateUserCommand) (user.User, err return user.User{}, err } + ownerRole, err := builtInRole(g.roles, role.OwnerName) + if err != nil { + return user.User{}, err + } + rawUuid := uuid.NewString() hashedPassword, err := g.hasher.Hash(createUser.Password) @@ -77,7 +84,7 @@ func (g *CreateUserUseCase) create(createUser CreateUserCommand) (user.User, err PasswordResetCode: "", Email: createUser.Email, IsVerified: false, - Role: user.RoleOwner, + RoleID: ownerRole.ID, VerificationCode: hashedVerificationCode, VerificationExpiration: time.Now().Add(2 * time.Hour), }) diff --git a/internal/infrastructure/useCases/GetMyAccess.go b/internal/infrastructure/useCases/GetMyAccess.go index 8d5e1ef..3d7643b 100644 --- a/internal/infrastructure/useCases/GetMyAccess.go +++ b/internal/infrastructure/useCases/GetMyAccess.go @@ -18,27 +18,11 @@ func NewGetMyAccessUseCase(roles role.Repository) *GetMyAccessUseCase { return &GetMyAccessUseCase{roles: roles} } -var legacyBuiltInNames = map[user.Role]string{ - user.RoleOwner: role.OwnerName, - user.RoleAdmin: role.AdminName, -} - func resolveRole(roles role.Repository, account user.User) (role.Role, error) { - if account.RoleID != 0 { - return roles.Get(account.RoleID) - } - name, known := legacyBuiltInNames[account.Role] - if !known { + if account.RoleID == 0 { return role.Role{}, role.ErrRoleNotFound } - found, err := roles.GetByName(name) - if err != nil { - return role.Role{}, err - } - if !found.BuiltIn { - return role.Role{}, role.ErrRoleNotFound - } - return found, nil + return roles.Get(account.RoleID) } func (u *GetMyAccessUseCase) Get(account user.User) (Access, error) { diff --git a/internal/infrastructure/useCases/ListUsersAndInvites.go b/internal/infrastructure/useCases/ListUsersAndInvites.go index 6c606a0..71373a1 100644 --- a/internal/infrastructure/useCases/ListUsersAndInvites.go +++ b/internal/infrastructure/useCases/ListUsersAndInvites.go @@ -2,6 +2,7 @@ package useCases import ( "RenewCMS/internal/domain/invite" + "RenewCMS/internal/domain/role" "RenewCMS/internal/domain/user" "sort" "time" @@ -15,6 +16,7 @@ const ( type ListUsersAndInvitesUseCase struct { userRepository user.Repository inviteRepository invite.Repository + roles role.Repository } type DirectoryEntry struct { @@ -23,30 +25,38 @@ type DirectoryEntry struct { Username string DisplayName string Email string - Role string + Role *RoleRef Status string CreatedAt time.Time } -func NewListUsersAndInvitesUseCase(userRepository user.Repository, inviteRepository invite.Repository) *ListUsersAndInvitesUseCase { - return &ListUsersAndInvitesUseCase{userRepository, inviteRepository} +func NewListUsersAndInvitesUseCase(userRepository user.Repository, inviteRepository invite.Repository, roles role.Repository) *ListUsersAndInvitesUseCase { + return &ListUsersAndInvitesUseCase{userRepository, inviteRepository, roles} } -func (g *ListUsersAndInvitesUseCase) ListUsersAndInvites() []DirectoryEntry { +func userEntry(account user.User, found *RoleRef) DirectoryEntry { + return DirectoryEntry{ + ID: account.ID, + Kind: EntryKindUser, + Username: account.Username, + DisplayName: account.DisplayName, + Email: account.Email, + Role: found, + Status: string(invite.StatusAccepted), + CreatedAt: account.CreatedAt, + } +} + +func (g *ListUsersAndInvitesUseCase) ListUsersAndInvites() ([]DirectoryEntry, error) { + refs, err := roleRefsByID(g.roles) + if err != nil { + return nil, err + } now := time.Now() entries := []DirectoryEntry{} for _, existingUser := range g.userRepository.GetAll() { - entries = append(entries, DirectoryEntry{ - ID: existingUser.ID, - Kind: EntryKindUser, - Username: existingUser.Username, - DisplayName: existingUser.DisplayName, - Email: existingUser.Email, - Role: string(existingUser.Role), - Status: string(invite.StatusAccepted), - CreatedAt: existingUser.CreatedAt, - }) + entries = append(entries, userEntry(existingUser, lookupRoleRef(refs, existingUser.RoleID))) } for _, existingInvite := range g.inviteRepository.GetAll() { @@ -58,6 +68,7 @@ func (g *ListUsersAndInvitesUseCase) ListUsersAndInvites() []DirectoryEntry { ID: existingInvite.ID, Kind: EntryKindInvite, Email: existingInvite.Email, + Role: lookupRoleRef(refs, existingInvite.RoleID), Status: string(status), CreatedAt: existingInvite.CreatedAt, }) @@ -67,5 +78,5 @@ func (g *ListUsersAndInvitesUseCase) ListUsersAndInvites() []DirectoryEntry { return entries[i].CreatedAt.After(entries[j].CreatedAt) }) - return entries + return entries, nil } diff --git a/internal/infrastructure/useCases/RenewInvite.go b/internal/infrastructure/useCases/RenewInvite.go index 920cf7a..8837cc0 100644 --- a/internal/infrastructure/useCases/RenewInvite.go +++ b/internal/infrastructure/useCases/RenewInvite.go @@ -2,9 +2,11 @@ package useCases import ( "RenewCMS/internal/domain/audit" + "RenewCMS/internal/domain/contenttype" "RenewCMS/internal/domain/hash" "RenewCMS/internal/domain/invite" "RenewCMS/internal/domain/mail" + "RenewCMS/internal/domain/role" "RenewCMS/internal/domain/user" "context" "errors" @@ -14,61 +16,75 @@ import ( type RenewInviteUseCase struct { userRepository user.Repository inviteRepository invite.Repository + roles role.Repository + contentTypes contenttype.Repository hasher hash.Hasher mailer inviteMailer recorder audit.Recorder } type RenewInviteCommand struct { + Caller ContentCaller ID uint32 RenewedBy uint32 } -func NewRenewInviteUseCase(userRepository user.Repository, inviteRepository invite.Repository, hasher hash.Hasher, mailRepository mail.Repository, publicURL string, recorder audit.Recorder) *RenewInviteUseCase { - return &RenewInviteUseCase{userRepository, inviteRepository, hasher, inviteMailer{mailRepository, publicURL}, recorder} +func NewRenewInviteUseCase(userRepository user.Repository, inviteRepository invite.Repository, roles role.Repository, contentTypes contenttype.Repository, hasher hash.Hasher, mailRepository mail.Repository, publicURL string, recorder audit.Recorder) *RenewInviteUseCase { + return &RenewInviteUseCase{userRepository, inviteRepository, roles, contentTypes, hasher, inviteMailer{mailRepository, publicURL}, recorder} } -func (u *RenewInviteUseCase) RenewInvite(ctx context.Context, command RenewInviteCommand) (invite.Invite, error) { +func (u *RenewInviteUseCase) coveredRole(caller ContentCaller, previous invite.Invite) (role.Role, error) { + return coveredAssignableRole(caller, u.roles, u.contentTypes, previous.RoleID) +} + +func (u *RenewInviteUseCase) RenewInvite(ctx context.Context, command RenewInviteCommand) (InviteView, error) { event := audit.NewEvent(audit.ActionInviteRenew, audit.CategoryUsers) - renewed, err := u.renew(command) + renewed, held, err := u.renew(command) if err != nil { - recordFailure(ctx, u.recorder, event, err) - return invite.Invite{}, err + if !isInviteRoleRefusal(err) { + recordFailure(ctx, u.recorder, event, err) + } + return InviteView{}, err } event.Target = inviteTarget(renewed) u.recorder.Record(ctx, event) - return renewed, nil + return InviteView{Invite: renewed, Role: NewRoleRef(held)}, nil } -func (u *RenewInviteUseCase) renew(command RenewInviteCommand) (invite.Invite, error) { +func (u *RenewInviteUseCase) renew(command RenewInviteCommand) (invite.Invite, role.Role, error) { now := time.Now() renewer, err := u.userRepository.Get(command.RenewedBy) if err != nil { - return invite.Invite{}, err + return invite.Invite{}, role.Role{}, err } previous, err := findManageableInvite(u.inviteRepository, command.ID) if err != nil { - return invite.Invite{}, err + return invite.Invite{}, role.Role{}, err + } + + held, err := u.coveredRole(command.Caller, previous) + if err != nil { + return invite.Invite{}, role.Role{}, err } rawCode, hashedCode, err := newInviteCode(u.hasher) if err != nil { - return invite.Invite{}, err + return invite.Invite{}, role.Role{}, err } renewed, err := u.inviteRepository.Renew(previous.ID, hashedCode, now.Add(invite.Lifetime), now) if err != nil { - return invite.Invite{}, err + return invite.Invite{}, role.Role{}, err } if sendErr := u.mailer.send(renewed.Email, rawCode, renewer); sendErr != nil { if _, restoreErr := u.inviteRepository.Renew(previous.ID, previous.HashedCode, previous.ExpiresAt, previous.CreatedAt); restoreErr != nil { - return invite.Invite{}, errors.Join(sendErr, restoreErr) + return invite.Invite{}, role.Role{}, errors.Join(sendErr, restoreErr) } - return invite.Invite{}, sendErr + return invite.Invite{}, role.Role{}, sendErr } - return renewed, nil + return renewed, held, nil } diff --git a/internal/infrastructure/useCases/TransferOwnership.go b/internal/infrastructure/useCases/TransferOwnership.go new file mode 100644 index 0000000..cfab3bf --- /dev/null +++ b/internal/infrastructure/useCases/TransferOwnership.go @@ -0,0 +1,103 @@ +package useCases + +import ( + "RenewCMS/internal/domain/audit" + "RenewCMS/internal/domain/role" + "RenewCMS/internal/domain/user" + "context" + "errors" +) + +type TransferOwnershipUseCase struct { + users user.Repository + roles role.Repository + recorder audit.Recorder +} + +type TransferOwnershipCommand struct { + Caller ContentCaller + UserID uint32 + ConfirmEmail string +} + +type OwnershipSuccessor struct { + ID uint32 `json:"id"` + Name string `json:"name"` +} + +type OwnershipPredecessor struct { + ID uint32 `json:"id"` + Name string `json:"name"` + Role RoleRef `json:"role"` +} + +type OwnershipTransfer struct { + Owner OwnershipSuccessor `json:"owner"` + FormerOwner OwnershipPredecessor `json:"former_owner"` +} + +func NewTransferOwnershipUseCase(users user.Repository, roles role.Repository, recorder audit.Recorder) *TransferOwnershipUseCase { + return &TransferOwnershipUseCase{users: users, roles: roles, recorder: recorder} +} + +var transferRefusals = []error{user.ErrRoleChanged, user.ErrInvalidTransfer, user.ErrUserNotFound} + +func (u *TransferOwnershipUseCase) Transfer(ctx context.Context, command TransferOwnershipCommand) (OwnershipTransfer, error) { + successor, err := u.successor(command) + if err != nil { + return OwnershipTransfer{}, err + } + former, err := u.users.Get(command.Caller.UserID) + if err != nil { + return OwnershipTransfer{}, err + } + ownerRole, err := builtInRole(u.roles, role.OwnerName) + if err != nil { + return OwnershipTransfer{}, err + } + adminRole, err := builtInRole(u.roles, role.AdminName) + if err != nil { + return OwnershipTransfer{}, err + } + + event := audit.NewEvent(audit.ActionUserTransferOwnership, audit.CategoryUsers) + event.Security = true + event.Target = audit.NewTarget(audit.TargetUser, successor.ID, successor.Username) + if err := u.users.TransferOwnership(former.ID, successor.ID, ownerRole.ID, adminRole.ID); err != nil { + u.recordWriteFailure(ctx, event, err) + return OwnershipTransfer{}, err + } + event.Params[audit.ParamFrom] = former.ID + event.Params[audit.ParamTo] = successor.ID + u.recorder.Record(ctx, event) + return OwnershipTransfer{ + Owner: OwnershipSuccessor{ID: successor.ID, Name: successor.NameOrUsername()}, + FormerOwner: OwnershipPredecessor{ID: former.ID, Name: former.NameOrUsername(), Role: NewRoleRef(adminRole)}, + }, nil +} + +func (u *TransferOwnershipUseCase) successor(command TransferOwnershipCommand) (user.User, error) { + successor, err := u.users.Get(command.UserID) + if errors.Is(err, user.ErrUserNotFound) { + return user.User{}, user.ErrInvalidTransfer + } + if err != nil { + return user.User{}, err + } + if successor.ID == command.Caller.UserID || !successor.IsVerified { + return user.User{}, user.ErrInvalidTransfer + } + if successor.Email != command.ConfirmEmail { + return user.User{}, user.ErrConfirmationMismatch + } + return successor, nil +} + +func (u *TransferOwnershipUseCase) recordWriteFailure(ctx context.Context, event audit.Event, err error) { + for _, refusal := range transferRefusals { + if errors.Is(err, refusal) { + return + } + } + recordFailure(ctx, u.recorder, event, err) +} diff --git a/internal/infrastructure/useCases/roleRef.go b/internal/infrastructure/useCases/roleRef.go new file mode 100644 index 0000000..bc0b68f --- /dev/null +++ b/internal/infrastructure/useCases/roleRef.go @@ -0,0 +1,42 @@ +package useCases + +import ( + "RenewCMS/internal/domain/invite" + "RenewCMS/internal/domain/role" +) + +type InviteView struct { + invite.Invite + Role RoleRef +} + +type RoleRef struct { + ID uint32 `json:"id"` + Name string `json:"name"` + BuiltIn bool `json:"built_in"` + Locked bool `json:"locked"` +} + +func NewRoleRef(found role.Role) RoleRef { + return RoleRef{ID: found.ID, Name: found.Name, BuiltIn: found.BuiltIn, Locked: found.Locked} +} + +func roleRefsByID(roles role.Repository) (map[uint32]RoleRef, error) { + listed, err := roles.List() + if err != nil { + return nil, err + } + refs := make(map[uint32]RoleRef, len(listed)) + for _, found := range listed { + refs[found.ID] = NewRoleRef(found) + } + return refs, nil +} + +func lookupRoleRef(refs map[uint32]RoleRef, id uint32) *RoleRef { + ref, known := refs[id] + if !known { + return nil + } + return &ref +} diff --git a/internal/infrastructure/useCases/roleSupport.go b/internal/infrastructure/useCases/roleSupport.go index 26a3f4f..473e47c 100644 --- a/internal/infrastructure/useCases/roleSupport.go +++ b/internal/infrastructure/useCases/roleSupport.go @@ -11,6 +11,17 @@ import ( "slices" ) +func builtInRole(roles role.Repository, name string) (role.Role, error) { + found, err := roles.GetByName(name) + if err != nil { + return role.Role{}, err + } + if !found.BuiltIn { + return role.Role{}, role.ErrRoleNotFound + } + return found, nil +} + func roleTarget(found role.Role) audit.Target { return audit.NewTarget(audit.TargetRole, found.ID, found.Name) } @@ -33,9 +44,41 @@ func ensureCovered(caller ContentCaller, contentTypes contenttype.Repository, ca if err != nil { return err } + return ensureCoveredBy(caller, typeKeys, candidate) +} + +func ensureCoveredBy(caller ContentCaller, typeKeys []string, candidate role.Role) error { return caller.Grants.Covers(candidate.GrantSet(), typeKeys) } +func assignableRole(roles role.Repository, id uint32) (role.Role, error) { + if id == 0 { + return role.Role{}, role.ErrUnknownRole + } + found, err := roles.Get(id) + if errors.Is(err, role.ErrRoleNotFound) { + return role.Role{}, role.ErrUnknownRole + } + if err != nil { + return role.Role{}, err + } + if found.Locked { + return role.Role{}, role.ErrRoleLocked + } + return found, nil +} + +func coveredAssignableRole(caller ContentCaller, roles role.Repository, contentTypes contenttype.Repository, id uint32) (role.Role, error) { + found, err := assignableRole(roles, id) + if err != nil { + return role.Role{}, err + } + if err := ensureCovered(caller, contentTypes, found); err != nil { + return role.Role{}, err + } + return found, nil +} + func ensureNotOwnRole(roles role.Repository, users user.Repository, caller ContentCaller, target role.Role) error { account, err := users.Get(caller.UserID) if err != nil { diff --git a/internal/infrastructure/useCases/roleView.go b/internal/infrastructure/useCases/roleView.go index 3045d6a..623eae6 100644 --- a/internal/infrastructure/useCases/roleView.go +++ b/internal/infrastructure/useCases/roleView.go @@ -4,7 +4,6 @@ import ( "RenewCMS/internal/domain/contenttype" "RenewCMS/internal/domain/role" "RenewCMS/internal/domain/user" - "errors" "slices" "time" ) @@ -43,7 +42,7 @@ func (v roleViewer) liveGrants(grants []role.Grant) ([]role.Grant, error) { } func (v roleViewer) views(found []role.Role) ([]RoleView, error) { - counts, err := memberCounts(v.roles, v.users.GetAll()) + counts, err := v.roles.MemberCounts() if err != nil { return nil, err } @@ -75,7 +74,7 @@ func (v roleViewer) one(found role.Role, members int) (RoleView, error) { } func (v roleViewer) members(roleID uint32) (int, error) { - counts, err := memberCounts(v.roles, v.users.GetAll()) + counts, err := v.roles.MemberCounts() if err != nil { return 0, err } @@ -117,27 +116,3 @@ func toRoleView(found role.Role, members int, editors map[uint32]string) RoleVie } return view } - -func memberCounts(roles role.Repository, accounts []user.User) (map[uint32]int, error) { - counts := map[uint32]int{} - legacyIDs := map[user.Role]uint32{} - for _, account := range accounts { - id := account.RoleID - if id == 0 { - resolved, cached := legacyIDs[account.Role] - if !cached { - found, err := resolveRole(roles, account) - if err != nil && !errors.Is(err, role.ErrRoleNotFound) { - return nil, err - } - resolved = found.ID - legacyIDs[account.Role] = resolved - } - id = resolved - } - if id != 0 { - counts[id]++ - } - } - return counts, nil -} diff --git a/test/accept_invitation_atomic_test.go b/test/accept_invitation_atomic_test.go index 6f4a9b6..657c768 100644 --- a/test/accept_invitation_atomic_test.go +++ b/test/accept_invitation_atomic_test.go @@ -116,7 +116,7 @@ func newAtomicAcceptFixture(t *testing.T, wrap func(db *gorm.DB, repo invite.Rep hasher := atomicPlainHasher{} handler := auth.NewAcceptInviteHandler( useCases.NewGetInviteUseCase(invites, hasher), - useCases.NewAcceptInviteUseCase(users, invites, hasher, discardRecorder), + useCases.NewAcceptInviteUseCase(users, invites, newSeededMockRoleRepo(), hasher, discardRecorder), authtoken.New([]byte("test-secret")), true, ) @@ -143,6 +143,7 @@ func seedRealInvite(t *testing.T, db *gorm.DB) models.Invite { HashedCode: "hashed:" + acceptCode, ExpiresAt: time.Now().Add(48 * time.Hour), InvitedBy: 1, + RoleID: uaRoleIDPtr(writerRoleID), } if err := db.Create(&pending).Error; err != nil { t.Fatalf("seed invite: %v", err) diff --git a/test/accept_invitation_test.go b/test/accept_invitation_test.go index 4799829..1398f34 100644 --- a/test/accept_invitation_test.go +++ b/test/accept_invitation_test.go @@ -55,20 +55,20 @@ func newAcceptInviteFixture(t *testing.T, maxAttempts int) *acceptInviteFixture getUser := useCases.NewGetUserUseCase(f.users) acceptHandler := auth.NewAcceptInviteHandler( useCases.NewGetInviteUseCase(f.invites, f.hasher), - useCases.NewAcceptInviteUseCase(f.users, f.invites, f.hasher, discardRecorder), + useCases.NewAcceptInviteUseCase(f.users, f.invites, newSeededMockRoleRepo(), f.hasher, discardRecorder), f.token, true, ) meHandler := auth.NewMeHandler(getUser, useCases.NewGetMyAuthorUseCase(plainAuthors()), useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo())) listHandler := userhandler.NewInviteHandler( - useCases.NewCreateInviteUseCase(f.users, f.invites, f.hasher, f.mail, invitePublicURL, discardRecorder), + useCases.NewCreateInviteUseCase(f.users, f.invites, newSeededMockRoleRepo(), newMockContentTypeRepo(), f.hasher, f.mail, invitePublicURL, discardRecorder), nil, nil, - useCases.NewListUsersAndInvitesUseCase(f.users, f.invites), + useCases.NewListUsersAndInvitesUseCase(f.users, f.invites, newSeededMockRoleRepo()), ) registerHandler := auth.NewLoginHandler( f.token, - useCases.NewCreateUserUseCase(f.users, f.hasher, f.mail, invitePublicURL, discardRecorder), + useCases.NewCreateUserUseCase(f.users, newSeededMockRoleRepo(), f.hasher, f.mail, invitePublicURL, discardRecorder), useCases.NewAuthenticateUserUseCase(f.users, f.hasher, discardRecorder), true, ) @@ -497,7 +497,7 @@ func TestAcceptInviteUseCase(t *testing.T) { newUseCase := func() (*useCases.AcceptInviteUseCase, *mockUserRepo, *inviteFakeRepo, *stubHasher) { users, invites, hasher := inviteInviterRepo(""), newInviteFakeRepo(), &stubHasher{} invites.users = users - return useCases.NewAcceptInviteUseCase(users, invites, hasher, discardRecorder), users, invites, hasher + return useCases.NewAcceptInviteUseCase(users, invites, newSeededMockRoleRepo(), hasher, discardRecorder), users, invites, hasher } command := useCases.AcceptInviteCommand{Code: acceptCode, Username: "lea", Password: acceptPassword} @@ -571,7 +571,7 @@ func TestAcceptInviteUseCase(t *testing.T) { invites.invites[seeded.ID] = seeded users.users[8] = user.User{ID: 8, Username: "lea2", Email: acceptEmail} }} - uc := useCases.NewAcceptInviteUseCase(users, racing, hasher, discardRecorder) + uc := useCases.NewAcceptInviteUseCase(users, racing, newSeededMockRoleRepo(), hasher, discardRecorder) if _, err := uc.AcceptInvite(testCtx, command); !errors.Is(err, invite.ErrInviteInvalid) { t.Errorf("expected ErrInviteInvalid, got %v", err) } diff --git a/test/activity_log_backend_http_test.go b/test/activity_log_backend_http_test.go index 48c8b59..ce828bc 100644 --- a/test/activity_log_backend_http_test.go +++ b/test/activity_log_backend_http_test.go @@ -64,10 +64,10 @@ func newActivityHTTPEnv(t *testing.T, attempts int) *activityHTTPEnv { token: authtoken.New([]byte("test-secret")), generator: hashing.NewSHA256KeyGenerator(), } - e.users.users[activityOwnerID] = user.User{ID: activityOwnerID, Username: "marta", DisplayName: "Marta Alves", Email: "marta@example.com", Password: "hashed:" + activityPassword, IsVerified: true, Role: user.RoleOwner} - e.users.users[activityAdminID] = user.User{ID: activityAdminID, Username: "dev", Email: "dev@example.com", Password: "hashed:" + activityPassword, IsVerified: true, Role: user.RoleAdmin} - e.users.users[activityEditorID] = user.User{ID: activityEditorID, Username: "ed", Email: "ed@example.com", Password: "hashed:" + activityPassword, IsVerified: true, Role: "editor", RoleID: writerRoleID} - e.users.users[activityUnverifiedID] = user.User{ID: activityUnverifiedID, Username: "quinn", Email: "quinn@example.com", IsVerified: false, Role: user.RoleOwner} + e.users.users[activityOwnerID] = user.User{ID: activityOwnerID, Username: "marta", DisplayName: "Marta Alves", Email: "marta@example.com", Password: "hashed:" + activityPassword, IsVerified: true, RoleID: ownerRoleID} + e.users.users[activityAdminID] = user.User{ID: activityAdminID, Username: "dev", Email: "dev@example.com", Password: "hashed:" + activityPassword, IsVerified: true, RoleID: adminRoleID} + e.users.users[activityEditorID] = user.User{ID: activityEditorID, Username: "ed", Email: "ed@example.com", Password: "hashed:" + activityPassword, IsVerified: true, RoleID: writerRoleID} + e.users.users[activityUnverifiedID] = user.User{ID: activityUnverifiedID, Username: "quinn", Email: "quinn@example.com", IsVerified: false, RoleID: ownerRoleID} e.users.nextID = 4 hasher := &stubHasher{} diff --git a/test/activity_log_producers_http_test.go b/test/activity_log_producers_http_test.go index de2f370..758b952 100644 --- a/test/activity_log_producers_http_test.go +++ b/test/activity_log_producers_http_test.go @@ -115,7 +115,7 @@ func (p *producersEnv) seedBanner(id uint32, filename string, hash string) { } func (p *producersEnv) seedPendingInvite(email string) invite.Invite { - return p.invites.seed(invite.Invite{Email: email, HashedCode: "hashed:code", ExpiresAt: time.Now().Add(24 * time.Hour), InvitedBy: apiKeyOwnerId, CreatedAt: time.Now()}) + return p.invites.seed(invite.Invite{Email: email, HashedCode: "hashed:code", RoleID: writerRoleID, ExpiresAt: time.Now().Add(24 * time.Hour), InvitedBy: apiKeyOwnerId, CreatedAt: time.Now()}) } func paramsJSON(t *testing.T, event audit.Event) string { @@ -770,7 +770,7 @@ func TestActivityLogProducersUsers(t *testing.T) { func (p *producersEnv) seedAcceptedInvite(email string) { accepted := time.Now().Add(-time.Hour) - p.invites.seed(invite.Invite{Email: email, HashedCode: "hashed:code", ExpiresAt: time.Now().Add(24 * time.Hour), InvitedBy: apiKeyOwnerId, AcceptedAt: &accepted, CreatedAt: time.Now().Add(-2 * time.Hour)}) + p.invites.seed(invite.Invite{Email: email, HashedCode: "hashed:code", RoleID: writerRoleID, ExpiresAt: time.Now().Add(24 * time.Hour), InvitedBy: apiKeyOwnerId, AcceptedAt: &accepted, CreatedAt: time.Now().Add(-2 * time.Hour)}) } func TestActivityLogProducersSystem(t *testing.T) { diff --git a/test/activity_log_producers_test.go b/test/activity_log_producers_test.go index 725ab9d..457a781 100644 --- a/test/activity_log_producers_test.go +++ b/test/activity_log_producers_test.go @@ -422,7 +422,7 @@ func TestActivityLogProducersAccounts(t *testing.T) { t.Run("Registering is recorded with the new account as actor", func(t *testing.T) { h := newActivityHarness(t) - uc := useCases.NewCreateUserUseCase(newUsers(), &stubHasher{}, newMockMailRepo(), "https://cms.example.com", h.recorder) + uc := useCases.NewCreateUserUseCase(newUsers(), newSeededMockRoleRepo(), &stubHasher{}, newMockMailRepo(), "https://cms.example.com", h.recorder) created, err := uc.CreateUser(producerAnonymousContext(), useCases.CreateUserCommand{Username: "marta", Password: password, Email: "marta@example.com"}) @@ -454,7 +454,7 @@ func TestActivityLogProducersAccounts(t *testing.T) { users := newUsers() mail := newMockMailRepo() example.setup(users, mail) - uc := useCases.NewCreateUserUseCase(users, &stubHasher{}, mail, "https://cms.example.com", h.recorder) + uc := useCases.NewCreateUserUseCase(users, newSeededMockRoleRepo(), &stubHasher{}, mail, "https://cms.example.com", h.recorder) if _, err := uc.CreateUser(producerAnonymousContext(), useCases.CreateUserCommand{Username: "marta", Password: password, Email: "marta@example.com"}); err == nil { t.Fatal("expected a refusal") @@ -478,7 +478,7 @@ func TestActivityLogProducersAccounts(t *testing.T) { mail.fail.failNext("Send") cleanupFailure := errors.New("cleanup failure") users.fail.failNextWith("Delete", cleanupFailure) - uc := useCases.NewCreateUserUseCase(users, &stubHasher{}, mail, "https://cms.example.com", h.recorder) + uc := useCases.NewCreateUserUseCase(users, newSeededMockRoleRepo(), &stubHasher{}, mail, "https://cms.example.com", h.recorder) _, err := uc.CreateUser(producerAnonymousContext(), useCases.CreateUserCommand{Username: "marta", Password: password, Email: "marta@example.com"}) @@ -539,8 +539,8 @@ func TestActivityLogProducersAccounts(t *testing.T) { users := newUsers() invites := newInviteFakeRepo() invites.users = users - seeded := invites.seed(invite.Invite{Email: "new@example.com", HashedCode: "hashed:the-code", ExpiresAt: time.Now().Add(time.Hour)}) - uc := useCases.NewAcceptInviteUseCase(users, invites, &stubHasher{}, h.recorder) + seeded := invites.seed(invite.Invite{Email: "new@example.com", HashedCode: "hashed:the-code", RoleID: writerRoleID, ExpiresAt: time.Now().Add(time.Hour)}) + uc := useCases.NewAcceptInviteUseCase(users, invites, newSeededMockRoleRepo(), &stubHasher{}, h.recorder) created, err := uc.AcceptInvite(producerAnonymousContext(), useCases.AcceptInviteCommand{Code: "the-code", Username: "sam", Password: password}) @@ -578,8 +578,8 @@ func TestActivityLogProducersAccounts(t *testing.T) { } invites := newInviteFakeRepo() invites.users = users - invites.seed(invite.Invite{Email: "new@example.com", HashedCode: "hashed:the-code", ExpiresAt: time.Now().Add(example.expiresIn)}) - uc := useCases.NewAcceptInviteUseCase(users, invites, &stubHasher{}, h.recorder) + invites.seed(invite.Invite{Email: "new@example.com", HashedCode: "hashed:the-code", RoleID: writerRoleID, ExpiresAt: time.Now().Add(example.expiresIn)}) + uc := useCases.NewAcceptInviteUseCase(users, invites, newSeededMockRoleRepo(), &stubHasher{}, h.recorder) if _, err := uc.AcceptInvite(producerAnonymousContext(), useCases.AcceptInviteCommand{Code: example.code, Username: "sam", Password: password}); err == nil { t.Fatal("expected a refusal") diff --git a/test/activity_usecases_test.go b/test/activity_usecases_test.go index 2f6075b..467b4ff 100644 --- a/test/activity_usecases_test.go +++ b/test/activity_usecases_test.go @@ -38,9 +38,9 @@ func newActivityWorld(t *testing.T) *activityWorld { keys: newMockAPIKeyRepo(), } w.probe.maxReads = 50 - w.users.users[1] = user.User{ID: 1, Username: "owner", IsVerified: true, Role: user.RoleOwner} - w.users.users[2] = user.User{ID: 2, Username: "admin", IsVerified: true, Role: user.RoleAdmin} - w.users.users[3] = user.User{ID: 3, Username: "marta", DisplayName: "Marta Alves", IsVerified: true, Role: "editor"} + w.users.users[1] = user.User{ID: 1, Username: "owner", IsVerified: true, RoleID: ownerRoleID} + w.users.users[2] = user.User{ID: 2, Username: "admin", IsVerified: true, RoleID: adminRoleID} + w.users.users[3] = user.User{ID: 3, Username: "marta", DisplayName: "Marta Alves", IsVerified: true} w.list = useCases.NewListActivityUseCase(h.probe, w.users, w.articles, w.images, w.keys, h.clock, plainSites(), plainAuthors()) w.summary = useCases.NewGetActivitySummaryUseCase(h.probe, h.clock) w.export = useCases.NewExportActivityUseCase(h.probe, h.clock) diff --git a/test/api_catalogue_drift_test.go b/test/api_catalogue_drift_test.go index 5e5b9df..55ed44f 100644 --- a/test/api_catalogue_drift_test.go +++ b/test/api_catalogue_drift_test.go @@ -79,10 +79,12 @@ var routesNotYetInTheCatalogue = []string{ "POST /v1/role", "POST /v1/role/{id}/reassign", "POST /v1/role/{id}/reset", + "POST /v1/site/transfer-ownership", "PUT /v1/content-type/{key}", "PUT /v1/content-type/{key}/entry/{id}", "PUT /v1/image-variants/{name}", "PUT /v1/role/{id}", + "PUT /v1/user/{id}/role", } func TestRouterRoutesAreInTheCatalogue(t *testing.T) { diff --git a/test/api_keys_http_test.go b/test/api_keys_http_test.go index 2f1a534..e146bb7 100644 --- a/test/api_keys_http_test.go +++ b/test/api_keys_http_test.go @@ -93,10 +93,10 @@ func newAPIKeyEnvRecording(t *testing.T, recorder audit.Recorder) *apiKeyEnv { Notes: update.ReleaseNotes{Version: "v2.3.0-beta", Items: []string{"Media library"}}, }} - env.users.users[apiKeyOwnerId] = user.User{ID: apiKeyOwnerId, Username: "marta", Email: "marta@example.com", Password: "hashed:oldpassword1", IsVerified: true, Role: user.RoleOwner} - env.users.users[apiKeyDevId] = user.User{ID: apiKeyDevId, Username: "dev", Email: "dev@example.com", Password: "hashed:oldpassword1", IsVerified: true, Role: user.RoleAdmin, PasswordResetCode: "hashed:reset-code-1", PasswordResetExpiration: env.now.Add(time.Hour)} - env.users.users[apiKeyPatId] = user.User{ID: apiKeyPatId, Username: "pat", Email: "pat@example.com", IsVerified: true, Role: user.RoleAdmin} - env.users.users[apiKeyQuinnId] = user.User{ID: apiKeyQuinnId, Username: "quinn", Email: "quinn@example.com", IsVerified: false, Role: user.RoleAdmin} + env.users.users[apiKeyOwnerId] = user.User{ID: apiKeyOwnerId, Username: "marta", Email: "marta@example.com", Password: "hashed:oldpassword1", IsVerified: true, RoleID: ownerRoleID} + env.users.users[apiKeyDevId] = user.User{ID: apiKeyDevId, Username: "dev", Email: "dev@example.com", Password: "hashed:oldpassword1", IsVerified: true, RoleID: adminRoleID, PasswordResetCode: "hashed:reset-code-1", PasswordResetExpiration: env.now.Add(time.Hour)} + env.users.users[apiKeyPatId] = user.User{ID: apiKeyPatId, Username: "pat", Email: "pat@example.com", IsVerified: true, RoleID: adminRoleID} + env.users.users[apiKeyQuinnId] = user.User{ID: apiKeyQuinnId, Username: "quinn", Email: "quinn@example.com", IsVerified: false, RoleID: adminRoleID} env.users.nextID = 4 clock := func() time.Time { return env.now } @@ -119,19 +119,19 @@ func newAPIKeyEnvRecording(t *testing.T, recorder audit.Recorder) *apiKeyEnv { GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(env.roles), CheckPermissionUseCase: useCases.NewCheckPermissionUseCase(env.roles), ListPermissionsUseCase: useCases.NewListPermissionsUseCase(), - CreateUserUseCase: useCases.NewCreateUserUseCase(env.users, hasher, mail, "https://cms.example.com", recorder), + CreateUserUseCase: useCases.NewCreateUserUseCase(env.users, newSeededMockRoleRepo(), hasher, mail, "https://cms.example.com", recorder), AuthenticateUserUseCase: useCases.NewAuthenticateUserUseCase(env.users, hasher, recorder), VerifyUserUseCase: useCases.NewVerifyUserUseCase(env.users, hasher, recorder), CancelRegistrationUseCase: useCases.NewCancelRegistrationUseCase(env.users), RequestPasswordResetUseCase: useCases.NewRequestPasswordResetUseCase(env.users, hasher, mail, "https://cms.example.com", recorder), ResetPasswordUseCase: useCases.NewResetPasswordUseCase(env.users, hasher, env.keys, clock, recorder), UpdateUserProfileUseCase: useCases.NewUpdateUserProfileUseCase(env.users, recorder), - CreateInviteUseCase: useCases.NewCreateInviteUseCase(env.users, env.invites, hasher, mail, "https://cms.example.com", recorder), + CreateInviteUseCase: useCases.NewCreateInviteUseCase(env.users, env.invites, newSeededMockRoleRepo(), newMockContentTypeRepo(), hasher, mail, "https://cms.example.com", recorder), CancelInviteUseCase: useCases.NewCancelInviteUseCase(env.invites, recorder), - RenewInviteUseCase: useCases.NewRenewInviteUseCase(env.users, env.invites, hasher, mail, "https://cms.example.com", recorder), - ListUsersAndInvitesUseCase: useCases.NewListUsersAndInvitesUseCase(env.users, env.invites), + RenewInviteUseCase: useCases.NewRenewInviteUseCase(env.users, env.invites, newSeededMockRoleRepo(), newMockContentTypeRepo(), hasher, mail, "https://cms.example.com", recorder), + ListUsersAndInvitesUseCase: useCases.NewListUsersAndInvitesUseCase(env.users, env.invites, newSeededMockRoleRepo()), GetInviteUseCase: useCases.NewGetInviteUseCase(env.invites, hasher), - AcceptInviteUseCase: useCases.NewAcceptInviteUseCase(env.users, env.invites, hasher, recorder), + AcceptInviteUseCase: useCases.NewAcceptInviteUseCase(env.users, env.invites, newSeededMockRoleRepo(), hasher, recorder), CreateImageUseCase: useCases.NewCreateImageUseCase(env.images, newMockImageStorage(), recorder, imaging.NewMetadataStripper()), GenerateVariantsOnUploadUseCase: newInertGenerateOnUploadUseCase(), ListMediaUseCase: useCases.NewListMediaUseCase(env.images, env.articles, env.users, newMockVariantFileRepo(), plainSites(), plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo()), @@ -575,7 +575,7 @@ func TestAPIKeysHttp(t *testing.T) { t.Run("A writer's key can not invite", func(t *testing.T) { env := newAPIKeyEnv(t) - env.addUser(5, "", writerRoleID, true) + env.addUser(5, writerRoleID, true) raw, _ := env.issueKey(5, "ci") rec := env.withKey(t, http.MethodPost, "/v1/user/invite", `{"email": "new@example.com"}`, raw) @@ -657,7 +657,7 @@ func TestAPIKeysHttp(t *testing.T) { t.Run("A Bearer key wins over the cookie, and a bad key is not rescued by a good cookie", func(t *testing.T) { env := newAPIKeyEnv(t) - env.addUser(5, "", writerRoleID, true) + env.addUser(5, writerRoleID, true) raw, _ := env.issueKey(5, "ci") invite := env.send(t, apiKeyRequest{method: http.MethodPost, path: "/v1/user/invite", body: `{"email": "new@example.com"}`, bearer: raw, cookieUser: apiKeyOwnerId}) diff --git a/test/api_keys_test.go b/test/api_keys_test.go index 34a5ce0..23e3241 100644 --- a/test/api_keys_test.go +++ b/test/api_keys_test.go @@ -181,10 +181,10 @@ var ( func apiKeyUnitUsers() *mockUserRepo { users := newMockUserRepo() - users.users[1] = user.User{ID: 1, Username: "marta", IsVerified: true, Role: user.RoleOwner} - users.users[2] = user.User{ID: 2, Username: "dev", IsVerified: true, Role: user.RoleAdmin, TokenVersion: 3} - users.users[3] = user.User{ID: 3, Username: "pat", IsVerified: true, Role: user.RoleAdmin} - users.users[4] = user.User{ID: 4, Username: "quinn", IsVerified: false, Role: user.RoleAdmin} + users.users[1] = user.User{ID: 1, Username: "marta", IsVerified: true, RoleID: ownerRoleID} + users.users[2] = user.User{ID: 2, Username: "dev", IsVerified: true, RoleID: adminRoleID, TokenVersion: 3} + users.users[3] = user.User{ID: 3, Username: "pat", IsVerified: true, RoleID: adminRoleID} + users.users[4] = user.User{ID: 4, Username: "quinn", IsVerified: false, RoleID: adminRoleID} return users } diff --git a/test/author_support_test.go b/test/author_support_test.go index bdb23d2..c1a3183 100644 --- a/test/author_support_test.go +++ b/test/author_support_test.go @@ -340,7 +340,6 @@ var _ author.Repository = &mockAuthorRepo{} const ( authorEditorID = 5 authorOtherEditorID = 6 - authorEditorRole = user.Role("editor") ) type authorEnv struct { @@ -351,8 +350,8 @@ type authorEnv struct { func newAuthorEnv(t *testing.T) *authorEnv { t.Helper() p := newProducersEnv(t) - p.users.users[authorEditorID] = user.User{ID: authorEditorID, Username: "edith", DisplayName: "Edith Moreau", Email: "edith@example.com", IsVerified: true, Role: authorEditorRole, RoleID: writerRoleID} - p.users.users[authorOtherEditorID] = user.User{ID: authorOtherEditorID, Username: "frank", Email: "frank@example.com", IsVerified: true, Role: authorEditorRole, RoleID: writerRoleID} + p.users.users[authorEditorID] = user.User{ID: authorEditorID, Username: "edith", DisplayName: "Edith Moreau", Email: "edith@example.com", IsVerified: true, RoleID: writerRoleID} + p.users.users[authorOtherEditorID] = user.User{ID: authorOtherEditorID, Username: "frank", Email: "frank@example.com", IsVerified: true, RoleID: writerRoleID} p.users.nextID = authorOtherEditorID authors := newMockAuthorRepo(p.articles) sites := plainSites() @@ -437,9 +436,9 @@ func joinNames(names []string) string { } func userWithEmail(id uint32, username string, email string) user.User { - return user.User{ID: id, Username: username, Email: email, IsVerified: true, Role: authorEditorRole, RoleID: writerRoleID} + return user.User{ID: id, Username: username, Email: email, IsVerified: true, RoleID: writerRoleID} } func userNamed(id uint32, username string, displayName string) user.User { - return user.User{ID: id, Username: username, DisplayName: displayName, Email: username + "@example.com", IsVerified: true, Role: authorEditorRole, RoleID: writerRoleID} + return user.User{ID: id, Username: username, DisplayName: displayName, Email: username + "@example.com", IsVerified: true, RoleID: writerRoleID} } diff --git a/test/authors_backend_articles_http_test.go b/test/authors_backend_articles_http_test.go index 4c0964a..9f4ca50 100644 --- a/test/authors_backend_articles_http_test.go +++ b/test/authors_backend_articles_http_test.go @@ -383,7 +383,7 @@ func TestAuthorsBackendCurrentUser(t *testing.T) { t.Run("The existing fields are unchanged", func(t *testing.T) { e := newAuthorEnv(t) body := decodeAuthorBody(t, e.as(t, authorEditorID, http.MethodGet, "/v1/auth/me", "")) - for _, key := range []string{"id", "username", "email", "is_verified", "created_at", "updated_at", "display_name", "role"} { + for _, key := range []string{"id", "username", "email", "is_verified", "created_at", "updated_at", "display_name", "access"} { if _, present := body[key]; !present { t.Errorf("missing %s", key) } diff --git a/test/cancel_pending_registration_http_test.go b/test/cancel_pending_registration_http_test.go index 89954d8..b146ba4 100644 --- a/test/cancel_pending_registration_http_test.go +++ b/test/cancel_pending_registration_http_test.go @@ -19,7 +19,7 @@ func newCancelRegistrationRouter(userRepo *mockUserRepo, mailRepo *mockMailRepo, hasher := &stubHasher{} loginHandler := auth.NewLoginHandler( token, - useCases.NewCreateUserUseCase(userRepo, hasher, mailRepo, "https://cms.example.com", discardRecorder), + useCases.NewCreateUserUseCase(userRepo, newSeededMockRoleRepo(), hasher, mailRepo, "https://cms.example.com", discardRecorder), useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder), true, ) diff --git a/test/custom_content_types_support_http_test.go b/test/custom_content_types_support_http_test.go index ead3f4e..4cdefe4 100644 --- a/test/custom_content_types_support_http_test.go +++ b/test/custom_content_types_support_http_test.go @@ -40,8 +40,8 @@ var supportFieldKeys = []string{"default", "key", "label", "max", "min", "option func newSupportEnv(t *testing.T) *contentTypeEnv { t.Helper() env := newContentTypeEnv(t) - env.users.users[ctOwnerId] = user.User{ID: ctOwnerId, Username: "marta", DisplayName: "Marta Alves", IsVerified: true, Role: user.RoleOwner} - env.users.users[ctMemberId] = user.User{ID: ctMemberId, Username: "sam", DisplayName: "Sam Roy", IsVerified: true, Role: user.RoleAdmin} + env.users.users[ctOwnerId] = user.User{ID: ctOwnerId, Username: "marta", DisplayName: "Marta Alves", IsVerified: true, RoleID: ownerRoleID} + env.users.users[ctMemberId] = user.User{ID: ctMemberId, Username: "sam", DisplayName: "Sam Roy", IsVerified: true, RoleID: adminRoleID} return env } @@ -332,7 +332,7 @@ func TestBuiltinTypeHTTP(t *testing.T) { {"site", "site_name", "text", false, nil, float64(100), []any{}}, {"site", "title_template", "text", false, nil, float64(200), []any{}}, {"site", "default_description", "text", false, nil, float64(320), []any{}}, - {"user", "role", "select", true, nil, nil, []any{"owner", "admin"}}, + {"user", "role", "text", false, nil, nil, []any{}}, {"user", "email", "text", true, nil, nil, []any{}}, } for _, tc := range cases { @@ -412,7 +412,7 @@ func TestBuiltinTypeHTTP(t *testing.T) { t.Run("Reading the built-in types never touches data", func(t *testing.T) { validator.InitValidator() users := newMockUserRepo() - users.users[ctAdminId] = user.User{ID: ctAdminId, Username: "dev", IsVerified: true, Role: user.RoleAdmin} + users.users[ctAdminId] = user.User{ID: ctAdminId, Username: "dev", IsVerified: true, RoleID: adminRoleID} token := authtoken.New([]byte("test-secret")) now := func() time.Time { return time.Now() } generator := hashing.NewSHA256KeyGenerator() @@ -445,6 +445,8 @@ type panickingContentTypeRepo struct{ contenttype.Repository } type panickingEntryRepo struct{ entry.Repository } +var catalogueFieldsServedAsReferences = map[string]struct{}{"user.role": {}} + func TestBuiltinTypeCatalogueMatchesEntities(t *testing.T) { entities := map[string]any{ builtintype.KeyArticle: article.Article{}, @@ -466,6 +468,9 @@ func TestBuiltinTypeCatalogueMatchesEntities(t *testing.T) { t.Fatal(err) } for _, field := range found.Fields { + if _, derived := catalogueFieldsServedAsReferences[key+"."+field.Key]; derived { + continue + } if _, exists := object[field.Key]; !exists { t.Errorf("catalogue field %s.%s does not exist in the entity JSON", key, field.Key) } diff --git a/test/custom_content_types_support_test.go b/test/custom_content_types_support_test.go index ccada0e..599eb0a 100644 --- a/test/custom_content_types_support_test.go +++ b/test/custom_content_types_support_test.go @@ -257,11 +257,11 @@ func newContentTypeEnv(t *testing.T) *contentTypeEnv { token: authtoken.New([]byte("test-secret")), clock: time.Date(2026, time.October, 5, 9, 0, 0, 0, time.UTC), } - env.users.users[ctOwnerId] = user.User{ID: ctOwnerId, Username: "marta", IsVerified: true, Role: user.RoleOwner} - env.users.users[ctAdminId] = user.User{ID: ctAdminId, Username: "dev", IsVerified: true, Role: user.RoleAdmin} + env.users.users[ctOwnerId] = user.User{ID: ctOwnerId, Username: "marta", IsVerified: true, RoleID: ownerRoleID} + env.users.users[ctAdminId] = user.User{ID: ctAdminId, Username: "dev", IsVerified: true, RoleID: adminRoleID} env.users.users[ctMemberId] = user.User{ID: ctMemberId, Username: "pat", IsVerified: true} - env.users.users[ctUnverifiedId] = user.User{ID: ctUnverifiedId, Username: "quinn", IsVerified: false, Role: user.RoleAdmin} - env.users.users[ctStaleId] = user.User{ID: ctStaleId, Username: "stale", IsVerified: true, Role: user.RoleAdmin, TokenVersion: 1} + env.users.users[ctUnverifiedId] = user.User{ID: ctUnverifiedId, Username: "quinn", IsVerified: false, RoleID: adminRoleID} + env.users.users[ctStaleId] = user.User{ID: ctStaleId, Username: "stale", IsVerified: true, RoleID: adminRoleID, TokenVersion: 1} now := func() time.Time { return env.clock } container := &api.UseCases{ Token: env.token, @@ -273,7 +273,7 @@ func newContentTypeEnv(t *testing.T) *contentTypeEnv { GetContentTypeUseCase: useCases.NewGetContentTypeUseCase(env.types), UpdateContentTypeUseCase: useCases.NewUpdateContentTypeUseCase(env.types, env.entries, now), DeleteContentTypeUseCase: useCases.NewDeleteContentTypeUseCase(env.types, env.entries, newMockRoleRepo()), - ListUsersAndInvitesUseCase: useCases.NewListUsersAndInvitesUseCase(env.users, newInviteFakeRepo()), + ListUsersAndInvitesUseCase: useCases.NewListUsersAndInvitesUseCase(env.users, newInviteFakeRepo(), newSeededMockRoleRepo()), ListBuiltinTypesUseCase: useCases.NewListBuiltinTypesUseCase(), GetBuiltinTypeUseCase: useCases.NewGetBuiltinTypeUseCase(), LookupUserNamesUseCase: useCases.NewLookupUserNamesUseCase(env.users), diff --git a/test/get_current_user_http_test.go b/test/get_current_user_http_test.go index ff72ad0..f150de5 100644 --- a/test/get_current_user_http_test.go +++ b/test/get_current_user_http_test.go @@ -67,7 +67,7 @@ func TestGetCurrentUserHttp(t *testing.T) { userRepo.users[7] = user.User{ ID: 7, Username: "alice", - Role: user.RoleOwner, + RoleID: ownerRoleID, Email: "alice@example.com", IsVerified: true, TokenVersion: 0, @@ -98,7 +98,7 @@ func TestGetCurrentUserHttp(t *testing.T) { userRepo.users[8] = user.User{ ID: 8, Username: "bob", - Role: user.RoleOwner, + RoleID: ownerRoleID, Email: "bob@example.com", IsVerified: false, TokenVersion: 0, @@ -126,7 +126,7 @@ func TestGetCurrentUserHttp(t *testing.T) { t.Run("No jwt cookie at all is refused", func(t *testing.T) { token := authtoken.New([]byte("test-secret")) userRepo := newMockUserRepo() - userRepo.users[7] = user.User{ID: 7, Username: "alice", Role: user.RoleAdmin, IsVerified: true, TokenVersion: 0} + userRepo.users[7] = user.User{ID: 7, Username: "alice", RoleID: adminRoleID, IsVerified: true, TokenVersion: 0} router := newGetCurrentUserRouter(userRepo, &stubHasher{}, token) rec := sendGetCurrentUserRequest(router, http.MethodGet, "/v1/auth/me", nil) @@ -139,7 +139,7 @@ func TestGetCurrentUserHttp(t *testing.T) { t.Run("A jwt whose token_version no longer matches the stored user is refused", func(t *testing.T) { token := authtoken.New([]byte("test-secret")) userRepo := newMockUserRepo() - userRepo.users[7] = user.User{ID: 7, Username: "alice", Role: user.RoleAdmin, IsVerified: true, TokenVersion: 1} + userRepo.users[7] = user.User{ID: 7, Username: "alice", RoleID: adminRoleID, IsVerified: true, TokenVersion: 1} router := newGetCurrentUserRouter(userRepo, &stubHasher{}, token) cookie := mintGetCurrentUserCookie(t, token, 7, 0) @@ -156,7 +156,7 @@ func TestGetCurrentUserHttp(t *testing.T) { t.Run("A jwt minted for a since-deleted account is refused", func(t *testing.T) { token := authtoken.New([]byte("test-secret")) userRepo := newMockUserRepo() - userRepo.users[7] = user.User{ID: 7, Username: "alice", Role: user.RoleAdmin, IsVerified: false, TokenVersion: 0} + userRepo.users[7] = user.User{ID: 7, Username: "alice", RoleID: adminRoleID, IsVerified: false, TokenVersion: 0} router := newGetCurrentUserRouter(userRepo, &stubHasher{}, token) cookie := mintGetCurrentUserCookie(t, token, 7, 0) @@ -177,7 +177,7 @@ func TestGetCurrentUserHttp(t *testing.T) { t.Run("The route is never rate-limited, unlike login and password reset", func(t *testing.T) { token := authtoken.New([]byte("test-secret")) userRepo := newMockUserRepo() - userRepo.users[7] = user.User{ID: 7, Username: "alice", Role: user.RoleAdmin, IsVerified: true, TokenVersion: 0} + userRepo.users[7] = user.User{ID: 7, Username: "alice", RoleID: adminRoleID, IsVerified: true, TokenVersion: 0} router := newGetCurrentUserRouter(userRepo, &stubHasher{}, token) cookie := mintGetCurrentUserCookie(t, token, 7, 0) diff --git a/test/hasher_test.go b/test/hasher_test.go index 85dd9aa..f5eafbd 100644 --- a/test/hasher_test.go +++ b/test/hasher_test.go @@ -34,7 +34,7 @@ func (s *spyHasher) Compare(hashed string, value string) error { func TestCreateUserUseCaseHashesPasswordAndVerificationCode(t *testing.T) { t.Run("stores hashed password and hashed verification code, returns raw code", func(t *testing.T) { repo := newMockUserRepo() - uc := useCases.NewCreateUserUseCase(repo, &stubHasher{}, newMockMailRepo(), "https://cms.example.com", discardRecorder) + uc := useCases.NewCreateUserUseCase(repo, newSeededMockRoleRepo(), &stubHasher{}, newMockMailRepo(), "https://cms.example.com", discardRecorder) result, err := uc.CreateUser(testCtx, useCases.CreateUserCommand{ Username: "alice", @@ -59,7 +59,7 @@ func TestCreateUserUseCaseHashesPasswordAndVerificationCode(t *testing.T) { t.Run("a hashing failure persists nothing", func(t *testing.T) { repo := newMockUserRepo() - uc := useCases.NewCreateUserUseCase(repo, &stubHasher{hashErr: errStubHashFailure}, newMockMailRepo(), "https://cms.example.com", discardRecorder) + uc := useCases.NewCreateUserUseCase(repo, newSeededMockRoleRepo(), &stubHasher{hashErr: errStubHashFailure}, newMockMailRepo(), "https://cms.example.com", discardRecorder) _, err := uc.CreateUser(testCtx, useCases.CreateUserCommand{ Username: "alice", @@ -177,7 +177,7 @@ func newHasherRouter(userRepo *mockUserRepo, hasher *stubHasher) *chi.Mux { token := authtoken.New([]byte("test-secret")) loginHandler := auth.NewLoginHandler( token, - useCases.NewCreateUserUseCase(userRepo, hasher, newMockMailRepo(), "https://cms.example.com", discardRecorder), + useCases.NewCreateUserUseCase(userRepo, newSeededMockRoleRepo(), hasher, newMockMailRepo(), "https://cms.example.com", discardRecorder), useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder), true, ) @@ -195,7 +195,7 @@ func newRealHasherRouter(userRepo *mockUserRepo) *chi.Mux { hasher := hashing.NewBcryptHasher() loginHandler := auth.NewLoginHandler( token, - useCases.NewCreateUserUseCase(userRepo, hasher, newMockMailRepo(), "https://cms.example.com", discardRecorder), + useCases.NewCreateUserUseCase(userRepo, newSeededMockRoleRepo(), hasher, newMockMailRepo(), "https://cms.example.com", discardRecorder), useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder), true, ) diff --git a/test/image_variants_env_test.go b/test/image_variants_env_test.go index cdc9c85..41ffe45 100644 --- a/test/image_variants_env_test.go +++ b/test/image_variants_env_test.go @@ -88,11 +88,11 @@ func newVariantEnvLimits(t *testing.T, renderer *countingRenderer, limits useCas usage: newMockVariantUsageRepo(), } env.tracker = usage.NewTracker(env.usage, func() time.Time { return env.now }) - env.users.users[variantOwnerId] = user.User{ID: variantOwnerId, Username: "marta", DisplayName: "Marta Alves", IsVerified: true, Role: user.RoleOwner} - env.users.users[variantAdminId] = user.User{ID: variantAdminId, Username: "dev", IsVerified: true, Role: user.RoleAdmin} + env.users.users[variantOwnerId] = user.User{ID: variantOwnerId, Username: "marta", DisplayName: "Marta Alves", IsVerified: true, RoleID: ownerRoleID} + env.users.users[variantAdminId] = user.User{ID: variantAdminId, Username: "dev", IsVerified: true, RoleID: adminRoleID} env.users.users[variantPlainUserId] = user.User{ID: variantPlainUserId, Username: "pat", IsVerified: true} - env.users.users[variantUnverifiedId] = user.User{ID: variantUnverifiedId, Username: "quinn", IsVerified: false, Role: user.RoleAdmin} - env.users.users[variantStaleId] = user.User{ID: variantStaleId, Username: "stale", IsVerified: true, Role: user.RoleAdmin, TokenVersion: 1} + env.users.users[variantUnverifiedId] = user.User{ID: variantUnverifiedId, Username: "quinn", IsVerified: false, RoleID: adminRoleID} + env.users.users[variantStaleId] = user.User{ID: variantStaleId, Username: "stale", IsVerified: true, RoleID: adminRoleID, TokenVersion: 1} container := env.container(limits) admin := chi.NewRouter() diff --git a/test/invitations_test.go b/test/invitations_test.go index 3c77f15..9b3f26a 100644 --- a/test/invitations_test.go +++ b/test/invitations_test.go @@ -111,7 +111,7 @@ func (m *inviteFakeRepo) AcceptWithUser(id uint32, at time.Time, newUser user.Us var _ invite.Repository = &inviteFakeRepo{} func inviteAt(email string, createdAt time.Time, expiresAt time.Time) invite.Invite { - return invite.Invite{Email: email, HashedCode: "hashed:seed", InvitedBy: 7, CreatedAt: createdAt, ExpiresAt: expiresAt} + return invite.Invite{Email: email, HashedCode: "hashed:seed", InvitedBy: 7, RoleID: writerRoleID, CreatedAt: createdAt, ExpiresAt: expiresAt} } func pendingInvite(email string) invite.Invite { @@ -126,7 +126,7 @@ func expiredInvite(email string) invite.Invite { func inviteInviterRepo(displayName string) *mockUserRepo { repo := newMockUserRepo() - repo.users[7] = user.User{ID: 7, Username: "marta", DisplayName: displayName, Email: "marta@renew.example", IsVerified: true, Role: user.RoleOwner} + repo.users[7] = user.User{ID: 7, Username: "marta", DisplayName: displayName, Email: "marta@renew.example", IsVerified: true, RoleID: ownerRoleID} repo.nextID = 7 return repo } @@ -152,10 +152,10 @@ func newInviteFixture(t *testing.T, maxAttempts int) *inviteFixture { } getUser := useCases.NewGetUserUseCase(f.users) handler := userhandler.NewInviteHandler( - useCases.NewCreateInviteUseCase(f.users, f.invites, f.hasher, f.mail, invitePublicURL, discardRecorder), + useCases.NewCreateInviteUseCase(f.users, f.invites, newSeededMockRoleRepo(), newMockContentTypeRepo(), f.hasher, f.mail, invitePublicURL, discardRecorder), nil, nil, - useCases.NewListUsersAndInvitesUseCase(f.users, f.invites), + useCases.NewListUsersAndInvitesUseCase(f.users, f.invites, newSeededMockRoleRepo()), ) f.router = chi.NewRouter() f.router.Route("/v1/user", func(r chi.Router) { @@ -165,6 +165,7 @@ func newInviteFixture(t *testing.T, maxAttempts int) *inviteFixture { r.Use(middleware.RequireVerifiedUser(getUser)) r.Get("/", handler.ListUsers) r.Group(func(r chi.Router) { + r.Use(middleware.RequirePermission(getUser, useCases.NewCheckPermissionUseCase(newSeededMockRoleRepo()), "users.invite")) r.Use(middleware.TrustedProxyRealIP(nil)) r.Use(middleware.NewRateLimiter(maxAttempts, time.Hour)) r.Post("/invite", handler.CreateInvite) @@ -238,7 +239,7 @@ func TestInvitationsHttp(t *testing.T) { t.Fatalf("expected 201, got %d: %s", rec.Code, rec.Body.String()) } decoded := decodeKeys(t, rec.Body.Bytes()) - assertExactKeys(t, decoded, "id", "kind", "email", "status", "created_at", "expires_at") + assertExactKeys(t, decoded, "id", "kind", "email", "role", "status", "created_at", "expires_at") if decodedString(t, decoded, "kind") != "invite" || decodedString(t, decoded, "email") != "sam.oliveira@renew.example" || decodedString(t, decoded, "status") != "pending" { t.Errorf("unexpected body: %s", rec.Body.String()) } @@ -271,7 +272,7 @@ func TestInvitationsHttp(t *testing.T) { t.Run("The email names the inviter by display name when there is one", func(t *testing.T) { f := newInviteFixture(t, 100) - f.users.users[7] = user.User{ID: 7, Username: "ines", DisplayName: "Ines Duarte", IsVerified: true} + f.users.users[7] = user.User{ID: 7, Username: "ines", DisplayName: "Ines Duarte", IsVerified: true, RoleID: ownerRoleID} rec := f.post(t, `{"email": "lea.martin@renew.example"}`) @@ -493,7 +494,7 @@ func TestInvitationsHttp(t *testing.T) { t.Errorf("item %d unexpected: %v", index, item) } if want.kind == "invite" { - assertExactKeys(t, item, "id", "kind", "email", "status", "created_at") + assertExactKeys(t, item, "id", "kind", "email", "role", "status", "created_at") } else { assertExactKeys(t, item, "id", "kind", "username", "display_name", "email", "role", "status", "created_at") } @@ -536,7 +537,7 @@ func TestInvitationsDoNotChangeRegistration(t *testing.T) { hasher := &stubHasher{} handler := auth.NewLoginHandler( authtoken.New([]byte("test-secret")), - useCases.NewCreateUserUseCase(users, hasher, mail, invitePublicURL, discardRecorder), + useCases.NewCreateUserUseCase(users, newSeededMockRoleRepo(), hasher, mail, invitePublicURL, discardRecorder), useCases.NewAuthenticateUserUseCase(users, hasher, discardRecorder), true, ) @@ -601,12 +602,12 @@ func TestInvitationsDoNotChangeRegistration(t *testing.T) { func TestCreateInviteUseCase(t *testing.T) { newUseCase := func() (*useCases.CreateInviteUseCase, *mockUserRepo, *inviteFakeRepo, *mockMailRepo, *stubHasher) { users, invites, mail, hasher := inviteInviterRepo("Marta A"), newInviteFakeRepo(), newMockMailRepo(), &stubHasher{} - return useCases.NewCreateInviteUseCase(users, invites, hasher, mail, invitePublicURL, discardRecorder), users, invites, mail, hasher + return useCases.NewCreateInviteUseCase(users, invites, newSeededMockRoleRepo(), newMockContentTypeRepo(), hasher, mail, invitePublicURL, discardRecorder), users, invites, mail, hasher } t.Run("lower-cases the email and returns the stored invite", func(t *testing.T) { uc, _, _, _, _ := newUseCase() - got, err := uc.CreateInvite(testCtx, useCases.CreateInviteCommand{Email: "A@B.Example", InvitedBy: 7}) + got, err := uc.CreateInvite(testCtx, useCases.CreateInviteCommand{Caller: ownerCaller(7), Email: "A@B.Example", InvitedBy: 7}) if err != nil || got.Email != "a@b.example" || got.ID == 0 { t.Fatalf("unexpected result %+v, %v", got, err) } @@ -614,7 +615,7 @@ func TestCreateInviteUseCase(t *testing.T) { t.Run("unknown inviter", func(t *testing.T) { uc, _, invites, _, _ := newUseCase() - _, err := uc.CreateInvite(testCtx, useCases.CreateInviteCommand{Email: "a@b.example", InvitedBy: 99}) + _, err := uc.CreateInvite(testCtx, useCases.CreateInviteCommand{Caller: ownerCaller(7), Email: "a@b.example", InvitedBy: 99}) if !errors.Is(err, user.ErrUserNotFound) || len(invites.invites) != 0 { t.Errorf("expected ErrUserNotFound, got %v", err) } @@ -623,7 +624,7 @@ func TestCreateInviteUseCase(t *testing.T) { t.Run("existing account is refused case-insensitively", func(t *testing.T) { uc, users, _, _, _ := newUseCase() users.users[8] = user.User{ID: 8, Email: "Jonas@Renew.example"} - _, err := uc.CreateInvite(testCtx, useCases.CreateInviteCommand{Email: "jonas@renew.example", InvitedBy: 7}) + _, err := uc.CreateInvite(testCtx, useCases.CreateInviteCommand{Caller: ownerCaller(7), Email: "jonas@renew.example", InvitedBy: 7}) if !errors.Is(err, invite.ErrEmailHasAccount) { t.Errorf("expected ErrEmailHasAccount, got %v", err) } @@ -632,7 +633,7 @@ func TestCreateInviteUseCase(t *testing.T) { t.Run("pending invite is refused", func(t *testing.T) { uc, _, invites, _, _ := newUseCase() invites.seed(pendingInvite("a@b.example")) - _, err := uc.CreateInvite(testCtx, useCases.CreateInviteCommand{Email: "a@b.example", InvitedBy: 7}) + _, err := uc.CreateInvite(testCtx, useCases.CreateInviteCommand{Caller: ownerCaller(7), Email: "a@b.example", InvitedBy: 7}) if !errors.Is(err, invite.ErrPendingInvite) { t.Errorf("expected ErrPendingInvite, got %v", err) } @@ -645,7 +646,7 @@ func TestCreateInviteUseCase(t *testing.T) { accepted.AcceptedAt = &acceptedAt invites.seed(accepted) invites.seed(pendingInvite("other@b.example")) - if _, err := uc.CreateInvite(testCtx, useCases.CreateInviteCommand{Email: "a@b.example", InvitedBy: 7}); err != nil { + if _, err := uc.CreateInvite(testCtx, useCases.CreateInviteCommand{Caller: ownerCaller(7), Email: "a@b.example", InvitedBy: 7}); err != nil { t.Errorf("unexpected error %v", err) } if len(invites.invites) != 3 { @@ -656,7 +657,7 @@ func TestCreateInviteUseCase(t *testing.T) { t.Run("hash failure stores and sends nothing", func(t *testing.T) { uc, _, invites, mail, hasher := newUseCase() hasher.hashErr = errors.New("hash boom") - _, err := uc.CreateInvite(testCtx, useCases.CreateInviteCommand{Email: "a@b.example", InvitedBy: 7}) + _, err := uc.CreateInvite(testCtx, useCases.CreateInviteCommand{Caller: ownerCaller(7), Email: "a@b.example", InvitedBy: 7}) if err == nil || len(invites.invites) != 0 || len(mail.SentEmails) != 0 { t.Errorf("expected failure without side effects, got %v", err) } @@ -666,7 +667,7 @@ func TestCreateInviteUseCase(t *testing.T) { uc, _, invites, mail, _ := newUseCase() invites.seed(expiredInvite("a@b.example")) invites.fail.failNext("Delete") - _, err := uc.CreateInvite(testCtx, useCases.CreateInviteCommand{Email: "a@b.example", InvitedBy: 7}) + _, err := uc.CreateInvite(testCtx, useCases.CreateInviteCommand{Caller: ownerCaller(7), Email: "a@b.example", InvitedBy: 7}) if !errors.Is(err, errRepositoryFailure) || len(mail.SentEmails) != 0 { t.Errorf("expected repository failure, got %v", err) } @@ -676,7 +677,7 @@ func TestCreateInviteUseCase(t *testing.T) { uc, _, invites, mail, _ := newUseCase() mail.fail.failNextWith("Send", errors.New("smtp down")) invites.fail.failNextWith("Delete", errors.New("cleanup down")) - _, err := uc.CreateInvite(testCtx, useCases.CreateInviteCommand{Email: "a@b.example", InvitedBy: 7}) + _, err := uc.CreateInvite(testCtx, useCases.CreateInviteCommand{Caller: ownerCaller(7), Email: "a@b.example", InvitedBy: 7}) if !errors.Is(err, invite.ErrInviteEmailNotSent) || !strings.Contains(err.Error(), "smtp down") || !strings.Contains(err.Error(), "cleanup down") { t.Errorf("unexpected error %v", err) } @@ -742,7 +743,7 @@ func TestInvitationsHandlerUsesPublicUserSafeShape(t *testing.T) { func TestListUsersAndInvitesUseCase(t *testing.T) { t.Run("empty repositories give an empty non-nil list", func(t *testing.T) { - got := useCases.NewListUsersAndInvitesUseCase(newMockUserRepo(), newInviteFakeRepo()).ListUsersAndInvites() + got, _ := useCases.NewListUsersAndInvitesUseCase(newMockUserRepo(), newInviteFakeRepo(), newSeededMockRoleRepo()).ListUsersAndInvites() if got == nil || len(got) != 0 { t.Errorf("expected empty non-nil slice, got %#v", got) } diff --git a/test/invite_management_test.go b/test/invite_management_test.go index fb47a24..62adeeb 100644 --- a/test/invite_management_test.go +++ b/test/invite_management_test.go @@ -48,21 +48,21 @@ func newInviteManagementFixture(t *testing.T, maxAttempts int) *inviteManagement token: authtoken.New([]byte("test-secret")), } f.invites.users = f.users - f.users.users[9] = user.User{ID: 9, Username: "jonas", Email: "jonas@renew.example", IsVerified: true, Role: user.RoleAdmin, RoleID: writerRoleID} - f.users.users[11] = user.User{ID: 11, Username: "unverified", Email: "unverified@renew.example", IsVerified: false, Role: user.RoleAdmin} - f.users.users[12] = user.User{ID: 12, Username: "stale", Email: "stale@renew.example", IsVerified: true, Role: user.RoleOwner, TokenVersion: 2} + f.users.users[9] = user.User{ID: 9, Username: "jonas", Email: "jonas@renew.example", IsVerified: true, RoleID: writerRoleID} + f.users.users[11] = user.User{ID: 11, Username: "unverified", Email: "unverified@renew.example", IsVerified: false, RoleID: adminRoleID} + f.users.users[12] = user.User{ID: 12, Username: "stale", Email: "stale@renew.example", IsVerified: true, RoleID: ownerRoleID, TokenVersion: 2} f.users.nextID = 12 getUser := useCases.NewGetUserUseCase(f.users) handler := userhandler.NewInviteHandler( - useCases.NewCreateInviteUseCase(f.users, f.invites, f.hasher, f.mail, invitePublicURL, discardRecorder), + useCases.NewCreateInviteUseCase(f.users, f.invites, newSeededMockRoleRepo(), newMockContentTypeRepo(), f.hasher, f.mail, invitePublicURL, discardRecorder), useCases.NewCancelInviteUseCase(f.invites, discardRecorder), - useCases.NewRenewInviteUseCase(f.users, f.invites, f.hasher, f.mail, invitePublicURL, discardRecorder), - useCases.NewListUsersAndInvitesUseCase(f.users, f.invites), + useCases.NewRenewInviteUseCase(f.users, f.invites, newSeededMockRoleRepo(), newMockContentTypeRepo(), f.hasher, f.mail, invitePublicURL, discardRecorder), + useCases.NewListUsersAndInvitesUseCase(f.users, f.invites, newSeededMockRoleRepo()), ) acceptHandler := auth.NewAcceptInviteHandler( useCases.NewGetInviteUseCase(f.invites, f.hasher), - useCases.NewAcceptInviteUseCase(f.users, f.invites, f.hasher, discardRecorder), + useCases.NewAcceptInviteUseCase(f.users, f.invites, newSeededMockRoleRepo(), f.hasher, discardRecorder), f.token, true, ) @@ -145,7 +145,7 @@ func TestInviteManagementHttp(t *testing.T) { t.Run("A cancelled invite's link stops working and its address can be invited again", func(t *testing.T) { f := newInviteManagementFixture(t, 100) - f.invites.seed(invite.Invite{Email: "sam.oliveira@renew.example", HashedCode: "hashed:OLD-CODE", InvitedBy: 7, CreatedAt: time.Now(), ExpiresAt: time.Now().Add(time.Hour)}) + f.invites.seed(invite.Invite{Email: "sam.oliveira@renew.example", HashedCode: "hashed:OLD-CODE", InvitedBy: 7, RoleID: writerRoleID, CreatedAt: time.Now(), ExpiresAt: time.Now().Add(time.Hour)}) f.asOwner(t, http.MethodDelete, "/v1/user/invite/1") rec := f.send(t, http.MethodGet, "/v1/auth/invite?c=OLD-CODE", nil) @@ -223,7 +223,7 @@ func TestInviteManagementHttp(t *testing.T) { for _, filler := range []string{"a@renew.example", "b@renew.example", "c@renew.example"} { f.invites.seed(pendingInvite(filler)) } - f.invites.seed(invite.Invite{Email: "lea.martin@renew.example", HashedCode: "hashed:OLD-CODE", InvitedBy: 9, CreatedAt: now.Add(-5 * 24 * time.Hour), ExpiresAt: now.Add(-2 * 24 * time.Hour)}) + f.invites.seed(invite.Invite{Email: "lea.martin@renew.example", HashedCode: "hashed:OLD-CODE", InvitedBy: 9, RoleID: writerRoleID, CreatedAt: now.Add(-5 * 24 * time.Hour), ExpiresAt: now.Add(-2 * 24 * time.Hour)}) rec := f.asOwner(t, http.MethodPost, "/v1/user/invite/4/renew") @@ -231,7 +231,7 @@ func TestInviteManagementHttp(t *testing.T) { t.Fatalf("unexpected %d %q", rec.Code, rec.Header().Get("Cache-Control")) } decoded := decodeKeys(t, rec.Body.Bytes()) - assertExactKeys(t, decoded, "id", "kind", "email", "status", "created_at", "expires_at") + assertExactKeys(t, decoded, "id", "kind", "email", "role", "status", "created_at", "expires_at") if decodedString(t, decoded, "kind") != "invite" || decodedString(t, decoded, "email") != "lea.martin@renew.example" || decodedString(t, decoded, "status") != "pending" { t.Errorf("unexpected body %s", rec.Body.String()) } @@ -268,8 +268,8 @@ func TestInviteManagementHttp(t *testing.T) { t.Run("A renewed pending invite moves to the top of the list and the old link dies", func(t *testing.T) { f := newInviteManagementFixture(t, 100) - f.invites.seed(invite.Invite{Email: "lea.martin@renew.example", HashedCode: "hashed:OLD-CODE", InvitedBy: 7, CreatedAt: time.Now().Add(-48 * time.Hour), ExpiresAt: time.Now().Add(24 * time.Hour)}) - f.invites.seed(invite.Invite{Email: "newer@renew.example", HashedCode: "hashed:OTHER", InvitedBy: 7, CreatedAt: time.Now().Add(-24 * time.Hour), ExpiresAt: time.Now().Add(48 * time.Hour)}) + f.invites.seed(invite.Invite{Email: "lea.martin@renew.example", HashedCode: "hashed:OLD-CODE", InvitedBy: 7, RoleID: writerRoleID, CreatedAt: time.Now().Add(-48 * time.Hour), ExpiresAt: time.Now().Add(24 * time.Hour)}) + f.invites.seed(invite.Invite{Email: "newer@renew.example", HashedCode: "hashed:OTHER", InvitedBy: 7, RoleID: writerRoleID, CreatedAt: time.Now().Add(-24 * time.Hour), ExpiresAt: time.Now().Add(48 * time.Hour)}) f.asOwner(t, http.MethodPost, "/v1/user/invite/1/renew") @@ -288,8 +288,8 @@ func TestInviteManagementHttp(t *testing.T) { t.Run("Accepting with a cancelled or replaced code is not found and creates no user", func(t *testing.T) { f := newInviteManagementFixture(t, 100) - f.invites.seed(invite.Invite{Email: "lea.martin@renew.example", HashedCode: "hashed:OLD-CODE", InvitedBy: 7, CreatedAt: time.Now().Add(-time.Hour), ExpiresAt: time.Now().Add(24 * time.Hour)}) - f.invites.seed(invite.Invite{Email: "sam@renew.example", HashedCode: "hashed:CANCELLED", InvitedBy: 7, CreatedAt: time.Now().Add(-time.Hour), ExpiresAt: time.Now().Add(24 * time.Hour)}) + f.invites.seed(invite.Invite{Email: "lea.martin@renew.example", HashedCode: "hashed:OLD-CODE", InvitedBy: 7, RoleID: writerRoleID, CreatedAt: time.Now().Add(-time.Hour), ExpiresAt: time.Now().Add(24 * time.Hour)}) + f.invites.seed(invite.Invite{Email: "sam@renew.example", HashedCode: "hashed:CANCELLED", InvitedBy: 7, RoleID: writerRoleID, CreatedAt: time.Now().Add(-time.Hour), ExpiresAt: time.Now().Add(24 * time.Hour)}) f.asOwner(t, http.MethodPost, "/v1/user/invite/1/renew") f.asOwner(t, http.MethodDelete, "/v1/user/invite/2") usersBefore := len(f.users.users) @@ -363,7 +363,7 @@ func TestInviteManagementHttp(t *testing.T) { t.Run("The old code of a pending invite works until it is renewed", func(t *testing.T) { f := newInviteManagementFixture(t, 100) - f.invites.seed(invite.Invite{Email: "lea.martin@renew.example", HashedCode: "hashed:OLD-CODE", InvitedBy: 7, CreatedAt: time.Now().Add(-time.Hour), ExpiresAt: time.Now().Add(24 * time.Hour)}) + f.invites.seed(invite.Invite{Email: "lea.martin@renew.example", HashedCode: "hashed:OLD-CODE", InvitedBy: 7, RoleID: writerRoleID, CreatedAt: time.Now().Add(-time.Hour), ExpiresAt: time.Now().Add(24 * time.Hour)}) if before := f.send(t, http.MethodGet, "/v1/auth/invite?c=OLD-CODE", nil); before.Code != http.StatusOK { t.Fatalf("control expected 200, got %d", before.Code) @@ -480,9 +480,9 @@ func TestManageInviteUseCases(t *testing.T) { mail := newMockMailRepo() seeded := invites.seed(pendingInvite("a@renew.example")) mail.fail.failNextWith("Send", errors.New("smtp down")) - renew := useCases.NewRenewInviteUseCase(users, invites, &stubHasher{}, mail, invitePublicURL, discardRecorder) + renew := useCases.NewRenewInviteUseCase(users, invites, newSeededMockRoleRepo(), newMockContentTypeRepo(), &stubHasher{}, mail, invitePublicURL, discardRecorder) - _, err := renew.RenewInvite(testCtx, useCases.RenewInviteCommand{ID: 1, RenewedBy: 7}) + _, err := renew.RenewInvite(testCtx, useCases.RenewInviteCommand{Caller: ownerCaller(7), ID: 1, RenewedBy: 7}) if !errors.Is(err, invite.ErrInviteEmailNotSent) || errors.Is(err, errRepositoryFailure) || invites.invites[1] != seeded { t.Errorf("unexpected result %v", err) @@ -495,9 +495,9 @@ func TestManageInviteUseCases(t *testing.T) { mail := newMockMailRepo() invites.seed(pendingInvite("a@renew.example")) mail.fail.failNextWith("Send", errors.New("smtp down")) - renew := useCases.NewRenewInviteUseCase(users, &renewRestoreFailRepo{inviteFakeRepo: invites}, &stubHasher{}, mail, invitePublicURL, discardRecorder) + renew := useCases.NewRenewInviteUseCase(users, &renewRestoreFailRepo{inviteFakeRepo: invites}, newSeededMockRoleRepo(), newMockContentTypeRepo(), &stubHasher{}, mail, invitePublicURL, discardRecorder) - _, err := renew.RenewInvite(testCtx, useCases.RenewInviteCommand{ID: 1, RenewedBy: 7}) + _, err := renew.RenewInvite(testCtx, useCases.RenewInviteCommand{Caller: ownerCaller(7), ID: 1, RenewedBy: 7}) if !errors.Is(err, invite.ErrInviteEmailNotSent) || !errors.Is(err, errRepositoryFailure) { t.Errorf("unexpected result %v", err) @@ -530,9 +530,9 @@ func TestManageInviteUseCases(t *testing.T) { mail := newMockMailRepo() seeded := invites.seed(pendingInvite("a@renew.example")) invites.fail.failNextWith("Renew", errRepositoryFailure) - renew := useCases.NewRenewInviteUseCase(users, invites, &stubHasher{}, mail, invitePublicURL, discardRecorder) + renew := useCases.NewRenewInviteUseCase(users, invites, newSeededMockRoleRepo(), newMockContentTypeRepo(), &stubHasher{}, mail, invitePublicURL, discardRecorder) - _, err := renew.RenewInvite(testCtx, useCases.RenewInviteCommand{ID: 1, RenewedBy: 7}) + _, err := renew.RenewInvite(testCtx, useCases.RenewInviteCommand{Caller: ownerCaller(7), ID: 1, RenewedBy: 7}) if !errors.Is(err, errRepositoryFailure) || len(mail.SentEmails) != 0 || invites.invites[1] != seeded { t.Errorf("unexpected result %v", err) @@ -544,9 +544,9 @@ func TestManageInviteUseCases(t *testing.T) { invites := newInviteFakeRepo() mail := newMockMailRepo() invites.seed(pendingInvite("a@renew.example")) - renew := useCases.NewRenewInviteUseCase(users, invites, &stubHasher{}, mail, invitePublicURL, discardRecorder) + renew := useCases.NewRenewInviteUseCase(users, invites, newSeededMockRoleRepo(), newMockContentTypeRepo(), &stubHasher{}, mail, invitePublicURL, discardRecorder) - if _, err := renew.RenewInvite(testCtx, useCases.RenewInviteCommand{ID: 1, RenewedBy: 7}); err != nil { + if _, err := renew.RenewInvite(testCtx, useCases.RenewInviteCommand{Caller: ownerCaller(7), ID: 1, RenewedBy: 7}); err != nil { t.Fatalf("unexpected %v", err) } if inviteMailData(t, mail).InvitedBy != "Marta L." { @@ -565,7 +565,7 @@ func TestInviteRepositoryRenew(t *testing.T) { } repo := persistence.NewInviteRepository(db) now := time.Now().UTC().Truncate(time.Second) - created, err := repo.Create(invite.Invite{Email: "a@renew.example", HashedCode: "old", InvitedBy: 7, CreatedAt: now.Add(-96 * time.Hour), ExpiresAt: now.Add(-24 * time.Hour)}) + created, err := repo.Create(invite.Invite{Email: "a@renew.example", HashedCode: "old", InvitedBy: 7, RoleID: writerRoleID, CreatedAt: now.Add(-96 * time.Hour), ExpiresAt: now.Add(-24 * time.Hour)}) if err != nil { t.Fatalf("create failed: %v", err) } diff --git a/test/login_and_password_reset_rate_limiting_http_test.go b/test/login_and_password_reset_rate_limiting_http_test.go index 3f468f7..ff78e95 100644 --- a/test/login_and_password_reset_rate_limiting_http_test.go +++ b/test/login_and_password_reset_rate_limiting_http_test.go @@ -36,7 +36,7 @@ func newRateLimitedAuthRouter(t *testing.T, userRepo user.Repository, mailRepo * token := authtoken.New([]byte("test-secret")) loginHandler := auth.NewLoginHandler( token, - useCases.NewCreateUserUseCase(userRepo, hasher, mailRepo, "https://cms.example.com", discardRecorder), + useCases.NewCreateUserUseCase(userRepo, newSeededMockRoleRepo(), hasher, mailRepo, "https://cms.example.com", discardRecorder), useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder), true, ) diff --git a/test/login_logout_cookie_test.go b/test/login_logout_cookie_test.go index 06d66ee..af46379 100644 --- a/test/login_logout_cookie_test.go +++ b/test/login_logout_cookie_test.go @@ -19,7 +19,7 @@ func newLoginLogoutCookieRouter(userRepo *mockUserRepo, mailRepo *mockMailRepo, hasher := &stubHasher{} loginHandler := auth.NewLoginHandler( token, - useCases.NewCreateUserUseCase(userRepo, hasher, mailRepo, "https://cms.example.com", discardRecorder), + useCases.NewCreateUserUseCase(userRepo, newSeededMockRoleRepo(), hasher, mailRepo, "https://cms.example.com", discardRecorder), useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder), true, ) diff --git a/test/media_get_http_test.go b/test/media_get_http_test.go index 186c62d..36bf5b8 100644 --- a/test/media_get_http_test.go +++ b/test/media_get_http_test.go @@ -15,7 +15,7 @@ const mediaGetEditorID = 9 func newMediaGetEnv(t *testing.T) *siteEnv { t.Helper() e := newSiteEnvWithMain(t) - e.users.users[mediaGetEditorID] = user.User{ID: mediaGetEditorID, Username: "editor", Email: "editor@example.com", IsVerified: true, Role: user.Role("editor"), RoleID: writerRoleID} + e.users.users[mediaGetEditorID] = user.User{ID: mediaGetEditorID, Username: "editor", Email: "editor@example.com", IsVerified: true, RoleID: writerRoleID} e.seedImageRecord(5) return e } diff --git a/test/media_merge_hardening_test.go b/test/media_merge_hardening_test.go index 6af6286..1fd7094 100644 --- a/test/media_merge_hardening_test.go +++ b/test/media_merge_hardening_test.go @@ -92,7 +92,7 @@ func newHardeningEnv(t *testing.T) *hardeningEnv { users: newMockUserRepo(), token: authtoken.New([]byte("test-secret")), } - env.users.users[hardeningAdminID] = user.User{ID: hardeningAdminID, Username: "marta", IsVerified: true, Role: user.RoleAdmin} + env.users.users[hardeningAdminID] = user.User{ID: hardeningAdminID, Username: "marta", IsVerified: true, RoleID: adminRoleID} env.transactor = newMergeTransactor(env.images, env.articles, env.sites, env.authors, env.entries) env.storage = newMergeRecordingStorage(env.transactor) env.variants = newMergeRecordingVariantFiles(env.transactor) diff --git a/test/media_purge_http_test.go b/test/media_purge_http_test.go index 0396d70..d4094da 100644 --- a/test/media_purge_http_test.go +++ b/test/media_purge_http_test.go @@ -96,11 +96,11 @@ func purgeTime(value string) *time.Time { func newPurgeEnv(t *testing.T) *mediaEnv { t.Helper() env := newMediaEnv(t) - env.users.users[purgeOwnerId] = user.User{ID: purgeOwnerId, Username: "owner", IsVerified: true, Role: user.RoleOwner} - env.users.users[purgeAdminId] = user.User{ID: purgeAdminId, Username: "admin", IsVerified: true, Role: user.RoleAdmin} - env.users.users[purgeEditorId] = user.User{ID: purgeEditorId, Username: "editor", IsVerified: true, Role: user.Role("editor")} + env.users.users[purgeOwnerId] = user.User{ID: purgeOwnerId, Username: "owner", IsVerified: true, RoleID: ownerRoleID} + env.users.users[purgeAdminId] = user.User{ID: purgeAdminId, Username: "admin", IsVerified: true, RoleID: adminRoleID} + env.users.users[purgeEditorId] = user.User{ID: purgeEditorId, Username: "editor", IsVerified: true} env.users.users[purgeNoRoleId] = user.User{ID: purgeNoRoleId, Username: "norole", IsVerified: true} - env.users.users[purgeUnverifiedId] = user.User{ID: purgeUnverifiedId, Username: "unverified", IsVerified: false, Role: user.RoleOwner} + env.users.users[purgeUnverifiedId] = user.User{ID: purgeUnverifiedId, Username: "unverified", IsVerified: false, RoleID: ownerRoleID} return env } diff --git a/test/media_purge_qa_test.go b/test/media_purge_qa_test.go index fdbe557..fa15fb5 100644 --- a/test/media_purge_qa_test.go +++ b/test/media_purge_qa_test.go @@ -15,16 +15,19 @@ import ( ) func TestPurgeRoleGuardIsAnAllowlist(t *testing.T) { - for _, role := range []string{"OWNER", "Admin", "superuser"} { + for _, unusable := range []struct { + name string + roleID uint32 + }{{"no role", 0}, {"missing role", 99}} { for _, route := range []struct{ method, path string }{ {http.MethodGet, "/v1/media/purge-settings"}, {http.MethodPatch, "/v1/media/purge-settings"}, {http.MethodGet, "/v1/media/purge-preview"}, {http.MethodPost, "/v1/media/purge"}, } { - t.Run(role+" "+route.method+" "+route.path, func(t *testing.T) { + t.Run(unusable.name+" "+route.method+" "+route.path, func(t *testing.T) { env := newPurgeEnv(t) - env.users.users[20] = user.User{ID: 20, Username: "other", IsVerified: true, Role: user.Role(role)} + env.users.users[20] = user.User{ID: 20, Username: "other", IsVerified: true, RoleID: unusable.roleID} rec := env.send(t, route.method, route.path, "", 20) diff --git a/test/mysql_server_test.go b/test/mysql_server_test.go index be5b489..18857dd 100644 --- a/test/mysql_server_test.go +++ b/test/mysql_server_test.go @@ -72,8 +72,9 @@ func TestMySQLMigration(t *testing.T) { } }) - t.Run("the first user lacking a role becomes owner and the others admin", func(t *testing.T) { + t.Run("a legacy users.role column is backfilled into role_id and the invites get Admin", func(t *testing.T) { _, _, db := migratedMySQL(t) + addLegacyRoleColumn(t, db) for _, name := range []string{"first", "second", "third"} { insert := db.Exec("INSERT INTO users (username, password, email, role, is_verified, token_version) VALUES (?, 'x', ?, '', 1, 0)", name, name+"@x.example") if insert.Error != nil { @@ -85,15 +86,18 @@ func TestMySQLMigration(t *testing.T) { t.Fatalf("migrate: %v", err) } - roles := map[string]string{} + roles := map[string]uint32{} var users []models.User db.Find(&users) - for _, user := range users { - roles[user.Username] = user.Role + for _, stored := range users { + roles[stored.Username] = roleIDOf(stored) } - if roles["first"] != "owner" || roles["second"] != "admin" || roles["third"] != "admin" { + if roles["first"] != storedRole(t, db, "Owner").ID || roles["second"] != storedRole(t, db, "Admin").ID || roles["third"] != storedRole(t, db, "Admin").ID { t.Errorf("unexpected roles %v", roles) } + if !db.Migrator().HasColumn("users", "role") { + t.Errorf("the legacy column must stay") + } }) } diff --git a/test/not_found_errors_test.go b/test/not_found_errors_test.go index cc0c8e5..1425d60 100644 --- a/test/not_found_errors_test.go +++ b/test/not_found_errors_test.go @@ -135,7 +135,7 @@ func newNotFoundErrorsRouter(articleRepo *mockArticleRepo, userRepo *mockUserRep hasher := hashing.NewBcryptHasher() loginHandler := auth.NewLoginHandler( token, - useCases.NewCreateUserUseCase(userRepo, hasher, newMockMailRepo(), "https://cms.example.com", discardRecorder), + useCases.NewCreateUserUseCase(userRepo, newSeededMockRoleRepo(), hasher, newMockMailRepo(), "https://cms.example.com", discardRecorder), useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder), true, ) diff --git a/test/postgres_server_test.go b/test/postgres_server_test.go index 4df7364..b194212 100644 --- a/test/postgres_server_test.go +++ b/test/postgres_server_test.go @@ -184,8 +184,9 @@ func TestPostgresMigration(t *testing.T) { } }) - t.Run("the first user lacking a role becomes owner and the others admin", func(t *testing.T) { + t.Run("a legacy users.role column is backfilled into role_id and the invites get Admin", func(t *testing.T) { _, _, db := migratedPostgres(t) + addLegacyRoleColumn(t, db) for _, name := range []string{"first", "second", "third"} { insert := db.Exec("INSERT INTO users (username, password, email, role, is_verified, token_version) VALUES (?, 'x', ?, '', true, 0)", name, name+"@x.example") if insert.Error != nil { @@ -197,15 +198,18 @@ func TestPostgresMigration(t *testing.T) { t.Fatalf("migrate: %v", err) } - roles := map[string]string{} + roles := map[string]uint32{} var users []models.User db.Find(&users) - for _, user := range users { - roles[user.Username] = user.Role + for _, stored := range users { + roles[stored.Username] = roleIDOf(stored) } - if roles["first"] != "owner" || roles["second"] != "admin" || roles["third"] != "admin" { + if roles["first"] != storedRole(t, db, "Owner").ID || roles["second"] != storedRole(t, db, "Admin").ID || roles["third"] != storedRole(t, db, "Admin").ID { t.Errorf("unexpected roles %v", roles) } + if !db.Migrator().HasColumn("users", "role") { + t.Errorf("the legacy column must stay") + } }) } diff --git a/test/registration_gate_and_verification_email_test.go b/test/registration_gate_and_verification_email_test.go index e20e21b..62469aa 100644 --- a/test/registration_gate_and_verification_email_test.go +++ b/test/registration_gate_and_verification_email_test.go @@ -42,7 +42,7 @@ func newRegistrationGateRouter(userRepo *mockUserRepo, mailRepo *mockMailRepo, p hasher := &stubHasher{} loginHandler := auth.NewLoginHandler( token, - useCases.NewCreateUserUseCase(userRepo, hasher, mailRepo, publicURL, discardRecorder), + useCases.NewCreateUserUseCase(userRepo, newSeededMockRoleRepo(), hasher, mailRepo, publicURL, discardRecorder), useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder), true, ) @@ -218,7 +218,7 @@ func TestRegistrationGateAndVerificationEmailUseCase(t *testing.T) { userRepo.nextID = 1 userRepo.fail.failNext("Create") mailRepo := newMockMailRepo() - uc := useCases.NewCreateUserUseCase(userRepo, &stubHasher{}, mailRepo, "https://cms.example.com", discardRecorder) + uc := useCases.NewCreateUserUseCase(userRepo, newSeededMockRoleRepo(), &stubHasher{}, mailRepo, "https://cms.example.com", discardRecorder) _, err := uc.CreateUser(testCtx, useCases.CreateUserCommand{ Username: "bob", @@ -240,7 +240,7 @@ func TestRegistrationGateAndVerificationEmailUseCase(t *testing.T) { userRepo.users[2] = user.User{ID: 2, Username: "bob", Email: "bob@example.com", IsVerified: false} userRepo.nextID = 2 mailRepo := newMockMailRepo() - uc := useCases.NewCreateUserUseCase(userRepo, &stubHasher{}, mailRepo, "https://cms.example.com", discardRecorder) + uc := useCases.NewCreateUserUseCase(userRepo, newSeededMockRoleRepo(), &stubHasher{}, mailRepo, "https://cms.example.com", discardRecorder) result, err := uc.CreateUser(testCtx, useCases.CreateUserCommand{ Username: "carol", @@ -268,7 +268,7 @@ func TestRegistrationGateAndVerificationEmailUseCase(t *testing.T) { t.Run("A failure hashing the verification code persists nothing", func(t *testing.T) { userRepo := newMockUserRepo() mailRepo := newMockMailRepo() - uc := useCases.NewCreateUserUseCase(userRepo, &sequentialHasher{}, mailRepo, "https://cms.example.com", discardRecorder) + uc := useCases.NewCreateUserUseCase(userRepo, newSeededMockRoleRepo(), &sequentialHasher{}, mailRepo, "https://cms.example.com", discardRecorder) _, err := uc.CreateUser(testCtx, useCases.CreateUserCommand{ Username: "alice", @@ -291,7 +291,7 @@ func TestRegistrationGateAndVerificationEmailUseCase(t *testing.T) { userRepo := newMockUserRepo() mailRepo := newMockMailRepo() mailRepo.fail.failNext("Send") - uc := useCases.NewCreateUserUseCase(userRepo, &stubHasher{}, mailRepo, "https://cms.example.com", discardRecorder) + uc := useCases.NewCreateUserUseCase(userRepo, newSeededMockRoleRepo(), &stubHasher{}, mailRepo, "https://cms.example.com", discardRecorder) _, err := uc.CreateUser(testCtx, useCases.CreateUserCommand{ Username: "alice", @@ -312,7 +312,7 @@ func TestRegistrationGateAndVerificationEmailUseCase(t *testing.T) { mailRepo := newMockMailRepo() mailRepo.fail.failNext("Send") userRepo.fail.failNext("Delete") - uc := useCases.NewCreateUserUseCase(userRepo, &stubHasher{}, mailRepo, "https://cms.example.com", discardRecorder) + uc := useCases.NewCreateUserUseCase(userRepo, newSeededMockRoleRepo(), &stubHasher{}, mailRepo, "https://cms.example.com", discardRecorder) _, err := uc.CreateUser(testCtx, useCases.CreateUserCommand{ Username: "alice", diff --git a/test/registration_status_endpoint_http_test.go b/test/registration_status_endpoint_http_test.go index 6d1c18f..6c27053 100644 --- a/test/registration_status_endpoint_http_test.go +++ b/test/registration_status_endpoint_http_test.go @@ -18,7 +18,7 @@ import ( func newRegistrationStatusRouter(userRepo *mockUserRepo, mailRepo *mockMailRepo) (*chi.Mux, *useCases.CreateUserUseCase) { token := authtoken.New([]byte("test-secret")) hasher := &stubHasher{} - createUseCase := useCases.NewCreateUserUseCase(userRepo, hasher, mailRepo, "https://cms.example.com", discardRecorder) + createUseCase := useCases.NewCreateUserUseCase(userRepo, newSeededMockRoleRepo(), hasher, mailRepo, "https://cms.example.com", discardRecorder) loginHandler := auth.NewLoginHandler(token, createUseCase, useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder), true) r := chi.NewRouter() @@ -162,7 +162,7 @@ func TestRegistrationStatusEndpointHttp(t *testing.T) { userRepo.users[1] = user.User{ID: 1, Username: "alice", Email: "alice@example.com", IsVerified: true} userRepo.nextID = 1 mailRepo := newMockMailRepo() - uc := useCases.NewCreateUserUseCase(userRepo, &stubHasher{}, mailRepo, "https://cms.example.com", discardRecorder) + uc := useCases.NewCreateUserUseCase(userRepo, newSeededMockRoleRepo(), &stubHasher{}, mailRepo, "https://cms.example.com", discardRecorder) if uc.IsRegistrationOpen() { t.Fatalf("expected IsRegistrationOpen to return false") diff --git a/test/repository_contract_test.go b/test/repository_contract_test.go index 5bf8b75..3f09602 100644 --- a/test/repository_contract_test.go +++ b/test/repository_contract_test.go @@ -129,7 +129,7 @@ func newContractRepositories(db *gorm.DB) contractRepositories { } func contractUser(username string, email string) domainUser.User { - return domainUser.User{Username: username, Password: "hashed", Email: email, Role: domainUser.RoleAdmin, IsVerified: true} + return domainUser.User{Username: username, Password: "hashed", Email: email, RoleID: adminRoleID, IsVerified: true} } func contractInvite(t *testing.T, repos contractRepositories, email string) domainInvite.Invite { diff --git a/test/role_hardening_test.go b/test/role_hardening_test.go index 0e2ef6b..7d6d53d 100644 --- a/test/role_hardening_test.go +++ b/test/role_hardening_test.go @@ -281,7 +281,7 @@ func TestGrantSetTypedDecisions(t *testing.T) { func TestBackfillCreatedByWithSoftDeletedArticle(t *testing.T) { db := openMigratedRolesDB(t) - createUserRow(t, db, 3, "owner", nil) + createUserRow(t, db, 3, ownerIDPtr(t, db)) createArticleRow(t, db, 10, 0) if err := db.Delete(&models.Article{}, 10).Error; err != nil { t.Fatal(err) diff --git a/test/role_repository_gaps_test.go b/test/role_repository_gaps_test.go index 339a3bd..73cacd7 100644 --- a/test/role_repository_gaps_test.go +++ b/test/role_repository_gaps_test.go @@ -119,16 +119,21 @@ func TestRoleRepositoryFailures(t *testing.T) { }) } -func TestBackfillUserRoleIDsWithoutBuiltInRoles(t *testing.T) { +func TestBackfillWithoutBuiltInRoles(t *testing.T) { db := openUnmigratedSQLite(t) - if err := db.AutoMigrate(&models.Role{}, &models.RolePermission{}, &models.User{}); err != nil { + if err := db.AutoMigrate(&models.Role{}, &models.RolePermission{}, &models.User{}, &models.Invite{}, &models.MigrationMarker{}); err != nil { t.Fatal(err) } - createUserRow(t, db, 1, "owner", nil) - if err := database.BackfillUserRoleIDs(db); err != nil { - t.Fatal(err) + addLegacyRoleColumn(t, db) + createUserRow(t, db, 1, nil) + setLegacyRole(t, db, 1, "owner") + if err := database.BackfillLegacyUserRoles(db); !errors.Is(err, role.ErrRoleNotFound) { + t.Errorf("expected ErrRoleNotFound, got %v", err) } if userRow(t, db, 1).RoleID != nil { t.Errorf("role_id must stay unset when no built-in role exists") } + if err := database.BackfillCreatedBy(db); err != nil { + t.Errorf("created_by backfill without roles must do nothing, got %v", err) + } } diff --git a/test/role_support_test.go b/test/role_support_test.go index 4cdf91b..1006755 100644 --- a/test/role_support_test.go +++ b/test/role_support_test.go @@ -16,6 +16,7 @@ type mockRoleRepo struct { nextID uint32 fail failSwitch users *mockUserRepo + invites *inviteFakeRepo calls map[string]int beforeSave func() } @@ -127,18 +128,47 @@ func (m *mockRoleRepo) Save(saved role.Role) (role.Role, error) { } func (m *mockRoleRepo) memberCount(roleID uint32) int { - if m.users == nil { - return 0 - } count := 0 - for _, member := range m.users.users { - if member.RoleID == roleID { - count++ + if m.users != nil { + for _, member := range m.users.users { + if member.RoleID == roleID { + count++ + } + } + } + if m.invites != nil { + for _, pending := range m.invites.invites { + if pending.RoleID == roleID && pending.AcceptedAt == nil { + count++ + } } } return count } +func (m *mockRoleRepo) MemberCounts() (map[uint32]int, error) { + m.calls["MemberCounts"]++ + if err := m.fail.trigger("MemberCounts"); err != nil { + return nil, err + } + counts := map[uint32]int{} + if m.users != nil { + for _, member := range m.users.users { + if member.RoleID != 0 { + counts[member.RoleID]++ + } + } + } + if m.invites != nil { + for _, pending := range m.invites.invites { + if pending.RoleID != 0 && pending.AcceptedAt == nil { + counts[pending.RoleID]++ + } + } + } + return counts, nil +} + func (m *mockRoleRepo) Delete(id uint32) error { m.calls["Delete"]++ if err := m.fail.trigger("Delete"); err != nil { @@ -159,15 +189,23 @@ func (m *mockRoleRepo) ReassignMembers(from uint32, to uint32) (int, error) { if err := m.fail.trigger("ReassignMembers"); err != nil { return 0, err } - if m.users == nil { - return 0, nil - } moved := 0 - for id, member := range m.users.users { - if member.RoleID == from { - member.RoleID = to - m.users.users[id] = member - moved++ + if m.users != nil { + for id, member := range m.users.users { + if member.RoleID == from { + member.RoleID = to + m.users.users[id] = member + moved++ + } + } + } + if m.invites != nil { + for id, pending := range m.invites.invites { + if pending.RoleID == from && pending.AcceptedAt == nil { + pending.RoleID = to + m.invites.invites[id] = pending + moved++ + } } } return moved, nil diff --git a/test/roles_api_hardening_test.go b/test/roles_api_hardening_test.go index d8d63ce..cce0bae 100644 --- a/test/roles_api_hardening_test.go +++ b/test/roles_api_hardening_test.go @@ -21,7 +21,7 @@ func TestRoleRepositoryRolesAPIHardening(t *testing.T) { db := openMigratedRolesDB(t) repo := persistence.NewRoleRepository(db) source := rolesAPICreateRole(t, repo, "Contributor", "content.read=all") - createUserRow(t, db, 1, "", &source.ID) + createUserRow(t, db, 1, &source.ID) moved, err := repo.ReassignMembers(source.ID, 9999) if !errors.Is(err, role.ErrInvalidReassign) || moved != 0 { t.Fatalf("expected ErrInvalidReassign, got %d %v", moved, err) @@ -81,7 +81,7 @@ func TestRoleRepositoryRolesAPIHardening(t *testing.T) { repo := persistence.NewRoleRepository(db) kept := rolesAPICreateRole(t, repo, "Contributor", "content.read=all") gone := rolesAPICreateRole(t, repo, "Intern", "content.read=own") - createUserRow(t, db, 1, "", &kept.ID) + createUserRow(t, db, 1, &kept.ID) if err := repo.Delete(kept.ID); !errors.Is(err, role.ErrRoleHasMembers) { t.Fatalf("expected ErrRoleHasMembers, got %v", err) } @@ -165,7 +165,7 @@ func TestRolesAPIHardeningUseCases(t *testing.T) { t.Run("a member count failure while deleting a role is reported and deletes nothing", func(t *testing.T) { world := newRolesAPIWorld(t) - world.roles.fail.failNext("GetByName") + world.roles.fail.failNext("MemberCounts") err := world.remove(raAdmin, "Intern") if !errors.Is(err, errRepositoryFailure) { t.Fatalf("expected the repository failure, got %v", err) diff --git a/test/roles_api_persistence_test.go b/test/roles_api_persistence_test.go index a5d0a29..cfc74f4 100644 --- a/test/roles_api_persistence_test.go +++ b/test/roles_api_persistence_test.go @@ -144,7 +144,7 @@ func TestRoleRepositoryRolesAPI(t *testing.T) { db := openMigratedRolesDB(t) repo := persistence.NewRoleRepository(db) created := rolesAPICreateRole(t, repo, "Contributor", "content.read=all") - createUserRow(t, db, 1, "", &created.ID) + createUserRow(t, db, 1, &created.ID) if err := repo.Delete(created.ID); !errors.Is(err, role.ErrRoleHasMembers) { t.Fatalf("expected ErrRoleHasMembers, got %v", err) } @@ -165,9 +165,9 @@ func TestRoleRepositoryRolesAPI(t *testing.T) { repo := persistence.NewRoleRepository(db) source := rolesAPICreateRole(t, repo, "Contributor", "content.read=all") writer := storedRole(t, db, "Writer") - createUserRow(t, db, 1, "", &source.ID) - createUserRow(t, db, 2, "", &source.ID) - createUserRow(t, db, 3, "", &writer.ID) + createUserRow(t, db, 1, &source.ID) + createUserRow(t, db, 2, &source.ID) + createUserRow(t, db, 3, &writer.ID) before := userRow(t, db, 1) moved, err := repo.ReassignMembers(source.ID, writer.ID) if err != nil || moved != 2 { diff --git a/test/roles_api_usecases_test.go b/test/roles_api_usecases_test.go index fe77653..201ef3b 100644 --- a/test/roles_api_usecases_test.go +++ b/test/roles_api_usecases_test.go @@ -3,7 +3,6 @@ package test import ( "RenewCMS/internal/domain/audit" "RenewCMS/internal/domain/role" - "RenewCMS/internal/domain/user" "RenewCMS/internal/infrastructure/useCases" "context" "errors" @@ -878,7 +877,7 @@ func TestGetMyAccessTypedGrants(t *testing.T) { t.Run("an unlocked role never lists an owner-only key", func(t *testing.T) { world := newRolesAPIWorld(t) hand, _ := world.roles.Create(role.Role{Name: "Hand placed", Version: 1, Grants: grantRows("system.apply_updates=all", "content.read=all", "content.read=own")}) - world.addUser(60, user.RoleAdmin, hand.ID, true) + world.addUser(60, hand.ID, true) access, _ := useCases.NewGetMyAccessUseCase(world.roles).Get(world.users.users[60]) if !reflect.DeepEqual(access.Permissions, grantRows("content.read=own")) { t.Fatalf("unexpected permissions %v", access.Permissions) diff --git a/test/roles_api_world_test.go b/test/roles_api_world_test.go index 7dfc2a6..a3da5d3 100644 --- a/test/roles_api_world_test.go +++ b/test/roles_api_world_test.go @@ -73,11 +73,11 @@ func newRolesAPIWorld(t *testing.T) *rolesAPIWorld { func (w *rolesAPIWorld) seedBuiltIns() { w.ids["Owner"], w.ids["Admin"], w.ids["Writer"] = ownerRoleID, adminRoleID, writerRoleID - w.addUser(raOwner, user.RoleOwner, ownerRoleID, true) - w.addUser(raAdmin, user.RoleAdmin, adminRoleID, true) - w.addUser(raLegacyAdmin, user.RoleAdmin, 0, true) - w.addUser(raWriter, "", writerRoleID, true) - w.addUser(raUnverified, user.RoleAdmin, adminRoleID, false) + w.addUser(raOwner, ownerRoleID, true) + w.addUser(raAdmin, adminRoleID, true) + w.addUser(raLegacyAdmin, adminRoleID, true) + w.addUser(raWriter, writerRoleID, true) + w.addUser(raUnverified, adminRoleID, false) } func (w *rolesAPIWorld) seedRoles() { @@ -85,7 +85,7 @@ func (w *rolesAPIWorld) seedRoles() { created, _ := w.roles.Create(role.Role{Name: spec.name, Version: 1, Grants: grantRows(spec.grants...)}) w.ids[spec.name] = created.ID for _, member := range spec.members { - w.addUser(member, "", created.ID, true) + w.addUser(member, created.ID, true) } } } @@ -138,12 +138,7 @@ func (w *rolesAPIWorld) path(name string) string { } func (w *rolesAPIWorld) caller(userID uint32) useCases.ContentCaller { - account := w.users.users[userID] - roleID := account.RoleID - if roleID == 0 { - roleID = w.ids[map[user.Role]string{user.RoleOwner: "Owner", user.RoleAdmin: "Admin"}[account.Role]] - } - return useCases.ContentCaller{UserID: userID, Grants: w.roles.roles[roleID].GrantSet()} + return useCases.ContentCaller{UserID: userID, Grants: w.roles.roles[w.users.users[userID].RoleID].GrantSet()} } func (w *rolesAPIWorld) members(name string) []uint32 { diff --git a/test/roles_content_scope_http_test.go b/test/roles_content_scope_http_test.go index 4abaae9..859b148 100644 --- a/test/roles_content_scope_http_test.go +++ b/test/roles_content_scope_http_test.go @@ -601,14 +601,17 @@ func TestContentScopeRequestLoads(t *testing.T) { }) } - t.Run("A legacy owner is resolved by one GetByName and no List call", func(t *testing.T) { + t.Run("A user with role_id 0 is denied without any role load", func(t *testing.T) { world := newContentScopeWorld(t) - legacy := world.users.users[csOwner] - legacy.RoleID = 0 - world.users.users[csOwner] = legacy + bare := world.users.users[csOwner] + bare.RoleID = 0 + world.users.users[csOwner] = bare world.resetCounters() - assertScopeWant(t, world.do(t, csOwner, http.MethodPatch, "/v1/article/10", csArticlePatch), okWant(200)) - if world.roleRepo.getByNames != 1 || world.roleRepo.gets != 0 || world.roleRepo.lists != 0 { + rec := world.do(t, csOwner, http.MethodPatch, "/v1/article/10", csArticlePatch) + if rec.Code != http.StatusForbidden || rec.Body.String() != "missing_permission\n" { + t.Errorf("expected 403 missing_permission, got %d %q", rec.Code, rec.Body.String()) + } + if world.roleRepo.touched() != 0 { t.Errorf("unexpected role loads %+v", world.roleRepo) } }) diff --git a/test/roles_content_scope_usecases_test.go b/test/roles_content_scope_usecases_test.go index 4f252f9..496f134 100644 --- a/test/roles_content_scope_usecases_test.go +++ b/test/roles_content_scope_usecases_test.go @@ -91,11 +91,11 @@ func TestContentScopeCheckPermissionUseCase(t *testing.T) { t.Errorf("unexpected loads %+v", counting) } counting.gets = 0 - if _, err := check.Require(user.User{ID: 4, Role: user.RoleAdmin}, "content.read"); err != nil { + if _, err := check.Require(user.User{ID: 4, RoleID: adminRoleID}, "content.read"); err != nil { t.Fatal(err) } - if counting.gets != 0 || counting.getByNames != 1 || counting.lists != 0 { - t.Errorf("unexpected legacy loads %+v", counting) + if counting.gets != 1 || counting.getByNames != 0 || counting.lists != 0 { + t.Errorf("unexpected loads %+v", counting) } }) diff --git a/test/roles_content_scope_world_test.go b/test/roles_content_scope_world_test.go index 0d39f5a..c0bebe6 100644 --- a/test/roles_content_scope_world_test.go +++ b/test/roles_content_scope_world_test.go @@ -179,15 +179,15 @@ func newContentScopeWorld(t *testing.T) *contentScopeWorld { } func (w *contentScopeWorld) seedUsers() { - w.addUser(csOwner, user.RoleOwner, ownerRoleID, true) - w.addUser(csAdmin, user.RoleAdmin, adminRoleID, true) - w.addUser(csWriter, "", writerRoleID, true) - w.addUser(csUnverified, user.RoleAdmin, adminRoleID, false) - w.addUser(csOrphan, "", 99, true) + w.addUser(csOwner, ownerRoleID, true) + w.addUser(csAdmin, adminRoleID, true) + w.addUser(csWriter, writerRoleID, true) + w.addUser(csUnverified, adminRoleID, false) + w.addUser(csOrphan, 99, true) for _, spec := range contentScopeCustomRoles { created, _ := w.roles.Create(role.Role{Name: spec.name, Version: 1, Grants: grantRows(spec.grants...)}) w.roleIDs[spec.user] = created.ID - w.addUser(spec.user, "", created.ID, true) + w.addUser(spec.user, created.ID, true) } } diff --git a/test/roles_permissions_enforcement_http_test.go b/test/roles_permissions_enforcement_http_test.go index 5ac5ed6..f763926 100644 --- a/test/roles_permissions_enforcement_http_test.go +++ b/test/roles_permissions_enforcement_http_test.go @@ -3,10 +3,10 @@ package test import ( "RenewCMS/api/pkg/apperr" "RenewCMS/internal/domain/role" - "RenewCMS/internal/domain/user" "encoding/json" "net/http" "net/http/httptest" + "strconv" "strings" "testing" ) @@ -32,17 +32,17 @@ func (g gateOutcome) blocked() bool { func newEnforcementEnv(t *testing.T) *apiKeyEnv { t.Helper() e := newAPIKeyEnv(t) - e.addUser(enforcementOwnerID, user.RoleOwner, ownerRoleID, true) - e.addUser(enforcementAdminID, user.RoleAdmin, adminRoleID, true) - e.addUser(enforcementWriterID, "", writerRoleID, true) - e.addUser(enforcementUnverified, user.RoleAdmin, adminRoleID, false) - e.addUser(enforcementOrphanID, "", 99, true) + e.addUser(enforcementOwnerID, ownerRoleID, true) + e.addUser(enforcementAdminID, adminRoleID, true) + e.addUser(enforcementWriterID, writerRoleID, true) + e.addUser(enforcementUnverified, adminRoleID, false) + e.addUser(enforcementOrphanID, 99, true) return e } func (e *apiKeyEnv) customRoleUser(id uint32, grants ...role.Grant) role.Role { custom, _ := e.roles.Create(role.Role{Name: "Custom " + string(rune('a'+id)), Version: 1, Grants: grants}) - e.addUser(id, "", custom.ID, true) + e.addUser(id, custom.ID, true) return custom } @@ -370,26 +370,25 @@ func TestRolesPermissionsEnforcementHTTP(t *testing.T) { assertAPIKeyMessage(t, rec, http.StatusInternalServerError, "Internal server error") }) - t.Run("The legacy role string is no longer read for authorization", func(t *testing.T) { + t.Run("Only role_id decides authorization, and role_id 0 is denied", func(t *testing.T) { rows := []struct { id uint32 - legacy user.Role roleID uint32 method string path string key string }{ - {14, user.RoleOwner, writerRoleID, http.MethodPost, "/v1/user/invite", "users.invite"}, - {15, user.RoleAdmin, writerRoleID, http.MethodPost, "/v1/content-type", "schema.edit"}, - {16, "", adminRoleID, http.MethodPost, "/v1/content-type", ""}, - {17, user.RoleOwner, 0, http.MethodPost, "/v1/user/invite", ""}, - {18, user.RoleAdmin, 0, http.MethodPost, "/v1/content-type", ""}, - {19, user.RoleAdmin, 0, http.MethodPatch, "/v1/settings", "system.edit_retention"}, + {14, writerRoleID, http.MethodPost, "/v1/user/invite", "users.invite"}, + {15, writerRoleID, http.MethodPost, "/v1/content-type", "schema.edit"}, + {16, adminRoleID, http.MethodPost, "/v1/content-type", ""}, + {17, 0, http.MethodPost, "/v1/user/invite", "users.invite"}, + {18, 0, http.MethodPost, "/v1/content-type", "schema.edit"}, + {19, 0, http.MethodPatch, "/v1/settings", "system.edit_retention"}, } for _, row := range rows { - t.Run(string(row.legacy)+" "+row.method+" "+row.path+" -> "+resultOf(row.key), func(t *testing.T) { + t.Run(strconv.Itoa(int(row.roleID))+" "+row.method+" "+row.path+" -> "+resultOf(row.key), func(t *testing.T) { e := newEnforcementEnv(t) - e.addUser(row.id, row.legacy, row.roleID, true) + e.addUser(row.id, row.roleID, true) assertGate(t, e.gateAs(t, row.id, row.method, row.path, ""), row.key) }) } diff --git a/test/roles_permissions_enforcement_test.go b/test/roles_permissions_enforcement_test.go index 04347e6..3001ad6 100644 --- a/test/roles_permissions_enforcement_test.go +++ b/test/roles_permissions_enforcement_test.go @@ -93,10 +93,8 @@ func TestCheckPermissionUseCase(t *testing.T) { t.Run("An unresolvable role is refused as a missing permission", func(t *testing.T) { check, _ := newCheck() for name, account := range map[string]user.User{ - "unknown role_id": {ID: 13, RoleID: 99}, - "empty legacy and no id": {ID: 14, Role: ""}, - "editor legacy and no id": {ID: 15, Role: "editor"}, - "legacy owner string ignored when role_id names Writer": {ID: 16, Role: user.RoleOwner, RoleID: writerRoleID}, + "unknown role_id": {ID: 13, RoleID: 99}, + "role id zero": {ID: 14}, } { _, err := check.Require(account, "users.invite") if !errors.Is(err, role.ErrMissingPermission) || missingKeyOf(t, err) != "users.invite" { @@ -107,10 +105,10 @@ func TestCheckPermissionUseCase(t *testing.T) { t.Run("The legacy string resolves only owner and admin when there is no role_id", func(t *testing.T) { check, _ := newCheck() - if _, err := check.Require(user.User{ID: 17, Role: user.RoleOwner}, "system.apply_updates"); err != nil { + if _, err := check.Require(user.User{ID: 17, RoleID: ownerRoleID}, "system.apply_updates"); err != nil { t.Errorf("legacy owner: %v", err) } - if _, err := check.Require(user.User{ID: 18, Role: user.RoleAdmin}, "system.apply_updates"); !errors.Is(err, role.ErrMissingPermission) { + if _, err := check.Require(user.User{ID: 18, RoleID: adminRoleID}, "system.apply_updates"); !errors.Is(err, role.ErrMissingPermission) { t.Errorf("legacy admin should lack owner-only keys, got %v", err) } }) diff --git a/test/roles_permissions_foundation_http_test.go b/test/roles_permissions_foundation_http_test.go index e10278c..56b48e8 100644 --- a/test/roles_permissions_foundation_http_test.go +++ b/test/roles_permissions_foundation_http_test.go @@ -25,13 +25,12 @@ type meBody struct { ID uint32 `json:"id"` Username string `json:"username"` IsVerified bool `json:"is_verified"` - Role string `json:"role"` Author any `json:"author"` Access accessBody `json:"access"` } -func (e *apiKeyEnv) addUser(id uint32, legacy user.Role, roleID uint32, verified bool) { - e.users.users[id] = user.User{ID: id, Username: "user" + string(rune('a'+id)), Email: "user" + string(rune('a'+id)) + "@example.com", IsVerified: verified, Role: legacy, RoleID: roleID} +func (e *apiKeyEnv) addUser(id uint32, roleID uint32, verified bool) { + e.users.users[id] = user.User{ID: id, Username: "user" + string(rune('a'+id)), Email: "user" + string(rune('a'+id)) + "@example.com", IsVerified: verified, RoleID: roleID} } func (e *apiKeyEnv) me(t *testing.T, id uint32) (*httptest.ResponseRecorder, meBody) { @@ -57,12 +56,12 @@ func permissionKeys(grants []role.Grant) []string { func TestRolesPermissionsFoundationHTTP(t *testing.T) { t.Run("The owner's /me carries the Owner role and all 34 permissions", func(t *testing.T) { e := newAPIKeyEnv(t) - e.addUser(7, user.RoleOwner, ownerRoleID, true) + e.addUser(7, ownerRoleID, true) rec, body := e.me(t, 7) if rec.Code != http.StatusOK || rec.Header().Get("Cache-Control") != "no-store" { t.Fatalf("status %d cache %q", rec.Code, rec.Header().Get("Cache-Control")) } - if body.ID != 7 || body.Role != "owner" || !body.IsVerified { + if body.ID != 7 || !body.IsVerified { t.Errorf("existing fields changed: %+v", body) } if body.Access.Role.ID != ownerRoleID || body.Access.Role.Name != "Owner" || !body.Access.Role.BuiltIn || !body.Access.Role.Locked { @@ -84,7 +83,7 @@ func TestRolesPermissionsFoundationHTTP(t *testing.T) { t.Run("An admin's /me omits the owner-only keys", func(t *testing.T) { e := newAPIKeyEnv(t) - e.addUser(8, user.RoleAdmin, adminRoleID, true) + e.addUser(8, adminRoleID, true) _, body := e.me(t, 8) if body.Access.Role.Name != "Admin" || !body.Access.Role.BuiltIn || body.Access.Role.Locked { t.Errorf("role %+v", body.Access.Role) @@ -105,7 +104,7 @@ func TestRolesPermissionsFoundationHTTP(t *testing.T) { t.Run("A writer's /me lists only granted keys with their scope", func(t *testing.T) { e := newAPIKeyEnv(t) - e.addUser(9, user.RoleAdmin, writerRoleID, true) + e.addUser(9, writerRoleID, true) _, body := e.me(t, 9) want := []role.Grant{ {Key: "content.read", Scope: role.ScopeAll}, {Key: "content.create", Scope: role.ScopeAll}, @@ -124,7 +123,7 @@ func TestRolesPermissionsFoundationHTTP(t *testing.T) { custom, _ := e.roles.Create(role.Role{Name: "Contributor", Version: 1, Grants: []role.Grant{ {Key: "content.update", Scope: role.ScopeOwn}, {Key: "content.read", Scope: role.ScopeAll}, }}) - e.addUser(10, user.RoleAdmin, custom.ID, true) + e.addUser(10, custom.ID, true) _, body := e.me(t, 10) want := []role.Grant{{Key: "content.read", Scope: role.ScopeAll}, {Key: "content.update", Scope: role.ScopeOwn}} if body.Access.Role.Name != "Contributor" || body.Access.Role.BuiltIn || !slices.Equal(body.Access.Permissions, want) { @@ -132,20 +131,16 @@ func TestRolesPermissionsFoundationHTTP(t *testing.T) { } }) - t.Run("A user with no role_id falls back to the built-in role named by the legacy role", func(t *testing.T) { - for legacy, name := range map[user.Role]string{user.RoleOwner: "Owner", user.RoleAdmin: "Admin"} { - e := newAPIKeyEnv(t) - e.addUser(11, legacy, 0, true) - _, body := e.me(t, 11) - if body.Access.Role.Name != name { - t.Errorf("%s: got %q", legacy, body.Access.Role.Name) - } - } + t.Run("A user with role_id 0 is answered 500 on /me and never promoted", func(t *testing.T) { + e := newAPIKeyEnv(t) + e.addUser(11, 0, true) + rec, _ := e.me(t, 11) + assertAPIKeyMessage(t, rec, http.StatusInternalServerError, "Internal server error") }) t.Run("The permissions follow the role live", func(t *testing.T) { e := newAPIKeyEnv(t) - e.addUser(9, user.RoleAdmin, writerRoleID, true) + e.addUser(9, writerRoleID, true) writer := e.roles.byName("Writer") writer.Grants = append(writer.Grants, role.Grant{Key: "media.delete", Scope: role.ScopeOwn}) _, _ = e.roles.Save(writer) @@ -157,7 +152,7 @@ func TestRolesPermissionsFoundationHTTP(t *testing.T) { t.Run("An unverified user still gets /me, with access", func(t *testing.T) { e := newAPIKeyEnv(t) - e.addUser(12, user.RoleAdmin, writerRoleID, false) + e.addUser(12, writerRoleID, false) rec, body := e.me(t, 12) if rec.Code != http.StatusOK || body.IsVerified || body.Access.Role.Name != "Writer" { t.Errorf("status %d body %+v", rec.Code, body) @@ -166,7 +161,7 @@ func TestRolesPermissionsFoundationHTTP(t *testing.T) { t.Run("A user whose role cannot be resolved gets a server error, not an empty access block", func(t *testing.T) { e := newAPIKeyEnv(t) - e.addUser(13, "", 99, true) + e.addUser(13, 99, true) rec, _ := e.me(t, 13) if rec.Code != http.StatusInternalServerError || rec.Body.String() != "Internal server error\n" { t.Errorf("status %d body %q", rec.Code, rec.Body.String()) @@ -178,7 +173,7 @@ func TestRolesPermissionsFoundationHTTP(t *testing.T) { if rec := e.anonymous(t, http.MethodGet, "/v1/auth/me", ""); rec.Code != http.StatusUnauthorized { t.Errorf("no cookie: %d", rec.Code) } - e.addUser(7, user.RoleOwner, ownerRoleID, true) + e.addUser(7, ownerRoleID, true) stale := e.users.users[7] stale.TokenVersion = 1 e.users.users[7] = stale @@ -210,7 +205,7 @@ func TestPermissionCatalogueHTTP(t *testing.T) { t.Run("A signed-in verified user reads the catalogue", func(t *testing.T) { e := newAPIKeyEnv(t) - e.addUser(9, user.RoleAdmin, writerRoleID, true) + e.addUser(9, writerRoleID, true) rec := e.withCookie(t, http.MethodGet, "/v1/permission", "", 9) if rec.Code != http.StatusOK { t.Fatalf("status %d: %s", rec.Code, rec.Body.String()) @@ -251,8 +246,8 @@ func TestPermissionCatalogueHTTP(t *testing.T) { t.Run("The catalogue is the same for every role", func(t *testing.T) { e := newAPIKeyEnv(t) - e.addUser(9, user.RoleAdmin, writerRoleID, true) - e.addUser(7, user.RoleOwner, ownerRoleID, true) + e.addUser(9, writerRoleID, true) + e.addUser(7, ownerRoleID, true) writer := e.withCookie(t, http.MethodGet, "/v1/permission", "", 9) owner := e.withCookie(t, http.MethodGet, "/v1/permission", "", 7) if writer.Body.String() != owner.Body.String() || writer.Body.Len() == 0 { @@ -277,7 +272,7 @@ func TestPermissionCatalogueHTTP(t *testing.T) { t.Run("A stale token is refused", func(t *testing.T) { e := newAPIKeyEnv(t) - e.addUser(7, user.RoleOwner, ownerRoleID, true) + e.addUser(7, ownerRoleID, true) stored := e.users.users[7] stored.TokenVersion = 1 e.users.users[7] = stored @@ -289,7 +284,7 @@ func TestPermissionCatalogueHTTP(t *testing.T) { t.Run("An unverified user is refused", func(t *testing.T) { e := newAPIKeyEnv(t) - e.addUser(12, user.RoleAdmin, writerRoleID, false) + e.addUser(12, writerRoleID, false) rec := e.withCookie(t, http.MethodGet, "/v1/permission", "", 12) if rec.Code != http.StatusForbidden || rec.Body.String() != apperr.VerificationRequiredErrorMessage+"\n" { t.Errorf("status %d body %q", rec.Code, rec.Body.String()) diff --git a/test/roles_permissions_foundation_test.go b/test/roles_permissions_foundation_test.go index 287e115..4d867fc 100644 --- a/test/roles_permissions_foundation_test.go +++ b/test/roles_permissions_foundation_test.go @@ -229,9 +229,9 @@ func TestSeedBuiltInRoles(t *testing.T) { }) } -func createUserRow(t *testing.T, db *gorm.DB, id uint32, legacy string, roleID *uint32) { +func createUserRow(t *testing.T, db *gorm.DB, id uint32, roleID *uint32) { t.Helper() - row := models.User{ID: id, Username: "u" + legacy + string(rune('a'+id)), Email: legacy + string(rune('a'+id)) + "@x.example", Password: "x", Role: legacy, RoleID: roleID} + row := models.User{ID: id, Username: "u" + string(rune('a'+id)), Email: string(rune('a'+id)) + "@x.example", Password: "x", RoleID: roleID} if err := db.Create(&row).Error; err != nil { t.Fatalf("user %d: %v", id, err) } @@ -253,62 +253,10 @@ func roleIDOf(row models.User) uint32 { return *row.RoleID } -func TestBackfillUserRoleIDs(t *testing.T) { - t.Run("Existing owner and admin users get the matching built-in role", func(t *testing.T) { - db := openMigratedRolesDB(t) - createUserRow(t, db, 3, "owner", nil) - createUserRow(t, db, 5, "admin", nil) - createUserRow(t, db, 9, "admin", nil) - if err := database.BackfillUserRoleIDs(db); err != nil { - t.Fatal(err) - } - ownerID, adminID := storedRole(t, db, "Owner").ID, storedRole(t, db, "Admin").ID - if roleIDOf(userRow(t, db, 3)) != ownerID || roleIDOf(userRow(t, db, 5)) != adminID || roleIDOf(userRow(t, db, 9)) != adminID { - t.Errorf("role ids not backfilled") - } - if userRow(t, db, 3).Role != "owner" || userRow(t, db, 5).Role != "admin" { - t.Errorf("legacy role changed") - } - }) - - t.Run("The backfill runs after BackfillUserRoles on a database that predates both", func(t *testing.T) { - db := openMigratedRolesDB(t) - createUserRow(t, db, 2, "", nil) - createUserRow(t, db, 4, "", nil) - if err := database.Migrate(db, migrationPublicURL); err != nil { - t.Fatal(err) - } - two, four := userRow(t, db, 2), userRow(t, db, 4) - if two.Role != "owner" || roleIDOf(two) != storedRole(t, db, "Owner").ID { - t.Errorf("user 2: %+v", two) - } - if four.Role != "admin" || roleIDOf(four) != storedRole(t, db, "Admin").ID { - t.Errorf("user 4: %+v", four) - } - }) - - t.Run("The role_id backfill is idempotent and respects an existing role_id", func(t *testing.T) { - db := openMigratedRolesDB(t) - custom, err := persistence.NewRoleRepository(db).Create(role.Role{Name: "Contributor", Version: 1}) - if err != nil { - t.Fatal(err) - } - createUserRow(t, db, 5, "admin", &custom.ID) - for range 2 { - if err := database.BackfillUserRoleIDs(db); err != nil { - t.Fatal(err) - } - } - if roleIDOf(userRow(t, db, 5)) != custom.ID { - t.Errorf("custom role replaced") - } - }) - - t.Run("A database with no users is left alone", func(t *testing.T) { - if err := database.BackfillUserRoleIDs(openMigratedRolesDB(t)); err != nil { - t.Errorf("unexpected error %v", err) - } - }) +func ownerIDPtr(t *testing.T, db *gorm.DB) *uint32 { + t.Helper() + id := storedRole(t, db, "Owner").ID + return &id } func createdByOf(t *testing.T, db *gorm.DB, table string, id uint32) uint32 { @@ -337,8 +285,8 @@ func createEntryRow(t *testing.T, db *gorm.DB, id uint32, createdBy uint32) { func TestBackfillCreatedBy(t *testing.T) { t.Run("Existing articles are attributed to the first owner", func(t *testing.T) { db := openMigratedRolesDB(t) - createUserRow(t, db, 3, "owner", nil) - createUserRow(t, db, 4, "owner", nil) + createUserRow(t, db, 3, ownerIDPtr(t, db)) + createUserRow(t, db, 4, ownerIDPtr(t, db)) createArticleRow(t, db, 10, 0) createArticleRow(t, db, 11, 0) if err := database.BackfillCreatedBy(db); err != nil { @@ -351,7 +299,7 @@ func TestBackfillCreatedBy(t *testing.T) { t.Run("Entries without an author are attributed to the first owner", func(t *testing.T) { db := openMigratedRolesDB(t) - createUserRow(t, db, 3, "owner", nil) + createUserRow(t, db, 3, ownerIDPtr(t, db)) createEntryRow(t, db, 20, 0) createEntryRow(t, db, 21, 7) if err := database.BackfillCreatedBy(db); err != nil { @@ -364,7 +312,7 @@ func TestBackfillCreatedBy(t *testing.T) { t.Run("The created_by backfill never rewrites an existing value, and is idempotent", func(t *testing.T) { db := openMigratedRolesDB(t) - createUserRow(t, db, 3, "owner", nil) + createUserRow(t, db, 3, ownerIDPtr(t, db)) createArticleRow(t, db, 10, 7) for range 2 { if err := database.BackfillCreatedBy(db); err != nil { @@ -403,13 +351,10 @@ func TestNewArticlesRecordTheirCreator(t *testing.T) { } func TestGetMyAccessUseCase(t *testing.T) { - t.Run("A user with no role_id falls back to the built-in role named by the legacy role", func(t *testing.T) { + t.Run("A user with role_id 0 gets an error and is never promoted", func(t *testing.T) { uc := useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()) - for legacy, name := range map[user.Role]string{user.RoleOwner: "Owner", user.RoleAdmin: "Admin"} { - access, err := uc.Get(user.User{ID: 11, Role: legacy}) - if err != nil || access.Role.Name != name { - t.Errorf("%s: %v %v", legacy, access.Role.Name, err) - } + if _, err := uc.Get(user.User{ID: 11}); !errors.Is(err, role.ErrRoleNotFound) { + t.Errorf("expected ErrRoleNotFound, got %v", err) } }) @@ -422,12 +367,4 @@ func TestGetMyAccessUseCase(t *testing.T) { t.Errorf("expected ErrRoleNotFound, got %v", err) } }) - - t.Run("The fallback never picks a custom role that reuses a built-in name", func(t *testing.T) { - repo := newMockRoleRepo() - _, _ = repo.Create(role.Role{Name: "Owner"}) - if _, err := useCases.NewGetMyAccessUseCase(repo).Get(user.User{Role: user.RoleOwner}); !errors.Is(err, role.ErrRoleNotFound) { - t.Errorf("expected ErrRoleNotFound, got %v", err) - } - }) } diff --git a/test/runtime_settings_injection_http_test.go b/test/runtime_settings_injection_http_test.go index ece5079..7a4e1aa 100644 --- a/test/runtime_settings_injection_http_test.go +++ b/test/runtime_settings_injection_http_test.go @@ -36,7 +36,7 @@ func newSecureCookieAuthRouter(t *testing.T, isProduction bool) (*chi.Mux, *mock loginHandler := auth.NewLoginHandler( token, - useCases.NewCreateUserUseCase(userRepo, hasher, newMockMailRepo(), "https://cms.example.com", discardRecorder), + useCases.NewCreateUserUseCase(userRepo, newSeededMockRoleRepo(), hasher, newMockMailRepo(), "https://cms.example.com", discardRecorder), useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder), isProduction, ) @@ -139,7 +139,7 @@ func newRuntimeSettingsContainer(t *testing.T) *api.UseCases { CheckPermissionUseCase: useCases.NewCheckPermissionUseCase(newSeededMockRoleRepo()), GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()), GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()), - CreateUserUseCase: useCases.NewCreateUserUseCase(userRepo, hasher, mailRepo, "https://cms.example.com", discardRecorder), + CreateUserUseCase: useCases.NewCreateUserUseCase(userRepo, newSeededMockRoleRepo(), hasher, mailRepo, "https://cms.example.com", discardRecorder), AuthenticateUserUseCase: useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder), CancelRegistrationUseCase: useCases.NewCancelRegistrationUseCase(userRepo), ListUsersUseCase: useCases.NewListUsersUseCase(userRepo), diff --git a/test/seo_data_support_test.go b/test/seo_data_support_test.go index bd9617e..9ca8e9c 100644 --- a/test/seo_data_support_test.go +++ b/test/seo_data_support_test.go @@ -41,7 +41,7 @@ type seoEnv struct { func newSeoEnv(t *testing.T) *seoEnv { t.Helper() p := newProducersEnv(t) - p.users.users[seoEditorID] = user.User{ID: seoEditorID, Username: "edith", Email: "edith@example.com", IsVerified: true, Role: user.Role("editor"), RoleID: writerRoleID} + p.users.users[seoEditorID] = user.User{ID: seoEditorID, Username: "edith", Email: "edith@example.com", IsVerified: true, RoleID: writerRoleID} p.users.nextID = seoEditorID authors := newMockAuthorRepo(p.articles) sites := newMockSiteRepo(p.articles) diff --git a/test/session_revocation_http_test.go b/test/session_revocation_http_test.go index b655497..f6f576c 100644 --- a/test/session_revocation_http_test.go +++ b/test/session_revocation_http_test.go @@ -170,7 +170,7 @@ func newSessionRevocationEnv(t *testing.T) *sessionRevocationEnv { t.Fatal(err) } userRepo := newMockUserRepo() - userRepo.users[1] = domainUser.User{ID: 1, Username: "alice", Email: "alice@example.com", Password: hashed, IsVerified: true, Role: domainUser.RoleOwner} + userRepo.users[1] = domainUser.User{ID: 1, Username: "alice", Email: "alice@example.com", Password: hashed, IsVerified: true, RoleID: ownerRoleID} sessions := newMemorySessionRepo() container := &api.UseCases{ Token: authtoken.New([]byte(testJWTSecret), authtoken.WithRevocationStore(useCases.NewSessionRevocationUseCase(sessions, time.Now))), @@ -178,7 +178,7 @@ func newSessionRevocationEnv(t *testing.T) *sessionRevocationEnv { CheckPermissionUseCase: useCases.NewCheckPermissionUseCase(newSeededMockRoleRepo()), GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()), GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()), - CreateUserUseCase: useCases.NewCreateUserUseCase(userRepo, hasher, newMockMailRepo(), "https://cms.example.com", discardRecorder), + CreateUserUseCase: useCases.NewCreateUserUseCase(userRepo, newSeededMockRoleRepo(), hasher, newMockMailRepo(), "https://cms.example.com", discardRecorder), AuthenticateUserUseCase: useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder), } settings := config.Settings{ diff --git a/test/site_acceptance_test.go b/test/site_acceptance_test.go index d814751..e6fa638 100644 --- a/test/site_acceptance_test.go +++ b/test/site_acceptance_test.go @@ -641,7 +641,7 @@ func TestSitesOnlyTheOwnerWrites(t *testing.T) { for _, request := range requests { t.Run(request.method+" "+request.path+" with a verified writer's jwt cookie", func(t *testing.T) { e := newSiteEnvWithShop(t) - e.addUser(siteWriterID, "", writerRoleID, true) + e.addUser(siteWriterID, writerRoleID, true) before := e.sites.snapshot() rec := e.as(t, siteWriterID, request.method, request.path, request.body) expectSiteStatusAndBody(t, rec, http.StatusForbidden, siteOwnerOnlyBody) @@ -664,7 +664,7 @@ func TestSitesOnlyTheOwnerWrites(t *testing.T) { t.Run("The role is checked before the body", func(t *testing.T) { e := newSiteEnvWithMain(t) - e.addUser(siteWriterID, "", writerRoleID, true) + e.addUser(siteWriterID, writerRoleID, true) rec := e.as(t, siteWriterID, http.MethodPost, "/v1/site/", "{not json") diff --git a/test/site_activity_acceptance_test.go b/test/site_activity_acceptance_test.go index 917f123..548d7a4 100644 --- a/test/site_activity_acceptance_test.go +++ b/test/site_activity_acceptance_test.go @@ -132,7 +132,7 @@ func TestSitesActivityLog(t *testing.T) { for _, c := range cases { t.Run(c.request, func(t *testing.T) { e := newSiteEnvWithShop(t) - e.addUser(siteWriterID, "", writerRoleID, true) + e.addUser(siteWriterID, writerRoleID, true) e.as(t, c.as, c.method, c.path, c.body) e.noEvent(t) }) diff --git a/test/sqlite_schema_unchanged_test.go b/test/sqlite_schema_unchanged_test.go index cd7cf37..f7db415 100644 --- a/test/sqlite_schema_unchanged_test.go +++ b/test/sqlite_schema_unchanged_test.go @@ -176,7 +176,7 @@ func TestSQLiteSchemaIsUntouchedByTheModelChanges(t *testing.T) { if _, indexed := after.schema["idx_articles_site_slug"]; !indexed { t.Errorf("the unique (site_id, slug) index is missing: %v", after.schema) } - for _, name := range []string{"articles", "idx_articles_site_id", "idx_articles_site_slug", "idx_articles_cover_image_id", "idx_articles_social_image_id", "users", "idx_users_role_id"} { + for _, name := range []string{"articles", "idx_articles_site_id", "idx_articles_site_slug", "idx_articles_cover_image_id", "idx_articles_social_image_id", "users", "idx_users_role_id", "invites", "idx_invites_role_id"} { delete(before.schema, name) delete(after.schema, name) } @@ -193,6 +193,12 @@ func TestSQLiteSchemaIsUntouchedByTheModelChanges(t *testing.T) { for _, row := range after.rows["users"] { delete(row, "role_id") } + for _, row := range after.rows["invites"] { + if row["role_id"] == nil { + t.Errorf("a legacy invite must receive a role_id: %v", row) + } + delete(row, "role_id") + } if !reflect.DeepEqual(before.rows, after.rows) { t.Errorf("rows changed:\nbefore %v\nafter %v", before.rows, after.rows) } @@ -219,7 +225,7 @@ func assertSiteColumnAdded(t *testing.T, db *gorm.DB) { } func isNewTableObject(name string) bool { - for _, prefix := range []string{"audit_", "idx_audit_", "image_variant", "idx_image_variant", "idx_images_path", "idx_sites_key", "sites", "authors", "idx_authors_", "article_authors", "idx_article_authors_", "content_types", "idx_content_types_", "entries", "idx_entries_", "revoked_sessions", "idx_revoked_sessions_", "role", "idx_role"} { + for _, prefix := range []string{"audit_", "idx_audit_", "image_variant", "idx_image_variant", "idx_images_path", "idx_sites_key", "sites", "authors", "idx_authors_", "article_authors", "idx_article_authors_", "content_types", "idx_content_types_", "entries", "idx_entries_", "revoked_sessions", "idx_revoked_sessions_", "migration_markers", "role", "idx_role"} { if strings.HasPrefix(name, prefix) { return true } diff --git a/test/stale_token_invalidation_http_test.go b/test/stale_token_invalidation_http_test.go index 08bca26..41199d7 100644 --- a/test/stale_token_invalidation_http_test.go +++ b/test/stale_token_invalidation_http_test.go @@ -31,7 +31,7 @@ func newStaleTokenInvalidationRouter(userRepo *mockUserRepo, articleRepo *mockAr ) loginHandler := auth.NewLoginHandler( token, - useCases.NewCreateUserUseCase(userRepo, hasher, mailRepo, "https://cms.example.com", discardRecorder), + useCases.NewCreateUserUseCase(userRepo, newSeededMockRoleRepo(), hasher, mailRepo, "https://cms.example.com", discardRecorder), useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder), true, ) diff --git a/test/trusted_proxy_client_ip_for_rate_limiting_http_test.go b/test/trusted_proxy_client_ip_for_rate_limiting_http_test.go index e63d4cf..b0c17a8 100644 --- a/test/trusted_proxy_client_ip_for_rate_limiting_http_test.go +++ b/test/trusted_proxy_client_ip_for_rate_limiting_http_test.go @@ -23,7 +23,7 @@ func newTrustedProxyAuthRouter(t *testing.T, userRepo user.Repository, mailRepo token := authtoken.New([]byte("test-secret")) loginHandler := auth.NewLoginHandler( token, - useCases.NewCreateUserUseCase(userRepo, hasher, mailRepo, "https://cms.example.com", discardRecorder), + useCases.NewCreateUserUseCase(userRepo, newSeededMockRoleRepo(), hasher, mailRepo, "https://cms.example.com", discardRecorder), useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder), true, ) diff --git a/test/unverified_users_and_reregistration_test.go b/test/unverified_users_and_reregistration_test.go index 92766e8..a91fb14 100644 --- a/test/unverified_users_and_reregistration_test.go +++ b/test/unverified_users_and_reregistration_test.go @@ -155,7 +155,7 @@ func newUnverifiedRegistrationRouter(userRepo *mockUserRepo, token *authtoken.Au hasher := hashing.NewBcryptHasher() loginHandler := auth.NewLoginHandler( token, - useCases.NewCreateUserUseCase(userRepo, hasher, newMockMailRepo(), "https://cms.example.com", discardRecorder), + useCases.NewCreateUserUseCase(userRepo, newSeededMockRoleRepo(), hasher, newMockMailRepo(), "https://cms.example.com", discardRecorder), useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder), true, ) diff --git a/test/update_http_test.go b/test/update_http_test.go index 4d26078..5ec5ff9 100644 --- a/test/update_http_test.go +++ b/test/update_http_test.go @@ -39,11 +39,11 @@ type updateHarness struct { func newUpdateUserRepo() *mockUserRepo { users := newMockUserRepo() - users.users[updateOwnerId] = user.User{ID: updateOwnerId, IsVerified: true, Role: user.RoleOwner, TokenVersion: 2} - users.users[updateAdminId] = user.User{ID: updateAdminId, IsVerified: true, Role: user.RoleAdmin} - users.users[updateUnverifiedId] = user.User{ID: updateUnverifiedId, IsVerified: false, Role: user.RoleAdmin} - users.users[updateSecondOwner] = user.User{ID: updateSecondOwner, IsVerified: true, Role: user.RoleOwner} - users.users[updateUnverifiedOwner] = user.User{ID: updateUnverifiedOwner, IsVerified: false, Role: user.RoleOwner} + users.users[updateOwnerId] = user.User{ID: updateOwnerId, IsVerified: true, RoleID: ownerRoleID, TokenVersion: 2} + users.users[updateAdminId] = user.User{ID: updateAdminId, IsVerified: true, RoleID: adminRoleID} + users.users[updateUnverifiedId] = user.User{ID: updateUnverifiedId, IsVerified: false, RoleID: adminRoleID} + users.users[updateSecondOwner] = user.User{ID: updateSecondOwner, IsVerified: true, RoleID: ownerRoleID} + users.users[updateUnverifiedOwner] = user.User{ID: updateUnverifiedOwner, IsVerified: false, RoleID: ownerRoleID} return users } diff --git a/test/use_case_baseline_test.go b/test/use_case_baseline_test.go index 7f0edc2..43c3856 100644 --- a/test/use_case_baseline_test.go +++ b/test/use_case_baseline_test.go @@ -412,7 +412,7 @@ func TestDeleteImageRequiresExistence(t *testing.T) { func TestCreateUserAssignsVerificationCode(t *testing.T) { repo := newMockUserRepo() - uc := useCases.NewCreateUserUseCase(repo, &stubHasher{}, newMockMailRepo(), "https://cms.example.com", discardRecorder) + uc := useCases.NewCreateUserUseCase(repo, newSeededMockRoleRepo(), &stubHasher{}, newMockMailRepo(), "https://cms.example.com", discardRecorder) before := time.Now() result, err := uc.CreateUser(testCtx, useCases.CreateUserCommand{ @@ -459,7 +459,7 @@ func TestCreateUserAssignsVerificationCode(t *testing.T) { func TestCreateUserPropagatesRepositoryFailure(t *testing.T) { repo := newMockUserRepo() repo.fail.failNext("Create") - uc := useCases.NewCreateUserUseCase(repo, &stubHasher{}, newMockMailRepo(), "https://cms.example.com", discardRecorder) + uc := useCases.NewCreateUserUseCase(repo, newSeededMockRoleRepo(), &stubHasher{}, newMockMailRepo(), "https://cms.example.com", discardRecorder) _, err := uc.CreateUser(testCtx, useCases.CreateUserCommand{ Username: "alice", diff --git a/test/user_display_name_test.go b/test/user_display_name_test.go index 85b1fcc..8299823 100644 --- a/test/user_display_name_test.go +++ b/test/user_display_name_test.go @@ -20,7 +20,7 @@ import ( "github.com/go-chi/chi/v5" ) -var displayNameResponseKeys = []string{"id", "username", "email", "is_verified", "created_at", "updated_at", "display_name", "role"} +var displayNameResponseKeys = []string{"id", "username", "email", "is_verified", "created_at", "updated_at", "display_name"} func newDisplayNameRouter(userRepo *mockUserRepo, token *authtoken.Auth) *chi.Mux { hasher := &stubHasher{} @@ -30,7 +30,7 @@ func newDisplayNameRouter(userRepo *mockUserRepo, token *authtoken.Auth) *chi.Mu meHandler := auth.NewMeHandler(getUser, useCases.NewGetMyAuthorUseCase(plainAuthors()), useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo())) loginHandler := auth.NewLoginHandler( token, - useCases.NewCreateUserUseCase(userRepo, hasher, newMockMailRepo(), "https://cms.example.com", discardRecorder), + useCases.NewCreateUserUseCase(userRepo, newSeededMockRoleRepo(), hasher, newMockMailRepo(), "https://cms.example.com", discardRecorder), useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder), true, ) @@ -91,7 +91,7 @@ func seededDisplayNameRepo(displayName string) *mockUserRepo { Email: "marta@renew.example", Password: "hashed:correct-horse", IsVerified: true, - Role: user.RoleOwner, + RoleID: ownerRoleID, DisplayName: displayName, } return repo @@ -393,7 +393,7 @@ func TestUserDisplayNameHttp(t *testing.T) { t.Run("GET /v1/auth/me returns an empty display_name, not a missing key, when none is set", func(t *testing.T) { repo := newMockUserRepo() - repo.users[8] = user.User{ID: 8, Username: "bob", IsVerified: false, Role: user.RoleAdmin} + repo.users[8] = user.User{ID: 8, Username: "bob", IsVerified: false, RoleID: adminRoleID} router := newDisplayNameRouter(repo, token) rec := sendDisplayNameRequest(router, http.MethodGet, "/v1/auth/me", "", displayNameCookie(t, token, 8, 0)) if rec.Code != http.StatusOK { @@ -421,7 +421,7 @@ func TestUserDisplayNameHttp(t *testing.T) { t.Run("An unverified user is refused", func(t *testing.T) { repo := newMockUserRepo() - repo.users[8] = user.User{ID: 8, Username: "bob", IsVerified: false, Role: user.RoleAdmin} + repo.users[8] = user.User{ID: 8, Username: "bob", IsVerified: false, RoleID: adminRoleID} router := newDisplayNameRouter(repo, token) rec := sendDisplayNameRequest(router, http.MethodPatch, "/v1/user/me", `{"display_name": "Bob"}`, displayNameCookie(t, token, 8, 0)) if rec.Code != http.StatusForbidden || rec.Body.String() != apperr.VerificationRequiredErrorMessage+"\n" { diff --git a/test/user_response_field_hygiene_test.go b/test/user_response_field_hygiene_test.go index e930310..0f8f75d 100644 --- a/test/user_response_field_hygiene_test.go +++ b/test/user_response_field_hygiene_test.go @@ -21,7 +21,7 @@ func newUserFieldHygieneRouter(userRepo *mockUserRepo, mailRepo *mockMailRepo, t hasher := &stubHasher{} loginHandler := auth.NewLoginHandler( token, - useCases.NewCreateUserUseCase(userRepo, hasher, mailRepo, "https://cms.example.com", discardRecorder), + useCases.NewCreateUserUseCase(userRepo, newSeededMockRoleRepo(), hasher, mailRepo, "https://cms.example.com", discardRecorder), useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder), true, ) @@ -125,7 +125,7 @@ func TestUserResponseFieldHygiene(t *testing.T) { t.Fatalf("failed to decode response: %v", err) } - expectedKeys := []string{"id", "username", "email", "is_verified", "created_at", "updated_at", "display_name", "role"} + expectedKeys := []string{"id", "username", "email", "is_verified", "created_at", "updated_at", "display_name"} if len(decoded) != len(expectedKeys) { t.Errorf("expected exactly %d keys, got %d: %v", len(expectedKeys), len(decoded), decoded) } diff --git a/test/user_roles_test.go b/test/user_roles_test.go index 7777ffa..85a99be 100644 --- a/test/user_roles_test.go +++ b/test/user_roles_test.go @@ -7,20 +7,16 @@ import ( "RenewCMS/api/pkg/apperr" validator "RenewCMS/api/pkg/validator" "RenewCMS/internal/domain/user" - "RenewCMS/internal/infrastructure/persistence/database" - entity "RenewCMS/internal/infrastructure/persistence/models" "RenewCMS/internal/infrastructure/useCases" "bytes" + "encoding/json" "net/http" "net/http/httptest" "testing" "time" "RenewCMS/api/pkg/authtoken" - "github.com/glebarez/sqlite" "github.com/go-chi/chi/v5" - "gorm.io/gorm" - "gorm.io/gorm/logger" ) const ( @@ -50,22 +46,22 @@ func newUserRolesFixture(t *testing.T, maxAttempts int) *userRolesFixture { getUser := useCases.NewGetUserUseCase(f.users) loginHandler := auth.NewLoginHandler( f.token, - useCases.NewCreateUserUseCase(f.users, hasher, f.mail, invitePublicURL, discardRecorder), + useCases.NewCreateUserUseCase(f.users, newSeededMockRoleRepo(), hasher, f.mail, invitePublicURL, discardRecorder), useCases.NewAuthenticateUserUseCase(f.users, hasher, discardRecorder), true, ) acceptHandler := auth.NewAcceptInviteHandler( useCases.NewGetInviteUseCase(f.invites, hasher), - useCases.NewAcceptInviteUseCase(f.users, f.invites, hasher, discardRecorder), + useCases.NewAcceptInviteUseCase(f.users, f.invites, newSeededMockRoleRepo(), hasher, discardRecorder), f.token, true, ) meHandler := auth.NewMeHandler(getUser, useCases.NewGetMyAuthorUseCase(plainAuthors()), useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo())) inviteHandler := userhandler.NewInviteHandler( - useCases.NewCreateInviteUseCase(f.users, f.invites, hasher, f.mail, invitePublicURL, discardRecorder), + useCases.NewCreateInviteUseCase(f.users, f.invites, newSeededMockRoleRepo(), newMockContentTypeRepo(), hasher, f.mail, invitePublicURL, discardRecorder), nil, nil, - useCases.NewListUsersAndInvitesUseCase(f.users, f.invites), + useCases.NewListUsersAndInvitesUseCase(f.users, f.invites, newSeededMockRoleRepo()), ) f.router = chi.NewRouter() @@ -95,13 +91,13 @@ func newUserRolesFixture(t *testing.T, maxAttempts int) *userRolesFixture { return f } -func (f *userRolesFixture) seedUser(id uint32, username string, role user.Role, verified bool) { +func (f *userRolesFixture) seedUser(id uint32, username string, roleID uint32, verified bool) { f.users.users[id] = user.User{ ID: id, Username: username, Email: username + "@renew.example", IsVerified: verified, - Role: role, + RoleID: roleID, } if id > f.users.nextID { f.users.nextID = id @@ -109,10 +105,7 @@ func (f *userRolesFixture) seedUser(id uint32, username string, role user.Role, } func (f *userRolesFixture) seedWriter(id uint32, username string) { - f.seedUser(id, username, "", true) - stored := f.users.users[id] - stored.RoleID = writerRoleID - f.users.users[id] = stored + f.seedUser(id, username, writerRoleID, true) } func (f *userRolesFixture) do(t *testing.T, method, target, body string, cookie *http.Cookie) *httptest.ResponseRecorder { @@ -155,17 +148,18 @@ func TestUserRolesHttp(t *testing.T) { t.Fatalf("expected one user, got %d", len(f.users.users)) } for _, stored := range f.users.users { - if stored.Username != "marta" || stored.Role != user.RoleOwner { + if stored.Username != "marta" || stored.RoleID != ownerRoleID { t.Errorf("unexpected user %+v", stored) } } }) - t.Run("A user created by accepting an invite is an admin", func(t *testing.T) { + t.Run("A user created by accepting an invite takes the role of the invite", func(t *testing.T) { f := newUserRolesFixture(t, 100) - f.seedUser(1, "marta", user.RoleOwner, true) + f.seedUser(1, "marta", ownerRoleID, true) pending := pendingInvite(acceptEmail) pending.HashedCode = "hashed:" + acceptCode + pending.RoleID = adminRoleID f.invites.seed(pending) rec := f.do(t, http.MethodPost, "/v1/auth/invite/accept", acceptBody(acceptCode, "lea", acceptPassword), nil) @@ -173,23 +167,24 @@ func TestUserRolesHttp(t *testing.T) { if rec.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) } - roles := map[string]user.Role{} + roles := map[string]uint32{} for _, stored := range f.users.users { - roles[stored.Username] = stored.Role + roles[stored.Username] = stored.RoleID } - if roles["lea"] != user.RoleAdmin { - t.Errorf("expected lea admin, got %q", roles["lea"]) + if roles["lea"] != adminRoleID { + t.Errorf("expected lea admin, got %d", roles["lea"]) } - if roles["marta"] != user.RoleOwner { - t.Errorf("expected marta still owner, got %q", roles["marta"]) + if roles["marta"] != ownerRoleID { + t.Errorf("expected marta still owner, got %d", roles["marta"]) } }) for _, tc := range []struct { username string - role user.Role - }{{"marta", user.RoleOwner}, {"lea", user.RoleAdmin}} { - t.Run("GET /v1/auth/me returns the role of the session user: "+tc.username, func(t *testing.T) { + role uint32 + name string + }{{"marta", ownerRoleID, "Owner"}, {"lea", adminRoleID, "Admin"}} { + t.Run("GET /v1/auth/me returns the role of the session user only in access.role: "+tc.username, func(t *testing.T) { f := newUserRolesFixture(t, 100) f.seedUser(4, tc.username, tc.role, true) @@ -198,15 +193,24 @@ func TestUserRolesHttp(t *testing.T) { if rec.Code != http.StatusOK { t.Fatalf("expected 200, got %d", rec.Code) } - if got := decodedString(t, decodeKeys(t, rec.Body.Bytes()), "role"); got != string(tc.role) { - t.Errorf("expected role %q, got %q", tc.role, got) + decoded := decodeKeys(t, rec.Body.Bytes()) + if _, present := decoded["role"]; present { + t.Errorf("no top-level role expected: %s", rec.Body.String()) + } + var access struct { + Role struct { + Name string `json:"name"` + } `json:"role"` + } + if err := json.Unmarshal(decoded["access"], &access); err != nil || access.Role.Name != tc.name { + t.Errorf("expected access.role %q, got %q (%v)", tc.name, access.Role.Name, err) } }) } - t.Run("An unverified owner sees their role on /auth/me", func(t *testing.T) { + t.Run("An unverified owner can read /auth/me", func(t *testing.T) { f := newUserRolesFixture(t, 100) - f.seedUser(5, "bob", user.RoleOwner, false) + f.seedUser(5, "bob", ownerRoleID, false) rec := f.do(t, http.MethodGet, "/v1/auth/me", "", f.as(t, 5)) @@ -214,7 +218,7 @@ func TestUserRolesHttp(t *testing.T) { t.Fatalf("expected 200, got %d", rec.Code) } decoded := decodeKeys(t, rec.Body.Bytes()) - if decodedString(t, decoded, "role") != "owner" || string(decoded["is_verified"]) != "false" { + if string(decoded["is_verified"]) != "false" { t.Errorf("unexpected body %s", rec.Body.String()) } }) @@ -222,9 +226,9 @@ func TestUserRolesHttp(t *testing.T) { t.Run("The users list carries email and role on accepted users", func(t *testing.T) { f := newUserRolesFixture(t, 100) day := func(d int) time.Time { return time.Date(2026, 3, d, 9, 0, 0, 0, time.UTC) } - f.users.users[1] = user.User{ID: 1, Username: "marta", Email: "marta@renew.example", IsVerified: true, Role: user.RoleOwner, CreatedAt: day(1)} - f.users.users[2] = user.User{ID: 2, Username: "jonas", Email: "jonas@renew.example", IsVerified: true, Role: user.RoleAdmin, CreatedAt: day(2)} - f.users.users[3] = user.User{ID: 3, Username: "ines", DisplayName: "Inès Duarte", Email: "ines.duarte@renew.example", IsVerified: true, Role: user.RoleAdmin, CreatedAt: day(3)} + f.users.users[1] = user.User{ID: 1, Username: "marta", Email: "marta@renew.example", IsVerified: true, RoleID: ownerRoleID, CreatedAt: day(1)} + f.users.users[2] = user.User{ID: 2, Username: "jonas", Email: "jonas@renew.example", IsVerified: true, RoleID: adminRoleID, CreatedAt: day(2)} + f.users.users[3] = user.User{ID: 3, Username: "ines", DisplayName: "Inès Duarte", Email: "ines.duarte@renew.example", IsVerified: true, RoleID: adminRoleID, CreatedAt: day(3)} f.invites.seed(pendingInvite("sam.oliveira@renew.example")) rec := f.do(t, http.MethodGet, "/v1/user/", "", f.as(t, 2)) @@ -237,18 +241,21 @@ func TestUserRolesHttp(t *testing.T) { t.Fatalf("expected 4 items, got %d", len(items)) } wantRoles := map[string][2]string{ - "marta": {"marta@renew.example", "owner"}, - "jonas": {"jonas@renew.example", "admin"}, - "ines": {"ines.duarte@renew.example", "admin"}, + "marta": {"marta@renew.example", "Owner"}, + "jonas": {"jonas@renew.example", "Admin"}, + "ines": {"ines.duarte@renew.example", "Admin"}, } for _, item := range items { if decodedString(t, item, "kind") == "invite" { - assertExactKeys(t, item, "id", "kind", "email", "status", "created_at") + assertExactKeys(t, item, "id", "kind", "email", "role", "status", "created_at") continue } assertExactKeys(t, item, "id", "kind", "username", "display_name", "email", "role", "status", "created_at") want := wantRoles[decodedString(t, item, "username")] - if decodedString(t, item, "email") != want[0] || decodedString(t, item, "role") != want[1] { + var ref struct { + Name string `json:"name"` + } + if err := json.Unmarshal(item["role"], &ref); err != nil || decodedString(t, item, "email") != want[0] || ref.Name != want[1] { t.Errorf("unexpected user item %v", item) } } @@ -263,7 +270,7 @@ func TestUserRolesHttp(t *testing.T) { t.Run("The owner can invite", func(t *testing.T) { f := newUserRolesFixture(t, 100) - f.seedUser(1, "marta", user.RoleOwner, true) + f.seedUser(1, "marta", ownerRoleID, true) rec := f.do(t, http.MethodPost, "/v1/user/invite", rolesInviteBody, f.as(t, 1)) @@ -277,7 +284,7 @@ func TestUserRolesHttp(t *testing.T) { t.Run("An admin can invite", func(t *testing.T) { f := newUserRolesFixture(t, 100) - f.seedUser(2, "jonas", user.RoleAdmin, true) + f.seedUser(2, "jonas", adminRoleID, true) rec := f.do(t, http.MethodPost, "/v1/user/invite", rolesInviteBody, f.as(t, 2)) @@ -329,7 +336,7 @@ func TestUserRolesHttp(t *testing.T) { t.Run("The role is read on every request, not from the token", func(t *testing.T) { f := newUserRolesFixture(t, 100) - f.seedUser(2, "jonas", user.RoleOwner, true) + f.seedUser(2, "jonas", ownerRoleID, true) cookie := f.as(t, 2) stored := f.users.users[2] stored.RoleID = writerRoleID @@ -344,7 +351,7 @@ func TestUserRolesHttp(t *testing.T) { t.Run("An admin can still list users", func(t *testing.T) { f := newUserRolesFixture(t, 100) - f.seedUser(2, "jonas", user.RoleAdmin, true) + f.seedUser(2, "jonas", adminRoleID, true) rec := f.do(t, http.MethodGet, "/v1/user/", "", f.as(t, 2)) @@ -355,7 +362,7 @@ func TestUserRolesHttp(t *testing.T) { t.Run("A jwt for a since-deleted account is refused with 401 on invite", func(t *testing.T) { f := newUserRolesFixture(t, 100) - f.seedUser(1, "marta", user.RoleOwner, true) + f.seedUser(1, "marta", ownerRoleID, true) cookie := f.as(t, 1) delete(f.users.users, 1) @@ -370,34 +377,35 @@ func TestUserRolesHttp(t *testing.T) { func TestUserRolesUseCases(t *testing.T) { t.Run("CreateUser returns an owner", func(t *testing.T) { users := newMockUserRepo() - uc := useCases.NewCreateUserUseCase(users, &stubHasher{}, newMockMailRepo(), invitePublicURL, discardRecorder) + uc := useCases.NewCreateUserUseCase(users, newSeededMockRoleRepo(), &stubHasher{}, newMockMailRepo(), invitePublicURL, discardRecorder) created, err := uc.CreateUser(testCtx, useCases.CreateUserCommand{Username: "marta", Password: "hunter2example12", Email: "marta@renew.example"}) if err != nil { t.Fatalf("unexpected error: %v", err) } - if created.Role != user.RoleOwner { - t.Errorf("expected owner, got %q", created.Role) + if created.RoleID != ownerRoleID { + t.Errorf("expected owner, got %d", created.RoleID) } }) - t.Run("AcceptInvite returns an admin", func(t *testing.T) { + t.Run("AcceptInvite returns a user with the role of the invite", func(t *testing.T) { users := newMockUserRepo() invites := newInviteFakeRepo() invites.users = users pending := pendingInvite(acceptEmail) pending.HashedCode = "hashed:" + acceptCode + pending.RoleID = adminRoleID invites.seed(pending) - uc := useCases.NewAcceptInviteUseCase(users, invites, &stubHasher{}, discardRecorder) + uc := useCases.NewAcceptInviteUseCase(users, invites, newSeededMockRoleRepo(), &stubHasher{}, discardRecorder) created, err := uc.AcceptInvite(testCtx, useCases.AcceptInviteCommand{Code: acceptCode, Username: "lea", Password: acceptPassword}) if err != nil { t.Fatalf("unexpected error: %v", err) } - if created.Role != user.RoleAdmin { - t.Errorf("expected admin, got %q", created.Role) + if created.RoleID != adminRoleID { + t.Errorf("expected admin, got %d", created.RoleID) } }) } @@ -406,7 +414,7 @@ func TestRequirePermissionRepositoryFailure(t *testing.T) { t.Run("An unexpected repository error is a 500, not a 403", func(t *testing.T) { token := authtoken.New([]byte("test-secret")) repo := newMockUserRepo() - repo.users[1] = user.User{ID: 1, Username: "marta", IsVerified: true, Role: user.RoleOwner} + repo.users[1] = user.User{ID: 1, Username: "marta", IsVerified: true, RoleID: ownerRoleID} r := chi.NewRouter() r.Use(authtoken.Verifier(token)) r.Use(authtoken.Authenticator) @@ -424,81 +432,3 @@ func TestRequirePermissionRepositoryFailure(t *testing.T) { } }) } - -func TestBackfillUserRoles(t *testing.T) { - openDB := func(t *testing.T) *gorm.DB { - t.Helper() - db, err := gorm.Open(sqlite.Open("file:roles?mode=memory&cache=private"), &gorm.Config{Logger: logger.Discard}) - if err != nil { - t.Fatalf("open: %v", err) - } - if err := db.AutoMigrate(&entity.User{}); err != nil { - t.Fatalf("migrate: %v", err) - } - t.Cleanup(func() { - if sqlDB, err := db.DB(); err == nil { - _ = sqlDB.Close() - } - }) - return db - } - rolesByID := func(t *testing.T, db *gorm.DB) map[uint32]string { - t.Helper() - var rows []entity.User - if err := db.Find(&rows).Error; err != nil { - t.Fatalf("find: %v", err) - } - roles := map[uint32]string{} - for _, row := range rows { - roles[row.ID] = row.Role - } - return roles - } - seed := func(t *testing.T, db *gorm.DB, id uint32, role string) { - t.Helper() - suffix := string(rune('a' + id)) - row := entity.User{ID: id, Username: "u" + suffix, Password: "x", Email: "e" + suffix, Role: role} - if err := db.Create(&row).Error; err != nil { - t.Fatalf("seed: %v", err) - } - } - - t.Run("The lowest id becomes owner and every other user admin", func(t *testing.T) { - db := openDB(t) - seed(t, db, 3, "") - seed(t, db, 5, "") - seed(t, db, 9, "") - - if err := database.BackfillUserRoles(db); err != nil { - t.Fatalf("backfill: %v", err) - } - - roles := rolesByID(t, db) - if roles[3] != "owner" || roles[5] != "admin" || roles[9] != "admin" { - t.Errorf("unexpected roles %v", roles) - } - }) - - t.Run("A later start changes nothing", func(t *testing.T) { - db := openDB(t) - seed(t, db, 2, "admin") - seed(t, db, 4, "owner") - - if err := database.BackfillUserRoles(db); err != nil { - t.Fatalf("backfill: %v", err) - } - - roles := rolesByID(t, db) - if roles[2] != "admin" || roles[4] != "owner" { - t.Errorf("unexpected roles %v", roles) - } - }) - - t.Run("An empty database is left alone", func(t *testing.T) { - db := openDB(t) - - if err := database.BackfillUserRoles(db); err != nil { - t.Fatalf("backfill: %v", err) - } - }) -} diff --git a/test/user_test.go b/test/user_test.go index 66ddf24..60ff58b 100644 --- a/test/user_test.go +++ b/test/user_test.go @@ -1,6 +1,7 @@ package test import ( + "RenewCMS/internal/domain/role" "RenewCMS/internal/domain/user" "RenewCMS/internal/infrastructure/useCases" "errors" @@ -47,6 +48,13 @@ type mockUserRepo struct { lastResetPasswordHash string updateDisplayNameCalls int lastUpdateDisplayName string + getAllCalls int + updateRoleCalls int + transferCalls int + beforeUpdateRole func() + beforeTransfer func() + beforeClearVerification func() + knownRoles *mockRoleRepo } func newMockUserRepo() *mockUserRepo { @@ -106,6 +114,7 @@ func (m *mockUserRepo) GetByEmail(email string) (user.User, error) { } func (m *mockUserRepo) GetAll() []user.User { + m.getAllCalls++ result := make([]user.User, 0) for _, id := range m.sortedIds() { result = append(result, m.users[id]) @@ -198,10 +207,22 @@ func (m *mockUserRepo) ClearVerificationCode(userId uint32) (user.User, error) { if err := m.fail.trigger("ClearVerificationCode"); err != nil { return user.User{}, err } + if m.beforeClearVerification != nil { + hook := m.beforeClearVerification + m.beforeClearVerification = nil + hook() + } u, ok := m.users[userId] if !ok { return user.User{}, user.ErrUserNotFound } + if u.RoleID == ownerRoleID { + for id, other := range m.users { + if id != userId && other.RoleID == ownerRoleID && other.IsVerified { + return user.User{}, user.ErrInvalidVerificationCode + } + } + } u.IsVerified = true u.VerificationCode = "" m.users[userId] = u @@ -242,11 +263,69 @@ func (m *mockUserRepo) UpdateDisplayName(userId uint32, displayName string) (use return u, nil } +func (m *mockUserRepo) roleExists(id uint32) bool { + if m.knownRoles == nil { + return true + } + _, present := m.knownRoles.roles[id] + return present +} + +func (m *mockUserRepo) UpdateRole(id uint32, from uint32, to uint32) (user.User, error) { + m.updateRoleCalls++ + if err := m.fail.trigger("UpdateRole"); err != nil { + return user.User{}, err + } + if m.beforeUpdateRole != nil { + hook := m.beforeUpdateRole + m.beforeUpdateRole = nil + hook() + } + stored, ok := m.users[id] + if !ok { + return user.User{}, user.ErrUserNotFound + } + if !m.roleExists(to) { + return user.User{}, role.ErrRoleNotFound + } + if stored.RoleID != from { + return user.User{}, user.ErrRoleChanged + } + stored.RoleID = to + m.users[id] = stored + return stored, nil +} + +func (m *mockUserRepo) TransferOwnership(from uint32, to uint32, ownerRoleID uint32, formerRoleID uint32) error { + m.transferCalls++ + if err := m.fail.trigger("TransferOwnership"); err != nil { + return err + } + if m.beforeTransfer != nil { + hook := m.beforeTransfer + m.beforeTransfer = nil + hook() + } + former, formerFound := m.users[from] + if !formerFound || former.RoleID != ownerRoleID { + return user.ErrRoleChanged + } + successor, successorFound := m.users[to] + if !successorFound || !successor.IsVerified { + return user.ErrInvalidTransfer + } + former.RoleID = formerRoleID + successor.RoleID = ownerRoleID + m.users[from] = former + m.users[to] = successor + return nil +} + var _ user.Repository = &mockUserRepo{} func TestCreateUser(t *testing.T) { repo := newMockUserRepo() - uc := useCases.NewCreateUserUseCase(repo, &stubHasher{}, newMockMailRepo(), "https://cms.example.com", discardRecorder) + uc := useCases.NewCreateUserUseCase(repo, newSeededMockRoleRepo(), &stubHasher{}, newMockMailRepo(), "https://cms.example.com", discardRecorder) cmd := useCases.CreateUserCommand{ Username: "testuser", diff --git a/test/users_api_domain_test.go b/test/users_api_domain_test.go new file mode 100644 index 0000000..d144274 --- /dev/null +++ b/test/users_api_domain_test.go @@ -0,0 +1,152 @@ +package test + +import ( + "RenewCMS/internal/domain/builtintype" + "RenewCMS/internal/domain/contenttype" + "RenewCMS/internal/domain/invite" + "RenewCMS/internal/domain/role" + "RenewCMS/internal/domain/user" + "errors" + "go/ast" + "go/token" + "path/filepath" + "reflect" + "strconv" + "strings" + "testing" +) + +func TestUsersAPIDomain(t *testing.T) { + t.Run("user.User has no role string and invite.Invite carries RoleID", func(t *testing.T) { + if _, present := reflect.TypeOf(user.User{}).FieldByName("Role"); present { + t.Errorf("user.User.Role must not exist") + } + if field, present := reflect.TypeOf(invite.Invite{}).FieldByName("RoleID"); !present || field.Type.Kind() != reflect.Uint32 { + t.Errorf("invite.Invite.RoleID is missing") + } + }) + + t.Run("the legacy role vocabulary is gone from the source tree", func(t *testing.T) { + for _, path := range nonTestGoFiles(t, "../internal", "../api", "../cmd") { + ast.Inspect(parsedGoFile(t, path), func(node ast.Node) bool { + switch found := node.(type) { + case *ast.SelectorExpr: + if owner, ok := found.X.(*ast.Ident); ok && (owner.Name == "user" || owner.Name == "domainUser") && (found.Sel.Name == "Role" || found.Sel.Name == "RoleOwner" || found.Sel.Name == "RoleAdmin") { + t.Errorf("%s reads %s.%s", path, owner.Name, found.Sel.Name) + } + case *ast.Ident: + if found.Name == "legacyBuiltInNames" || found.Name == "legacyRoleNames" || found.Name == "BackfillUserRoleIDs" { + t.Errorf("%s names %s", path, found.Name) + } + } + return true + }) + } + }) + + t.Run("the new sentinels map with errors.Is", func(t *testing.T) { + wrapped := errors.Join(errors.New("context"), user.ErrRoleChanged, user.ErrInvalidTransfer, user.ErrConfirmationMismatch, role.ErrUnknownRole) + for _, sentinel := range []error{user.ErrRoleChanged, user.ErrInvalidTransfer, user.ErrConfirmationMismatch, role.ErrUnknownRole} { + if !errors.Is(wrapped, sentinel) { + t.Errorf("%v is not matched", sentinel) + } + } + }) + + t.Run("user and invite import the standard library and domain packages only", func(t *testing.T) { + for _, pattern := range []string{"../internal/domain/user/*.go", "../internal/domain/invite/*.go"} { + files, _ := filepath.Glob(pattern) + if len(files) == 0 { + t.Fatalf("no files for %s", pattern) + } + for _, file := range files { + for _, imported := range importsOf(t, strings.TrimPrefix(filepath.ToSlash(file), "../")) { + if !isStandardLibrary(imported) && !strings.HasPrefix(imported, "RenewCMS/internal/domain/") { + t.Errorf("%s imports %s", file, imported) + } + } + } + } + }) + + t.Run("GrantSet.Covers rows used by the slice", func(t *testing.T) { + manager := unlockedGrantSet("content.read=all", "content.create=all", "content.update=own", "content.publish=own", "schema.view=all") + cases := []struct { + name string + grants []string + key string + }{ + {"Writer", []string{"content.read=all", "content.create=all", "content.update=all"}, "content.update"}, + {"Content lead", []string{"content.read=all", "content.update=all", "content.publish=all"}, "content.update"}, + {"Intern", []string{"content.read=own"}, ""}, + {"Contributor", []string{"content.read=all", "content.create=all", "content.update=own"}, ""}, + } + for _, row := range cases { + err := manager.Covers(unlockedGrantSet(row.grants...), nil) + if row.key == "" && err != nil { + t.Errorf("%s: unexpected %v", row.name, err) + } + var escalation role.EscalationError + if row.key != "" && (!errors.As(err, &escalation) || escalation.Key != row.key) { + t.Errorf("%s: expected an escalation on %s, got %v", row.name, row.key, err) + } + } + }) + + t.Run("the builtin user type keeps an optional plain text role field", func(t *testing.T) { + found, err := builtintype.Find(builtintype.KeyUser) + if err != nil { + t.Fatal(err) + } + for _, field := range found.Fields { + if field.Key != "role" { + continue + } + if field.Type != contenttype.TypeText || field.Required || len(field.Options) != 0 { + t.Errorf("unexpected role field %+v", field) + } + return + } + t.Errorf("the user type has no role field") + }) + + t.Run("api/router.go uses the key constants for the new routes", func(t *testing.T) { + wanted := map[string]bool{"KeyUsersChangeRole": false, "KeySystemTransferOwnership": false} + ast.Inspect(parsedGoFile(t, "../api/router.go"), func(node ast.Node) bool { + if selector, ok := node.(*ast.SelectorExpr); ok { + if _, tracked := wanted[selector.Sel.Name]; tracked { + wanted[selector.Sel.Name] = true + } + } + if literal, ok := node.(*ast.BasicLit); ok && literal.Kind == token.STRING { + if value, err := strconv.Unquote(literal.Value); err == nil && (value == "users.change_role" || value == "system.transfer_ownership") { + t.Errorf("router holds the literal %q", value) + } + } + return true + }) + for name, seen := range wanted { + if !seen { + t.Errorf("router does not use role.%s", name) + } + } + }) + + t.Run("handlers of the slice import no persistence, mailer, models or gorm", func(t *testing.T) { + for _, pattern := range []string{"../api/handlers/user/*.go", "../api/handlers/ownership/*.go"} { + files, _ := filepath.Glob(pattern) + if len(files) == 0 { + t.Fatalf("no files for %s", pattern) + } + for _, file := range files { + for _, imported := range importsOf(t, strings.TrimPrefix(filepath.ToSlash(file), "../")) { + for _, forbidden := range []string{"persistence", "mailer", "models", "gorm"} { + if strings.Contains(imported, forbidden) { + t.Errorf("%s imports %s", file, imported) + } + } + } + } + } + }) +} diff --git a/test/users_api_hardening_test.go b/test/users_api_hardening_test.go new file mode 100644 index 0000000..39cbc39 --- /dev/null +++ b/test/users_api_hardening_test.go @@ -0,0 +1,311 @@ +package test + +import ( + "RenewCMS/internal/domain/invite" + "RenewCMS/internal/domain/role" + "RenewCMS/internal/domain/user" + "RenewCMS/internal/infrastructure/persistence/database" + "RenewCMS/internal/infrastructure/persistence/models" + persistence "RenewCMS/internal/infrastructure/persistence/repositories" + "RenewCMS/internal/infrastructure/useCases" + "errors" + "sync" + "testing" + "time" +) + +func TestUsersAPIOwnerVerificationUseCase(t *testing.T) { + newRepo := func() *mockUserRepo { + repo := newMockUserRepo() + for _, id := range []uint32{1, 2} { + repo.users[id] = user.User{ID: id, RoleID: ownerRoleID, VerificationCode: "hashed:code", VerificationExpiration: time.Now().Add(time.Hour)} + } + return repo + } + + t.Run("the second Owner cannot be verified while a verified Owner exists", func(t *testing.T) { + repo := newRepo() + verifier := useCases.NewVerifyUserUseCase(repo, &stubHasher{}, discardRecorder) + if _, err := verifier.Verify(testCtx, 1, "code"); err != nil { + t.Fatal(err) + } + if _, err := verifier.Verify(testCtx, 2, "code"); !errors.Is(err, user.ErrInvalidVerificationCode) { + t.Fatalf("expected ErrInvalidVerificationCode, got %v", err) + } + if repo.users[2].IsVerified || !repo.users[1].IsVerified { + t.Errorf("exactly the first Owner must be verified") + } + }) + + t.Run("a rival verified in between wins and the late one is refused", func(t *testing.T) { + repo := newRepo() + verifier := useCases.NewVerifyUserUseCase(repo, &stubHasher{}, discardRecorder) + repo.beforeClearVerification = func() { + rival := repo.users[2] + rival.IsVerified = true + repo.users[2] = rival + } + if _, err := verifier.Verify(testCtx, 1, "code"); !errors.Is(err, user.ErrInvalidVerificationCode) { + t.Fatalf("expected ErrInvalidVerificationCode, got %v", err) + } + if repo.users[1].IsVerified { + t.Errorf("the late verification must not stand") + } + }) +} + +func TestUsersAPIInviteRoleLockedUseCases(t *testing.T) { + t.Run("accepting an invite that points at the Owner role is invalid", func(t *testing.T) { + w := newUsersAPIWorld(t) + pending := w.invites.invites[3] + pending.RoleID = w.id("Owner") + w.invites.invites[3] = pending + users := len(w.users.users) + _, err := w.container.AcceptInviteUseCase.AcceptInvite(testCtx, useCases.AcceptInviteCommand{Code: "code3", Username: "kim", Password: "password1"}) + if !errors.Is(err, invite.ErrInviteInvalid) { + t.Fatalf("expected ErrInviteInvalid, got %v", err) + } + if len(w.users.users) != users || w.invites.invites[3].AcceptedAt != nil { + t.Errorf("no user may be created and the invite stays open") + } + }) + + t.Run("renewing an invite that points at the Owner role is refused", func(t *testing.T) { + w := newUsersAPIWorld(t) + pending := w.invites.invites[3] + pending.RoleID = w.id("Owner") + w.invites.invites[3] = pending + _, err := w.container.RenewInviteUseCase.RenewInvite(testCtx, useCases.RenewInviteCommand{Caller: w.caller(uaOwner), ID: 3, RenewedBy: uaOwner}) + if !errors.Is(err, role.ErrRoleLocked) { + t.Fatalf("expected ErrRoleLocked, got %v", err) + } + if w.invites.invites[3].HashedCode != pending.HashedCode || len(w.mail.SentEmails) != 0 { + t.Errorf("nothing may be renewed or mailed") + } + }) + + t.Run("renewing an invite whose role was deleted is an unknown role", func(t *testing.T) { + w := newUsersAPIWorld(t) + pending := w.invites.invites[3] + pending.RoleID = 99 + w.invites.invites[3] = pending + _, err := w.container.RenewInviteUseCase.RenewInvite(testCtx, useCases.RenewInviteCommand{Caller: w.caller(uaAdmin), ID: 3, RenewedBy: uaAdmin}) + if !errors.Is(err, role.ErrUnknownRole) { + t.Fatalf("expected ErrUnknownRole, got %v", err) + } + }) +} + +func TestUsersAPIOwnerVerificationRepository(t *testing.T) { + t.Run("the second Owner is refused and stays unverified", func(t *testing.T) { + db := openMigratedRolesDB(t) + repo := persistence.NewUserRepository(db) + owner := storedRole(t, db, "Owner").ID + uaPersistedUser(t, db, 1, &owner, false) + uaPersistedUser(t, db, 2, &owner, false) + if _, err := repo.ClearVerificationCode(1); err != nil { + t.Fatal(err) + } + if _, err := repo.ClearVerificationCode(2); !errors.Is(err, user.ErrInvalidVerificationCode) { + t.Fatalf("expected ErrInvalidVerificationCode, got %v", err) + } + if userRow(t, db, 2).IsVerified || !userRow(t, db, 1).IsVerified { + t.Errorf("exactly one Owner must be verified") + } + }) + + t.Run("other roles verify freely and a missing user is not found", func(t *testing.T) { + db := openMigratedRolesDB(t) + repo := persistence.NewUserRepository(db) + admin := storedRole(t, db, "Admin").ID + uaPersistedUser(t, db, 1, &admin, true) + uaPersistedUser(t, db, 2, &admin, false) + uaPersistedUser(t, db, 3, nil, false) + for _, id := range []uint32{2, 3} { + if _, err := repo.ClearVerificationCode(id); err != nil || !userRow(t, db, id).IsVerified { + t.Fatalf("user %d: %v", id, err) + } + } + if _, err := repo.ClearVerificationCode(99); !errors.Is(err, user.ErrUserNotFound) { + t.Errorf("expected ErrUserNotFound, got %v", err) + } + }) + + t.Run("a verified Owner can be verified again", func(t *testing.T) { + db := openMigratedRolesDB(t) + repo := persistence.NewUserRepository(db) + owner := storedRole(t, db, "Owner").ID + uaPersistedUser(t, db, 1, &owner, true) + if _, err := repo.ClearVerificationCode(1); err != nil { + t.Fatal(err) + } + }) + + t.Run("two concurrent verifications leave one winner", func(t *testing.T) { + db := openMigratedRolesDB(t) + repo := persistence.NewUserRepository(db) + owner := storedRole(t, db, "Owner").ID + uaPersistedUser(t, db, 1, &owner, false) + uaPersistedUser(t, db, 2, &owner, false) + var group sync.WaitGroup + for _, id := range []uint32{1, 2} { + group.Add(1) + go func() { + defer group.Done() + _, _ = repo.ClearVerificationCode(id) + }() + } + group.Wait() + var verified int64 + if err := db.Model(&models.User{}).Where("role_id = ? AND is_verified = ?", owner, true).Count(&verified).Error; err != nil { + t.Fatal(err) + } + if verified != 1 { + t.Errorf("expected exactly one verified Owner, got %d", verified) + } + }) + + t.Run("a closed database fails", func(t *testing.T) { + db := openMigratedRolesDB(t) + repo := persistence.NewUserRepository(db) + sqlDB, _ := db.DB() + _ = sqlDB.Close() + if _, err := repo.ClearVerificationCode(1); err == nil { + t.Errorf("expected an error") + } + }) +} + +func TestUsersAPITransferToSelf(t *testing.T) { + db := openMigratedRolesDB(t) + repo := persistence.NewUserRepository(db) + owner, admin := storedRole(t, db, "Owner").ID, storedRole(t, db, "Admin").ID + uaPersistedUser(t, db, 3, &owner, true) + if err := repo.TransferOwnership(3, 3, owner, admin); !errors.Is(err, user.ErrInvalidTransfer) { + t.Fatalf("expected ErrInvalidTransfer, got %v", err) + } + if roleIDOf(userRow(t, db, 3)) != owner { + t.Errorf("the owner must stay") + } +} + +func TestUsersAPIMigrationRerun(t *testing.T) { + t.Run("several legacy owners keep the lowest id as Owner and the rest become Admin", func(t *testing.T) { + db := legacyUsersDB(t, map[uint32]string{4: "owner", 2: "owner", 9: "owner", 3: "admin"}) + if err := database.Migrate(db, migrationPublicURL); err != nil { + t.Fatal(err) + } + expect := map[uint32]string{2: "Owner", 4: "Admin", 9: "Admin", 3: "Admin"} + for id, name := range expect { + if roleIDOf(userRow(t, db, id)) != storedRole(t, db, name).ID { + t.Errorf("user %d: expected %s", id, name) + } + } + }) + + t.Run("a legacy owner is not displaced by a lower empty-string user", func(t *testing.T) { + db := legacyUsersDB(t, map[uint32]string{1: "", 5: "owner"}) + if err := database.Migrate(db, migrationPublicURL); err != nil { + t.Fatal(err) + } + if roleIDOf(userRow(t, db, 5)) != storedRole(t, db, "Owner").ID || roleIDOf(userRow(t, db, 1)) != storedRole(t, db, "Admin").ID { + t.Errorf("exactly one Owner is expected, user 5") + } + }) + + t.Run("users and invites created after the upgrade are never promoted by a later Migrate", func(t *testing.T) { + db := legacyUsersDB(t, map[uint32]string{2: "owner"}) + pending := uaPersistedInvite(t, db, "legacy@x.example", nil, false, false) + if err := database.Migrate(db, migrationPublicURL); err != nil { + t.Fatal(err) + } + uaPersistedUser(t, db, 20, nil, true) + late := uaPersistedInvite(t, db, "late@x.example", nil, false, false) + if err := database.Migrate(db, migrationPublicURL); err != nil { + t.Fatal(err) + } + if userRow(t, db, 20).RoleID != nil { + t.Errorf("the post-upgrade user must stay unassigned") + } + var stored models.Invite + if err := db.First(&stored, late.ID).Error; err != nil || stored.RoleID != nil { + t.Errorf("the post-upgrade invite must stay unassigned, got %v %v", stored.RoleID, err) + } + var legacy models.Invite + if err := db.First(&legacy, pending.ID).Error; err != nil || legacy.RoleID == nil || *legacy.RoleID != storedRole(t, db, "Admin").ID { + t.Errorf("the legacy invite must be Admin, got %v %v", legacy.RoleID, err) + } + }) + + t.Run("without the legacy column nothing is assigned", func(t *testing.T) { + db := openMigratedRolesDB(t) + uaPersistedUser(t, db, 20, nil, true) + uaPersistedInvite(t, db, "late@x.example", nil, false, false) + if err := database.Migrate(db, migrationPublicURL); err != nil { + t.Fatal(err) + } + if userRow(t, db, 20).RoleID != nil { + t.Errorf("the user must stay unassigned") + } + var stored models.Invite + _ = db.Where("email = ?", "late@x.example").First(&stored).Error + if stored.RoleID != nil { + t.Errorf("the invite must stay unassigned") + } + }) +} + +type usersAPIGetAllHookRepo struct { + *mockUserRepo + hook func() +} + +func (r *usersAPIGetAllHookRepo) GetAll() []user.User { + if r.hook != nil { + hook := r.hook + r.hook = nil + hook() + } + return r.mockUserRepo.GetAll() +} + +func TestUsersAPIAcceptInviteRace(t *testing.T) { + w := newUsersAPIWorld(t) + pending := w.invites.invites[3] + w.users.users[200] = user.User{ID: 200, Username: "taken", Email: pending.Email, IsVerified: true, RoleID: writerRoleID} + hooked := &usersAPIGetAllHookRepo{mockUserRepo: w.users} + hooked.hook = func() { + accepted := time.Now() + raced := w.invites.invites[3] + raced.AcceptedAt = &accepted + w.invites.invites[3] = raced + } + accept := useCases.NewAcceptInviteUseCase(hooked, w.invites, w.roles, &stubHasher{}, w.recorder) + _, err := accept.AcceptInvite(testCtx, useCases.AcceptInviteCommand{Code: "code3", Username: "kim", Password: "password1"}) + if !errors.Is(err, invite.ErrInviteInvalid) { + t.Fatalf("a concurrently accepted invite must surface its own refusal, got %v", err) + } +} + +func TestUsersAPIRepositoryFailuresUseCases(t *testing.T) { + t.Run("a failing successor lookup is not an invalid transfer", func(t *testing.T) { + w := newUsersAPIWorld(t) + w.users.fail.failNext("Get") + _, err := w.container.TransferOwnershipUseCase.Transfer(testCtx, useCases.TransferOwnershipCommand{Caller: w.caller(uaOwner), UserID: uaAdmin, ConfirmEmail: "jonas@renew.example"}) + if !errors.Is(err, errRepositoryFailure) { + t.Fatalf("expected the repository failure, got %v", err) + } + }) + + t.Run("a failing content type listing stops a role change", func(t *testing.T) { + w := newUsersAPIWorld(t) + w.types.fail.failNext("List") + _, err := w.container.ChangeUserRoleUseCase.ChangeRole(testCtx, useCases.ChangeUserRoleCommand{Caller: w.caller(uaAdmin), TargetID: uaWriter, RoleID: w.id("Contributor")}) + if !errors.Is(err, errRepositoryFailure) { + t.Fatalf("expected the repository failure, got %v", err) + } + if w.users.users[uaWriter].RoleID != writerRoleID { + t.Errorf("the role must not change") + } + }) +} diff --git a/test/users_api_http_test.go b/test/users_api_http_test.go new file mode 100644 index 0000000..9932b83 --- /dev/null +++ b/test/users_api_http_test.go @@ -0,0 +1,638 @@ +package test + +import ( + "RenewCMS/api/pkg/apperr" + "RenewCMS/internal/domain/audit" + "RenewCMS/internal/domain/role" + "RenewCMS/internal/domain/user" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" +) + +func usersAPIAssertMissing(t *testing.T, rec *httptest.ResponseRecorder, key string) { + t.Helper() + if rec.Code != http.StatusForbidden || rec.Body.String() != apperr.MissingPermissionErrorMessage+"\n" || rec.Header().Get(apperr.MissingPermissionHeader) != key { + t.Fatalf("expected 403 missing_permission for %s, got %d %q (%q)", key, rec.Code, rec.Body.String(), rec.Header().Get(apperr.MissingPermissionHeader)) + } +} + +func usersAPIAssertPlain(t *testing.T, rec *httptest.ResponseRecorder, status int, message string) { + t.Helper() + if rec.Code != status || rec.Body.String() != message+"\n" { + t.Fatalf("expected %d %q, got %d %q", status, message, rec.Code, rec.Body.String()) + } +} + +func (w *usersAPIWorld) transferBody(target uint32, email string) string { + return `{"user_id": ` + strconv.FormatUint(uint64(target), 10) + `, "confirm_email": ` + strconv.Quote(email) + `}` +} + +func TestUsersAPIGateHTTP(t *testing.T) { + t.Run("the gate decides who changes roles, invites and transfers", func(t *testing.T) { + rows := []struct { + name string + run func(w *usersAPIWorld) *httptest.ResponseRecorder + assert func(t *testing.T, w *usersAPIWorld, rec *httptest.ResponseRecorder) + }{ + {"Owner changes a role", func(w *usersAPIWorld) *httptest.ResponseRecorder { + return w.putRole(t, uaOwner, "41", w.idText("Intern")) + }, usersAPIWantStatus(http.StatusOK)}, + {"Admin changes a role", func(w *usersAPIWorld) *httptest.ResponseRecorder { + return w.putRole(t, uaAdmin, "41", w.idText("Intern")) + }, usersAPIWantStatus(http.StatusOK)}, + {"People manager changes a role", func(w *usersAPIWorld) *httptest.ResponseRecorder { + return w.putRole(t, uaPeople, "41", w.idText("Intern")) + }, usersAPIWantStatus(http.StatusOK)}, + {"Writer is refused", func(w *usersAPIWorld) *httptest.ResponseRecorder { + return w.putRole(t, uaWriter, "41", w.idText("Intern")) + }, usersAPIWantMissing("users.change_role")}, + {"Writer is refused before the id is read", func(w *usersAPIWorld) *httptest.ResponseRecorder { return w.putRole(t, uaWriter, "abc", "1") }, usersAPIWantMissing("users.change_role")}, + {"Viewer is refused before any lookup", func(w *usersAPIWorld) *httptest.ResponseRecorder { return w.putRole(t, uaViewer, "999", "1") }, usersAPIWantMissing("users.change_role")}, + {"Admin with a bad id", func(w *usersAPIWorld) *httptest.ResponseRecorder { return w.putRole(t, uaAdmin, "abc", "1") }, usersAPIWantPlain(http.StatusBadRequest, apperr.IdUint32ErrorMessage)}, + {"API key of an Admin", func(w *usersAPIWorld) *httptest.ResponseRecorder { + raw, _ := w.issueKey(uaAdmin, "ci") + return w.withKey(t, http.MethodPut, "/v1/user/41/role", `{"role_id": `+w.idText("Intern")+`}`, raw) + }, usersAPIWantPlain(http.StatusForbidden, apperr.SessionRequiredErrorMessage)}, + {"no cookie", func(w *usersAPIWorld) *httptest.ResponseRecorder { + return w.anonymous(t, http.MethodPut, "/v1/user/41/role", `{"role_id": 1}`) + }, usersAPIWantPlain(http.StatusUnauthorized, apperr.UnauthorizedErrorMessage)}, + {"unverified Admin", func(w *usersAPIWorld) *httptest.ResponseRecorder { return w.putRole(t, uaUnverified, "41", "1") }, usersAPIWantPlain(http.StatusForbidden, apperr.VerificationRequiredErrorMessage)}, + {"Owner transfers", func(w *usersAPIWorld) *httptest.ResponseRecorder { + return w.do(t, uaOwner, http.MethodPost, "/v1/site/transfer-ownership", w.transferBody(uaAdmin, "jonas@renew.example")) + }, usersAPIWantStatus(http.StatusOK)}, + {"Admin cannot transfer", func(w *usersAPIWorld) *httptest.ResponseRecorder { + return w.do(t, uaAdmin, http.MethodPost, "/v1/site/transfer-ownership", w.transferBody(uaOtherAdmin, "lea@renew.example")) + }, usersAPIWantMissing("system.transfer_ownership")}, + {"Writer is refused before the body", func(w *usersAPIWorld) *httptest.ResponseRecorder { + return w.do(t, uaWriter, http.MethodPost, "/v1/site/transfer-ownership", "") + }, usersAPIWantMissing("system.transfer_ownership")}, + {"API key of the Owner on the transfer", func(w *usersAPIWorld) *httptest.ResponseRecorder { + raw, _ := w.issueKey(uaOwner, "ci") + return w.withKey(t, http.MethodPost, "/v1/site/transfer-ownership", w.transferBody(uaAdmin, "jonas@renew.example"), raw) + }, usersAPIWantPlain(http.StatusForbidden, apperr.SessionRequiredErrorMessage)}, + {"Viewer cannot invite", func(w *usersAPIWorld) *httptest.ResponseRecorder { + return w.do(t, uaViewer, http.MethodPost, "/v1/user/invite", `{"email": "x@renew.example"}`) + }, usersAPIWantMissing("users.invite")}, + {"Inviter cannot list", func(w *usersAPIWorld) *httptest.ResponseRecorder { + return w.do(t, uaInviter, http.MethodGet, "/v1/user", "") + }, usersAPIWantMissing("users.view")}, + } + for _, row := range rows { + t.Run(row.name, func(t *testing.T) { + w := newUsersAPIWorld(t) + rec := row.run(w) + row.assert(t, w, rec) + if rec.Code >= 400 { + if w.storedRoleID(41) != w.id("Contributor") || w.storedRoleID(uaOwner) != ownerRoleID || len(w.recorder.events) != 0 { + t.Errorf("a refused call must store and record nothing") + } + } + }) + } + }) + + t.Run("the gate follows the role live", func(t *testing.T) { + w := newUsersAPIWorld(t) + usersAPIAssertMissing(t, w.putRole(t, uaViewer, "41", w.idText("Intern")), "users.change_role") + viewer := w.roles.roles[w.id("Viewer")] + viewer.Grants = append(viewer.Grants, role.Grant{Key: role.KeyUsersChangeRole, Scope: role.ScopeAll}, role.Grant{Key: role.KeyContentRead, Scope: role.ScopeAll}) + w.roles.roles[viewer.ID] = viewer + rec := w.putRole(t, uaViewer, "41", w.idText("Intern")) + if rec.Header().Get(apperr.MissingPermissionHeader) != "" { + t.Fatalf("expected the same cookie to pass the gate, got %d %q", rec.Code, rec.Body.String()) + } + assertEscalation(t, rec, "content.create") + }) +} + +func usersAPIWantStatus(status int) func(*testing.T, *usersAPIWorld, *httptest.ResponseRecorder) { + return func(t *testing.T, _ *usersAPIWorld, rec *httptest.ResponseRecorder) { + t.Helper() + if rec.Code != status { + t.Fatalf("expected %d, got %d %q", status, rec.Code, rec.Body.String()) + } + } +} + +func usersAPIWantMissing(key string) func(*testing.T, *usersAPIWorld, *httptest.ResponseRecorder) { + return func(t *testing.T, _ *usersAPIWorld, rec *httptest.ResponseRecorder) { + t.Helper() + usersAPIAssertMissing(t, rec, key) + } +} + +func usersAPIWantPlain(status int, message string) func(*testing.T, *usersAPIWorld, *httptest.ResponseRecorder) { + return func(t *testing.T, _ *usersAPIWorld, rec *httptest.ResponseRecorder) { + t.Helper() + usersAPIAssertPlain(t, rec, status, message) + } +} + +func TestUsersAPIChangeRoleHTTP(t *testing.T) { + rows := []struct { + name string + caller uint32 + target string + role string + status int + code string + key string + }{ + {"Admin gives Writer", uaAdmin, "41", "Writer", 200, "", ""}, + {"Admin gives Admin", uaAdmin, "41", "Admin", 200, "", ""}, + {"Admin demotes another Admin", uaAdmin, "10", "Contributor", 200, "", ""}, + {"Owner gives Content lead", uaOwner, "5", "Content lead", 200, "", ""}, + {"People manager gives Intern", uaPeople, "41", "Intern", 200, "", ""}, + {"People manager gives Writer", uaPeople, "41", "Writer", 403, "escalation", "content.update"}, + {"People manager gives Content lead", uaPeople, "41", "Content lead", 403, "escalation", "content.update"}, + {"People manager touches a Content lead", uaPeople, "45", "Intern", 403, "escalation", "content.update"}, + {"People manager touches an Admin", uaPeople, "5", "Intern", 403, "escalation", "content.update"}, + {"Admin gives Owner", uaAdmin, "41", "Owner", 403, "role_locked", ""}, + {"Owner gives Owner", uaOwner, "41", "Owner", 403, "role_locked", ""}, + {"Admin changes the owner", uaAdmin, "3", "Admin", 403, "role_locked", ""}, + {"Owner changes the owner", uaOwner, "3", "Admin", 403, "role_locked", ""}, + {"Admin changes itself", uaAdmin, "5", "Writer", 403, "own_role", ""}, + {"People manager changes itself", uaPeople, "31", "Intern", 403, "own_role", ""}, + {"unknown user", uaAdmin, "999", "Writer", 404, apperr.UserNotFoundErrorMessage, ""}, + {"unknown role", uaAdmin, "41", "unknown", 400, "invalid_role", ""}, + {"unverified user", uaAdmin, "12", "Writer", 200, "", ""}, + } + for _, row := range rows { + t.Run(row.name, func(t *testing.T) { + w := newUsersAPIWorld(t) + before := w.storedRoleID(41) + rec := w.putRole(t, row.caller, row.target, strconv.FormatUint(uint64(w.roleIDFor(row.role)), 10)) + if row.status == 200 { + item := usersAPIDecode(t, rec) + if rec.Code != 200 || item["kind"] != "user" || item["status"] != "accepted" { + t.Fatalf("unexpected %d %q", rec.Code, rec.Body.String()) + } + apiKeyAssertExactFields(t, item, "id", "kind", "username", "display_name", "email", "role", "status", "created_at") + if ref := usersAPIRoleRef(t, item); ref["name"] != row.role || ref["id"] != float64(w.ids[row.role]) { + t.Errorf("unexpected role %v", ref) + } + if rec.Header().Get("Cache-Control") != "no-store" { + t.Errorf("expected no-store") + } + return + } + if row.code == "escalation" { + assertEscalation(t, rec, row.key) + } else { + assertRefusal(t, rec, row.status, row.code) + } + if w.storedRoleID(41) != before || w.users.updateRoleCalls != 0 || len(w.recorder.events) != 0 || w.users.users[41].TokenVersion != 0 { + t.Errorf("a refused call changes nothing") + } + }) + } + + t.Run("missing and zero role ids are validation errors", func(t *testing.T) { + for _, body := range []string{`{}`, `{"role_id": 0}`} { + w := newUsersAPIWorld(t) + rec := w.do(t, uaAdmin, http.MethodPut, "/v1/user/41/role", body) + if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "RoleID") { + t.Errorf("%s: expected a validator 400, got %d %q", body, rec.Code, rec.Body.String()) + } + } + }) + + t.Run("an undecodable body is a body error", func(t *testing.T) { + w := newUsersAPIWorld(t) + usersAPIAssertPlain(t, w.do(t, uaAdmin, http.MethodPut, "/v1/user/41/role", `{"role_id": "x"`), http.StatusBadRequest, apperr.BodyErrorMessage) + }) + + t.Run("the same role again is a no-op with the unchanged item", func(t *testing.T) { + w := newUsersAPIWorld(t) + rec := w.putRole(t, uaAdmin, "41", w.idText("Contributor")) + if rec.Code != 200 || usersAPIRoleRef(t, usersAPIDecode(t, rec))["name"] != "Contributor" || w.users.updateRoleCalls != 0 || len(w.recorder.events) != 0 { + t.Fatalf("unexpected %d %q", rec.Code, rec.Body.String()) + } + }) + + t.Run("a role change takes effect at once and keeps sessions and keys", func(t *testing.T) { + w := newUsersAPIWorld(t) + raw, _ := w.issueKey(41, "mine") + if rec := w.do(t, 41, http.MethodGet, "/v1/auth/me", ""); usersAPIRoleName(t, rec) != "Contributor" { + t.Fatalf("unexpected %q", rec.Body.String()) + } + if rec := w.putRole(t, uaAdmin, "41", w.idText("Writer")); rec.Code != 200 { + t.Fatalf("unexpected %d %q", rec.Code, rec.Body.String()) + } + if w.users.users[41].TokenVersion != 0 { + t.Errorf("token_version must not change") + } + rec := w.do(t, 41, http.MethodGet, "/v1/auth/me", "") + if usersAPIRoleName(t, rec) != "Writer" { + t.Errorf("unexpected %q", rec.Body.String()) + } + if rec := w.withKey(t, http.MethodGet, "/v1/auth/me", "", raw); rec.Code != 200 || usersAPIRoleName(t, rec) != "Writer" { + t.Errorf("the key must still authenticate with the new role, got %d %q", rec.Code, rec.Body.String()) + } + if rec := w.do(t, 41, http.MethodGet, "/v1/media", ""); rec.Code != 200 { + t.Errorf("Writer may view media, got %d", rec.Code) + } + }) + + t.Run("two concurrent changes give one winner", func(t *testing.T) { + w := newUsersAPIWorld(t) + w.users.beforeUpdateRole = func() { + if rec := w.putRole(t, uaOwner, "41", w.idText("Intern")); rec.Code != 200 { + t.Errorf("the rival must win, got %d", rec.Code) + } + } + rec := w.putRole(t, uaAdmin, "41", w.idText("Writer")) + usersAPIAssertPlain(t, rec, http.StatusConflict, "user_role_changed") + if w.storedRoleID(41) != w.id("Intern") { + t.Errorf("the winner's role must stay") + } + }) + + t.Run("a deleted role is an invalid role", func(t *testing.T) { + w := newUsersAPIWorld(t) + temp := w.createTemp("content.read=own") + w.users.beforeUpdateRole = func() { delete(w.roles.roles, temp) } + usersAPIAssertPlain(t, w.putRole(t, uaAdmin, "41", strconv.FormatUint(uint64(temp), 10)), http.StatusBadRequest, "invalid_role") + if w.storedRoleID(41) != w.id("Contributor") { + t.Errorf("the user keeps Contributor") + } + }) + + t.Run("a repository failure is a 500", func(t *testing.T) { + w := newUsersAPIWorld(t) + w.users.fail.failNext("UpdateRole") + usersAPIAssertPlain(t, w.putRole(t, uaAdmin, "41", w.idText("Writer")), http.StatusInternalServerError, "Internal server error") + }) + + t.Run("a gated request loads the user at most once and the role once", func(t *testing.T) { + w := newUsersAPIWorld(t) + w.roles.calls = map[string]int{} + rec := w.do(t, uaAdmin, http.MethodGet, "/v1/user", "") + if rec.Code != 200 { + t.Fatalf("unexpected %d", rec.Code) + } + if w.roles.calls["MemberCounts"] != 0 || w.roles.calls["Get"] > 1 { + t.Errorf("unexpected role calls %v", w.roles.calls) + } + }) +} + +func usersAPIRoleName(t *testing.T, rec *httptest.ResponseRecorder) string { + t.Helper() + body := usersAPIDecode(t, rec) + access, ok := body["access"].(map[string]any) + if !ok { + t.Fatalf("no access in %q", rec.Body.String()) + } + return access["role"].(map[string]any)["name"].(string) +} + +func TestUsersAPIInviteHTTP(t *testing.T) { + rows := []struct { + name string + caller uint32 + body string + status int + code string + key string + role string + }{ + {"default role", uaAdmin, `{"email": "a@renew.example"}`, 201, "", "", "Writer"}, + {"role id zero", uaAdmin, `{"email": "a@renew.example", "role_id": 0}`, 201, "", "", "Writer"}, + {"Contributor", uaAdmin, `{"email": "a@renew.example", "role_id": {Contributor}}`, 201, "", "", "Contributor"}, + {"Admin", uaAdmin, `{"email": "a@renew.example", "role_id": {Admin}}`, 201, "", "", "Admin"}, + {"Owner invites a Content lead", uaOwner, `{"email": "a@renew.example", "role_id": {Content lead}}`, 201, "", "", "Content lead"}, + {"Owner role", uaAdmin, `{"email": "a@renew.example", "role_id": {Owner}}`, 403, "role_locked", "", ""}, + {"unknown role", uaAdmin, `{"email": "a@renew.example", "role_id": 999}`, 400, "invalid_role", "", ""}, + {"People manager invites an Intern", uaPeople, `{"email": "a@renew.example", "role_id": {Intern}}`, 201, "", "", "Intern"}, + {"People manager default Writer", uaPeople, `{"email": "a@renew.example"}`, 403, "escalation", "content.update", ""}, + {"People manager Content lead", uaPeople, `{"email": "a@renew.example", "role_id": {Content lead}}`, 403, "escalation", "content.update", ""}, + {"Inviter default Writer", uaInviter, `{"email": "a@renew.example"}`, 403, "escalation", "content.read", ""}, + {"unknown role keeps the expired invite", uaAdmin, `{"email": "old2@renew.example", "role_id": 999}`, 400, "invalid_role", "", ""}, + {"expired invite replaced", uaAdmin, `{"email": "old2@renew.example", "role_id": {Writer}}`, 201, "", "", "Writer"}, + {"pending invite", uaAdmin, `{"email": "new3@renew.example"}`, 409, apperr.PendingInviteErrorMessage, "", ""}, + {"email with an account", uaAdmin, `{"email": "ines@renew.example"}`, 409, apperr.EmailHasAccountErrorMessage, "", ""}, + } + for _, row := range rows { + t.Run(row.name, func(t *testing.T) { + w := newUsersAPIWorld(t) + body := row.body + for name, id := range w.ids { + body = strings.ReplaceAll(body, "{"+name+"}", strconv.FormatUint(uint64(id), 10)) + } + rec := w.do(t, row.caller, http.MethodPost, "/v1/user/invite", body) + if row.status == 201 { + item := usersAPIDecode(t, rec) + if rec.Code != 201 { + t.Fatalf("unexpected %d %q", rec.Code, rec.Body.String()) + } + apiKeyAssertExactFields(t, item, "id", "kind", "email", "status", "role", "created_at", "expires_at") + if usersAPIRoleRef(t, item)["name"] != row.role || len(w.mail.SentEmails) != 1 { + t.Errorf("unexpected invite %v with %d mails", item, len(w.mail.SentEmails)) + } + return + } + switch row.code { + case "escalation": + assertEscalation(t, rec, row.key) + default: + assertRefusal(t, rec, row.status, row.code) + } + if len(w.mail.SentEmails) != 0 || len(w.invites.invites) != 4 { + t.Errorf("a refusal creates no invite and sends no mail") + } + if strings.Contains(row.name, "expired invite") { + if _, present := w.invites.invites[2]; !present { + t.Errorf("invite 2 must stay") + } + } + }) + } + + t.Run("an invalid address is a validation error", func(t *testing.T) { + w := newUsersAPIWorld(t) + if rec := w.do(t, uaAdmin, http.MethodPost, "/v1/user/invite", `{"email": "not-an-email"}`); rec.Code != http.StatusBadRequest { + t.Fatalf("unexpected %d", rec.Code) + } + }) + + t.Run("an API key may still create invites with its creator's live role", func(t *testing.T) { + w := newUsersAPIWorld(t) + raw, _ := w.issueKey(uaPeople, "ci") + rec := w.withKey(t, http.MethodPost, "/v1/user/invite", `{"email": "a@renew.example"}`, raw) + assertEscalation(t, rec, "content.update") + rec = w.withKey(t, http.MethodPost, "/v1/user/invite", `{"email": "a@renew.example", "role_id": `+w.idText("Intern")+`}`, raw) + if rec.Code != http.StatusCreated { + t.Fatalf("unexpected %d %q", rec.Code, rec.Body.String()) + } + }) +} + +func TestUsersAPIRenewHTTP(t *testing.T) { + t.Run("renewing keeps the role and carries it in the response", func(t *testing.T) { + w := newUsersAPIWorld(t) + rec := w.do(t, uaAdmin, http.MethodPost, "/v1/user/invite/2/renew", "") + item := usersAPIDecode(t, rec) + if rec.Code != 200 || item["status"] != "pending" || usersAPIRoleRef(t, item)["name"] != "Contributor" || len(w.mail.SentEmails) != 1 { + t.Fatalf("unexpected %d %q", rec.Code, rec.Body.String()) + } + }) + + rows := []struct { + name string + caller uint32 + method string + path string + status int + code string + }{ + {"People manager renews Contributor", uaPeople, http.MethodPost, "/v1/user/invite/3/renew", 200, ""}, + {"People manager renews Writer", uaPeople, http.MethodPost, "/v1/user/invite/1/renew", 403, "escalation"}, + {"People manager cancels Writer", uaPeople, http.MethodDelete, "/v1/user/invite/1", 204, ""}, + {"unknown invite", uaAdmin, http.MethodPost, "/v1/user/invite/999/renew", 404, apperr.InviteNotFoundErrorMessage}, + {"accepted invite", uaAdmin, http.MethodPost, "/v1/user/invite/4/renew", 409, apperr.InviteAlreadyAcceptedErrorMessage}, + } + for _, row := range rows { + t.Run(row.name, func(t *testing.T) { + w := newUsersAPIWorld(t) + rec := w.do(t, row.caller, row.method, row.path, "") + if row.status < 300 { + if rec.Code != row.status { + t.Fatalf("unexpected %d %q", rec.Code, rec.Body.String()) + } + return + } + if row.code == "escalation" { + assertEscalation(t, rec, "content.update") + if len(w.mail.SentEmails) != 0 { + t.Errorf("no mail on a refusal") + } + return + } + usersAPIAssertPlain(t, rec, row.status, row.code) + }) + } +} + +func TestUsersAPIAcceptHTTP(t *testing.T) { + for _, name := range []string{"Writer", "Contributor", "Admin"} { + t.Run("accepting an invite for "+name, func(t *testing.T) { + w := newUsersAPIWorld(t) + pending := w.invites.invites[3] + pending.RoleID = w.ids[name] + w.invites.invites[3] = pending + rec := w.anonymous(t, http.MethodPost, "/v1/auth/invite/accept", `{"code": "code3", "username": "kim", "password": "password1"}`) + if rec.Code != http.StatusOK || strings.Contains(rec.Body.String(), `"role"`) { + t.Fatalf("unexpected %d %q", rec.Code, rec.Body.String()) + } + var created uint32 + for id, member := range w.users.users { + if member.Username == "kim" { + created = id + } + } + if w.storedRoleID(created) != w.ids[name] || !w.users.users[created].IsVerified { + t.Errorf("unexpected stored user %+v", w.users.users[created]) + } + if usersAPIRoleName(t, w.do(t, created, http.MethodGet, "/v1/auth/me", "")) != name { + t.Errorf("/auth/me must show %s", name) + } + }) + } + + t.Run("an invite without a usable role is invalid", func(t *testing.T) { + w := newUsersAPIWorld(t) + pending := w.invites.invites[3] + pending.RoleID = 0 + w.invites.invites[3] = pending + rec := w.anonymous(t, http.MethodPost, "/v1/auth/invite/accept", `{"code": "code3", "username": "kim", "password": "password1"}`) + usersAPIAssertPlain(t, rec, http.StatusNotFound, apperr.InviteInvalidErrorMessage) + }) +} + +func TestUsersAPITransferHTTP(t *testing.T) { + t.Run("transferring ownership", func(t *testing.T) { + w := newUsersAPIWorld(t) + raw5, _ := w.issueKey(uaAdmin, "five") + raw3, _ := w.issueKey(uaOwner, "three") + rec := w.do(t, uaOwner, http.MethodPost, "/v1/site/transfer-ownership", w.transferBody(uaAdmin, "jonas@renew.example")) + body := usersAPIDecode(t, rec) + if rec.Code != 200 || rec.Header().Get("Cache-Control") != "no-store" { + t.Fatalf("unexpected %d %q", rec.Code, rec.Body.String()) + } + apiKeyAssertExactFields(t, body, "owner", "former_owner") + apiKeyAssertExactFields(t, body["owner"].(map[string]any), "id", "name") + former := body["former_owner"].(map[string]any) + apiKeyAssertExactFields(t, former, "id", "name", "role") + if usersAPIRoleRef(t, former)["name"] != "Admin" || former["id"] != float64(uaOwner) { + t.Errorf("unexpected former owner %v", former) + } + if w.users.users[uaAdmin].TokenVersion != 0 || w.users.users[uaOwner].TokenVersion != 0 { + t.Errorf("token versions must not change") + } + if usersAPIRoleName(t, w.do(t, uaOwner, http.MethodGet, "/v1/auth/me", "")) != "Admin" || usersAPIRoleName(t, w.do(t, uaAdmin, http.MethodGet, "/v1/auth/me", "")) != "Owner" { + t.Errorf("sessions must follow the new roles") + } + if usersAPIRoleName(t, w.withKey(t, http.MethodGet, "/v1/auth/me", "", raw5)) != "Owner" || usersAPIRoleName(t, w.withKey(t, http.MethodGet, "/v1/auth/me", "", raw3)) != "Admin" { + t.Errorf("keys must follow the new roles") + } + listed := w.do(t, uaAdmin, http.MethodGet, "/v1/role", "") + if !strings.Contains(listed.Body.String(), `"name":"Owner"`) { + t.Fatalf("unexpected %q", listed.Body.String()) + } + usersAPIAssertMissing(t, w.do(t, uaOwner, http.MethodPost, "/v1/site/transfer-ownership", w.transferBody(uaAdmin, "jonas@renew.example")), "system.transfer_ownership") + if len(w.recorder.events) != 1 || w.recorder.events[0].Action != audit.ActionUserTransferOwnership { + t.Errorf("expected one transfer event, got %v", w.recorder.actions()) + } + }) + + refusals := []struct { + name string + body string + status int + code string + }{ + {"empty object", `{}`, 400, ""}, + {"missing confirm_email", `{"user_id": 5}`, 400, ""}, + {"unknown user", `{"user_id": 999, "confirm_email": "x@renew.example"}`, 400, "invalid_transfer"}, + {"yourself", `{"user_id": 3, "confirm_email": "marta@renew.example"}`, 400, "invalid_transfer"}, + {"unverified", `{"user_id": 12, "confirm_email": "sam@renew.example"}`, 400, "invalid_transfer"}, + {"wrong case", `{"user_id": 5, "confirm_email": "JONAS@renew.example"}`, 400, "confirmation_mismatch"}, + {"empty email", `{"user_id": 5, "confirm_email": ""}`, 400, ""}, + } + for _, row := range refusals { + t.Run("refusal: "+row.name, func(t *testing.T) { + w := newUsersAPIWorld(t) + rec := w.do(t, uaOwner, http.MethodPost, "/v1/site/transfer-ownership", row.body) + if row.code == "" { + if rec.Code != http.StatusBadRequest { + t.Fatalf("unexpected %d %q", rec.Code, rec.Body.String()) + } + } else { + assertRefusal(t, rec, row.status, row.code) + } + if w.storedRoleID(uaOwner) != ownerRoleID || w.users.transferCalls != 0 || len(w.recorder.events) != 0 { + t.Errorf("a refusal changes nothing") + } + }) + } + + t.Run("two simultaneous transfers give one new owner", func(t *testing.T) { + w := newUsersAPIWorld(t) + w.users.beforeTransfer = func() { + if rec := w.do(t, uaOwner, http.MethodPost, "/v1/site/transfer-ownership", w.transferBody(uaOtherAdmin, "lea@renew.example")); rec.Code != 200 { + t.Errorf("the rival must win, got %d", rec.Code) + } + } + rec := w.do(t, uaOwner, http.MethodPost, "/v1/site/transfer-ownership", w.transferBody(uaAdmin, "jonas@renew.example")) + usersAPIAssertPlain(t, rec, http.StatusConflict, "user_role_changed") + owners := 0 + for _, member := range w.users.users { + if member.RoleID == ownerRoleID && member.IsVerified { + owners++ + } + } + if owners != 1 || w.storedRoleID(uaOtherAdmin) != ownerRoleID { + t.Errorf("exactly the first successor must own the site") + } + }) +} + +func TestUsersAPIListHTTP(t *testing.T) { + t.Run("the list shows roles and nothing secret", func(t *testing.T) { + w := newUsersAPIWorld(t) + w.roles.calls = map[string]int{} + rec := w.do(t, uaAdmin, http.MethodGet, "/v1/user", "") + if rec.Code != 200 { + t.Fatalf("unexpected %d", rec.Code) + } + if w.roles.calls["List"] != 1 { + t.Errorf("expected one role List, got %v", w.roles.calls) + } + for _, secret := range []string{"password", "verification", "token_version", "hashed_code", "invited_by", "accepted_at", `"code"`} { + if strings.Contains(rec.Body.String(), secret) { + t.Errorf("the list leaks %q", secret) + } + } + items := apiKeyDecodeList(t, rec) + seen := map[string]bool{} + for _, item := range items { + kind := item["kind"].(string) + key := kind + strconv.Itoa(int(item["id"].(float64))) + seen[key] = true + switch kind { + case "user": + apiKeyAssertExactFields(t, item, "id", "kind", "username", "display_name", "email", "role", "status", "created_at") + default: + apiKeyAssertExactFields(t, item, "id", "kind", "email", "role", "status", "created_at") + } + if key == "user3" { + ref := usersAPIRoleRef(t, item) + if ref["name"] != "Owner" || ref["built_in"] != true || ref["locked"] != true || ref["id"] != float64(ownerRoleID) { + t.Errorf("unexpected owner role %v", ref) + } + } + if key == "user13" || key == "user14" { + if item["role"] != nil { + t.Errorf("%s must show role null, got %v", key, item["role"]) + } + } + if key == "invite2" && usersAPIRoleRef(t, item)["name"] != "Contributor" { + t.Errorf("invite 2 must be for Contributor") + } + } + if seen["invite4"] { + t.Errorf("accepted invites are history") + } + }) + + t.Run("a user without a usable role is denied and never promoted", func(t *testing.T) { + w := newUsersAPIWorld(t) + for _, id := range []uint32{uaNoRole, uaGhostRole} { + usersAPIAssertMissing(t, w.do(t, id, http.MethodGet, "/v1/user", ""), "users.view") + usersAPIAssertPlain(t, w.do(t, id, http.MethodGet, "/v1/auth/me", ""), http.StatusInternalServerError, "Internal server error") + } + }) +} + +func TestUsersAPIPublicUserShape(t *testing.T) { + t.Run("/auth/me keeps access.role and no top-level role", func(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") + if usersAPIRoleName(t, rec) != "Admin" { + t.Errorf("access.role must stay") + } + }) + + t.Run("registration carries no role key and stores the Owner", func(t *testing.T) { + w := newUsersAPIWorld(t) + w.users.users = map[uint32]user.User{} + rec := w.anonymous(t, http.MethodPost, "/v1/auth/register", `{"username": "first", "password": "password1", "email": "first@renew.example"}`) + if rec.Code != http.StatusCreated && rec.Code != http.StatusOK { + t.Fatalf("unexpected %d %q", rec.Code, rec.Body.String()) + } + if strings.Contains(rec.Body.String(), `"role"`) { + t.Errorf("registration must carry no role key: %q", rec.Body.String()) + } + for _, member := range w.users.users { + if member.RoleID != ownerRoleID || member.IsVerified { + t.Errorf("unexpected stored user %+v", member) + } + } + }) + + t.Run("registration fails with 500 when the Owner role is not seeded", func(t *testing.T) { + w := newUsersAPIWorld(t) + w.users.users = map[uint32]user.User{} + w.roles.roles = map[uint32]role.Role{} + rec := w.anonymous(t, http.MethodPost, "/v1/auth/register", `{"username": "first", "password": "password1", "email": "first@renew.example"}`) + if rec.Code != http.StatusInternalServerError || len(w.users.users) != 0 { + t.Fatalf("unexpected %d %q with %d users", rec.Code, rec.Body.String(), len(w.users.users)) + } + }) +} diff --git a/test/users_api_persistence_test.go b/test/users_api_persistence_test.go new file mode 100644 index 0000000..2e9efd8 --- /dev/null +++ b/test/users_api_persistence_test.go @@ -0,0 +1,420 @@ +package test + +import ( + "RenewCMS/internal/domain/invite" + "RenewCMS/internal/domain/role" + "RenewCMS/internal/domain/user" + "RenewCMS/internal/infrastructure/persistence/database" + "RenewCMS/internal/infrastructure/persistence/models" + persistence "RenewCMS/internal/infrastructure/persistence/repositories" + "errors" + "testing" + "time" + + "gorm.io/gorm" +) + +func uaPersistedUser(t *testing.T, db *gorm.DB, id uint32, roleID *uint32, verified bool) { + t.Helper() + row := models.User{ID: id, Username: "member" + string(rune('a'+id)), Email: "member" + string(rune('a'+id)) + "@x.example", Password: "x", IsVerified: verified, RoleID: roleID} + if err := db.Create(&row).Error; err != nil { + t.Fatalf("user %d: %v", id, err) + } +} + +func uaPersistedInvite(t *testing.T, db *gorm.DB, email string, roleID *uint32, accepted bool, expired bool) models.Invite { + t.Helper() + now := time.Now() + row := models.Invite{Email: email, HashedCode: "h", InvitedBy: 1, RoleID: roleID, ExpiresAt: now.Add(time.Hour), CreatedAt: now} + if expired { + row.ExpiresAt = now.Add(-time.Hour) + } + if accepted { + row.AcceptedAt = &now + } + if err := db.Create(&row).Error; err != nil { + t.Fatalf("invite %s: %v", email, err) + } + return row +} + +func uaRoleIDPtr(id uint32) *uint32 { + return &id +} + +func TestUsersAPIUserRepository(t *testing.T) { + t.Run("Create writes role_id and NULL for zero", func(t *testing.T) { + db := openMigratedRolesDB(t) + repo := persistence.NewUserRepository(db) + owner := storedRole(t, db, "Owner").ID + created, err := repo.Create(user.User{Username: "a", Email: "a@x.example", Password: "x", RoleID: owner}) + if err != nil || created.RoleID != owner || roleIDOf(userRow(t, db, created.ID)) != owner { + t.Fatalf("unexpected %+v, %v", created, err) + } + bare, err := repo.Create(user.User{Username: "b", Email: "b@x.example", Password: "x"}) + if err != nil || userRow(t, db, bare.ID).RoleID != nil { + t.Fatalf("expected a NULL role_id, got %+v, %v", userRow(t, db, bare.ID).RoleID, err) + } + }) + + t.Run("UpdateRole is a compare-and-swap", func(t *testing.T) { + db := openMigratedRolesDB(t) + repo := persistence.NewUserRepository(db) + admin, writer, owner := storedRole(t, db, "Admin").ID, storedRole(t, db, "Writer").ID, storedRole(t, db, "Owner").ID + uaPersistedUser(t, db, 5, &admin, true) + updated, err := repo.UpdateRole(5, admin, writer) + if err != nil || updated.RoleID != writer || updated.TokenVersion != 0 { + t.Fatalf("unexpected %+v, %v", updated, err) + } + if _, err := repo.UpdateRole(5, admin, owner); !errors.Is(err, user.ErrRoleChanged) { + t.Fatalf("the second call with the same from must lose, got %v", err) + } + if roleIDOf(userRow(t, db, 5)) != writer { + t.Errorf("the winner must stand") + } + if _, err := repo.UpdateRole(999, admin, writer); !errors.Is(err, user.ErrUserNotFound) { + t.Errorf("expected ErrUserNotFound, got %v", err) + } + if _, err := repo.UpdateRole(5, writer, 999); !errors.Is(err, role.ErrRoleNotFound) { + t.Errorf("expected ErrRoleNotFound, got %v", err) + } + if roleIDOf(userRow(t, db, 5)) != writer { + t.Errorf("a missing role must not be stored") + } + }) + + t.Run("UpdateRole swaps a NULL role_id when from is zero", func(t *testing.T) { + db := openMigratedRolesDB(t) + repo := persistence.NewUserRepository(db) + writer := storedRole(t, db, "Writer").ID + uaPersistedUser(t, db, 5, nil, true) + if _, err := repo.UpdateRole(5, 0, writer); err != nil || roleIDOf(userRow(t, db, 5)) != writer { + t.Fatalf("unexpected %v", err) + } + }) + + t.Run("UpdateRole fails when the database is closed", func(t *testing.T) { + db := openMigratedRolesDB(t) + repo := persistence.NewUserRepository(db) + sqlDB, _ := db.DB() + _ = sqlDB.Close() + if _, err := repo.UpdateRole(5, 1, 2); err == nil { + t.Errorf("expected an error") + } + }) + + t.Run("TransferOwnership updates both rows", func(t *testing.T) { + db := openMigratedRolesDB(t) + repo := persistence.NewUserRepository(db) + owner, admin := storedRole(t, db, "Owner").ID, storedRole(t, db, "Admin").ID + uaPersistedUser(t, db, 3, &owner, true) + uaPersistedUser(t, db, 5, &admin, true) + if err := repo.TransferOwnership(3, 5, owner, admin); err != nil { + t.Fatal(err) + } + if roleIDOf(userRow(t, db, 3)) != admin || roleIDOf(userRow(t, db, 5)) != owner { + t.Errorf("roles not swapped") + } + if err := repo.TransferOwnership(3, 5, owner, admin); !errors.Is(err, user.ErrRoleChanged) { + t.Errorf("the second transfer from the same owner must lose, got %v", err) + } + if roleIDOf(userRow(t, db, 5)) != owner { + t.Errorf("exactly one owner must remain") + } + }) + + t.Run("TransferOwnership rolls back for an unverified or missing target", func(t *testing.T) { + for name, target := range map[string]uint32{"unverified": 12, "missing": 99} { + db := openMigratedRolesDB(t) + repo := persistence.NewUserRepository(db) + owner, admin := storedRole(t, db, "Owner").ID, storedRole(t, db, "Admin").ID + uaPersistedUser(t, db, 3, &owner, true) + uaPersistedUser(t, db, 12, &admin, false) + if err := repo.TransferOwnership(3, target, owner, admin); !errors.Is(err, user.ErrInvalidTransfer) { + t.Fatalf("%s: expected ErrInvalidTransfer, got %v", name, err) + } + if roleIDOf(userRow(t, db, 3)) != owner { + t.Errorf("%s: the owner must stay", name) + } + } + }) + + t.Run("TransferOwnership fails when the database is closed", func(t *testing.T) { + db := openMigratedRolesDB(t) + repo := persistence.NewUserRepository(db) + sqlDB, _ := db.DB() + _ = sqlDB.Close() + if err := repo.TransferOwnership(3, 5, 1, 2); err == nil { + t.Errorf("expected an error") + } + }) +} + +func TestUsersAPIInviteRepository(t *testing.T) { + t.Run("invites have a role_id column and keep it through Create and Renew", func(t *testing.T) { + db := openMigratedRolesDB(t) + if !db.Migrator().HasColumn(&models.Invite{}, "role_id") { + t.Fatal("invites.role_id is missing") + } + repo := persistence.NewInviteRepository(db) + writer := storedRole(t, db, "Writer").ID + now := time.Now() + created, err := repo.Create(invite.Invite{Email: "a@x.example", HashedCode: "h", InvitedBy: 1, RoleID: writer, ExpiresAt: now.Add(time.Hour), CreatedAt: now}) + if err != nil || created.RoleID != writer { + t.Fatalf("unexpected %+v, %v", created, err) + } + renewed, err := repo.Renew(created.ID, "h2", now.Add(2*time.Hour), now) + if err != nil || renewed.RoleID != writer { + t.Fatalf("unexpected %+v, %v", renewed, err) + } + if all := repo.GetAll(); len(all) != 1 || all[0].RoleID != writer { + t.Errorf("unexpected %+v", all) + } + }) + + t.Run("AcceptWithUser stores the user's role_id and marks the invite accepted", func(t *testing.T) { + db := openMigratedRolesDB(t) + repo := persistence.NewInviteRepository(db) + contributor := storedRole(t, db, "Admin").ID + row := uaPersistedInvite(t, db, "kim@x.example", &contributor, false, false) + created, err := repo.AcceptWithUser(row.ID, time.Now(), user.User{Username: "kim", Email: "kim@x.example", Password: "x", IsVerified: true, RoleID: contributor}) + if err != nil || created.RoleID != contributor || roleIDOf(userRow(t, db, created.ID)) != contributor { + t.Fatalf("unexpected %+v, %v", created, err) + } + var stored models.Invite + _ = db.First(&stored, row.ID).Error + if stored.AcceptedAt == nil { + t.Errorf("the invite must be accepted") + } + }) +} + +type statementCounter struct { + statements int +} + +func countStatements(db *gorm.DB) *statementCounter { + counter := &statementCounter{} + increment := func(*gorm.DB) { counter.statements++ } + _ = db.Callback().Query().Before("gorm:query").Register("users_api:count_query", increment) + _ = db.Callback().Row().Before("gorm:row").Register("users_api:count_row", increment) + _ = db.Callback().Raw().Before("gorm:raw").Register("users_api:count_raw", increment) + return counter +} + +func TestUsersAPIRoleRepositoryMembers(t *testing.T) { + t.Run("MemberCounts counts users and open invites in two statements", func(t *testing.T) { + db := openMigratedRolesDB(t) + repo := persistence.NewRoleRepository(db) + writer, admin := storedRole(t, db, "Writer").ID, storedRole(t, db, "Admin").ID + uaPersistedUser(t, db, 1, &admin, true) + uaPersistedUser(t, db, 2, &admin, false) + uaPersistedUser(t, db, 3, &writer, true) + uaPersistedUser(t, db, 4, nil, true) + uaPersistedInvite(t, db, "p@x.example", &writer, false, false) + uaPersistedInvite(t, db, "e@x.example", &writer, false, true) + uaPersistedInvite(t, db, "a@x.example", &writer, true, false) + uaPersistedInvite(t, db, "n@x.example", nil, false, false) + counter := countStatements(db) + counts, err := repo.MemberCounts() + if err != nil { + t.Fatal(err) + } + if counts[admin] != 2 || counts[writer] != 3 || counts[0] != 0 || len(counts) != 2 { + t.Errorf("unexpected counts %v", counts) + } + if counter.statements > 2 { + t.Errorf("expected at most 2 statements, got %d", counter.statements) + } + }) + + t.Run("MemberCounts fails when the database is closed", func(t *testing.T) { + db := openMigratedRolesDB(t) + repo := persistence.NewRoleRepository(db) + sqlDB, _ := db.DB() + _ = sqlDB.Close() + if _, err := repo.MemberCounts(); err == nil { + t.Errorf("expected an error") + } + }) + + t.Run("Delete refuses a role with a user or an open invite and accepts an accepted one", func(t *testing.T) { + db := openMigratedRolesDB(t) + repo := persistence.NewRoleRepository(db) + withUser, _ := repo.Create(role.Role{Name: "With user", Version: 1}) + withInvite, _ := repo.Create(role.Role{Name: "With invite", Version: 1}) + withHistory, _ := repo.Create(role.Role{Name: "With history", Version: 1}) + uaPersistedUser(t, db, 1, &withUser.ID, true) + uaPersistedInvite(t, db, "p@x.example", &withInvite.ID, false, true) + uaPersistedInvite(t, db, "a@x.example", &withHistory.ID, true, false) + if err := repo.Delete(withUser.ID); !errors.Is(err, role.ErrRoleHasMembers) { + t.Errorf("a user must block, got %v", err) + } + if err := repo.Delete(withInvite.ID); !errors.Is(err, role.ErrRoleHasMembers) { + t.Errorf("an open invite must block, got %v", err) + } + if err := repo.Delete(withHistory.ID); err != nil { + t.Errorf("an accepted invite must not block, got %v", err) + } + }) + + t.Run("ReassignMembers moves users and open invites and returns the sum", func(t *testing.T) { + db := openMigratedRolesDB(t) + repo := persistence.NewRoleRepository(db) + source, _ := repo.Create(role.Role{Name: "Source", Version: 1}) + target, _ := repo.Create(role.Role{Name: "Target", Version: 1}) + uaPersistedUser(t, db, 1, &source.ID, true) + uaPersistedUser(t, db, 2, &source.ID, true) + open := uaPersistedInvite(t, db, "p@x.example", &source.ID, false, false) + done := uaPersistedInvite(t, db, "a@x.example", &source.ID, true, false) + moved, err := repo.ReassignMembers(source.ID, target.ID) + if err != nil || moved != 3 { + t.Fatalf("expected 3, got %d, %v", moved, err) + } + var movedInvite, keptInvite models.Invite + _ = db.First(&movedInvite, open.ID).Error + _ = db.First(&keptInvite, done.ID).Error + if *movedInvite.RoleID != target.ID || *keptInvite.RoleID != source.ID { + t.Errorf("only the open invite moves") + } + if _, err := repo.ReassignMembers(source.ID, 999); !errors.Is(err, role.ErrInvalidReassign) { + t.Errorf("expected ErrInvalidReassign, got %v", err) + } + }) +} + +func addLegacyRoleColumn(t *testing.T, db *gorm.DB) { + t.Helper() + if err := db.Exec("ALTER TABLE users ADD COLUMN role VARCHAR(32) NOT NULL DEFAULT ''").Error; err != nil { + t.Fatal(err) + } +} + +func setLegacyRole(t *testing.T, db *gorm.DB, id uint32, legacy string) { + t.Helper() + if err := db.Exec("UPDATE users SET role = ? WHERE id = ?", legacy, id).Error; err != nil { + t.Fatal(err) + } +} + +func legacyUsersDB(t *testing.T, rows map[uint32]string) *gorm.DB { + t.Helper() + db := openMigratedRolesDB(t) + addLegacyRoleColumn(t, db) + for id, legacy := range rows { + uaPersistedUser(t, db, id, nil, true) + setLegacyRole(t, db, id, legacy) + } + return db +} + +func TestUsersAPIMigration(t *testing.T) { + rows := []struct { + name string + users map[uint32]string + expect map[uint32]string + }{ + {"owner", map[uint32]string{3: "owner"}, map[uint32]string{3: "Owner"}}, + {"admin", map[uint32]string{5: "admin"}, map[uint32]string{5: "Admin"}}, + {"empty string, lowest id and any other id", map[uint32]string{2: "", 4: ""}, map[uint32]string{2: "Owner", 4: "Admin"}}, + {"something else", map[uint32]string{7: "something-else"}, map[uint32]string{7: "Writer"}}, + } + for _, row := range rows { + t.Run("legacy "+row.name, func(t *testing.T) { + db := legacyUsersDB(t, row.users) + if err := database.Migrate(db, migrationPublicURL); err != nil { + t.Fatal(err) + } + for id, name := range row.expect { + if roleIDOf(userRow(t, db, id)) != storedRole(t, db, name).ID { + t.Errorf("user %d: expected %s, got %d", id, name, roleIDOf(userRow(t, db, id))) + } + } + if !db.Migrator().HasColumn("users", "role") { + t.Errorf("the legacy column must stay") + } + before := map[uint32]uint32{} + for id := range row.users { + before[id] = roleIDOf(userRow(t, db, id)) + } + if err := database.Migrate(db, migrationPublicURL); err != nil { + t.Fatal(err) + } + for id, was := range before { + if roleIDOf(userRow(t, db, id)) != was { + t.Errorf("user %d changed on the second Migrate", id) + } + } + }) + } + + t.Run("a user that already has a role_id keeps it", func(t *testing.T) { + db := legacyUsersDB(t, nil) + custom, _ := persistence.NewRoleRepository(db).Create(role.Role{Name: "Contributor", Version: 1}) + uaPersistedUser(t, db, 5, &custom.ID, true) + setLegacyRole(t, db, 5, "admin") + if err := database.Migrate(db, migrationPublicURL); err != nil { + t.Fatal(err) + } + if roleIDOf(userRow(t, db, 5)) != custom.ID { + t.Errorf("the role_id was replaced") + } + }) + + t.Run("existing invites without a role get Admin, accepted ones included", func(t *testing.T) { + db := openMigratedRolesDB(t) + addLegacyRoleColumn(t, db) + pending := uaPersistedInvite(t, db, "p@x.example", nil, false, false) + done := uaPersistedInvite(t, db, "a@x.example", nil, true, false) + writer := storedRole(t, db, "Writer").ID + kept := uaPersistedInvite(t, db, "k@x.example", &writer, false, false) + if err := database.Migrate(db, migrationPublicURL); err != nil { + t.Fatal(err) + } + admin := storedRole(t, db, "Admin").ID + for _, id := range []uint32{pending.ID, done.ID} { + var stored models.Invite + _ = db.First(&stored, id).Error + if stored.RoleID == nil || *stored.RoleID != admin { + t.Errorf("invite %d must be for Admin", id) + } + } + var stored models.Invite + _ = db.First(&stored, kept.ID).Error + if *stored.RoleID != writer { + t.Errorf("an invite with a role keeps it") + } + }) + + t.Run("created_by is attributed to the lowest user holding the Owner role", func(t *testing.T) { + db := openMigratedRolesDB(t) + owner, admin := storedRole(t, db, "Owner").ID, storedRole(t, db, "Admin").ID + uaPersistedUser(t, db, 2, &admin, true) + uaPersistedUser(t, db, 6, &owner, true) + uaPersistedUser(t, db, 8, &owner, true) + createArticleRow(t, db, 10, 0) + if err := database.Migrate(db, migrationPublicURL); err != nil { + t.Fatal(err) + } + if createdByOf(t, db, "articles", 10) != 6 { + t.Errorf("expected user 6, got %d", createdByOf(t, db, "articles", 10)) + } + }) + + t.Run("a fresh database never has a users.role column and Migrate succeeds with no users", func(t *testing.T) { + db := openMigratedRolesDB(t) + if db.Migrator().HasColumn("users", "role") { + t.Fatal("a fresh database must not have users.role") + } + if err := database.Migrate(db, migrationPublicURL); err != nil { + t.Fatal(err) + } + }) + + t.Run("a legacy column with no users migrates", func(t *testing.T) { + db := legacyUsersDB(t, nil) + if err := database.Migrate(db, migrationPublicURL); err != nil { + t.Fatal(err) + } + }) +} diff --git a/test/users_api_qa_test.go b/test/users_api_qa_test.go new file mode 100644 index 0000000..a5b40c5 --- /dev/null +++ b/test/users_api_qa_test.go @@ -0,0 +1,39 @@ +package test + +import ( + "RenewCMS/api/pkg/apperr" + "net/http" + "testing" +) + +func TestUsersAPIQAGaps(t *testing.T) { + t.Run("renewing mails a new code and the old code no longer works", func(t *testing.T) { + w := newUsersAPIWorld(t) + before := w.invites.invites[2].HashedCode + if rec := w.do(t, uaAdmin, http.MethodPost, "/v1/user/invite/2/renew", ""); rec.Code != http.StatusOK { + t.Fatalf("unexpected %d %q", rec.Code, rec.Body.String()) + } + if w.invites.invites[2].HashedCode == before || len(w.mail.SentEmails) != 1 { + t.Fatalf("the code must change and be mailed") + } + rec := w.anonymous(t, http.MethodPost, "/v1/auth/invite/accept", `{"code": "code2", "username": "kim", "password": "password1"}`) + if rec.Code == http.StatusOK { + t.Errorf("the old code must not accept, got %d", rec.Code) + } + }) + + t.Run("accepting an invite whose role was deleted is invalid and creates no user", func(t *testing.T) { + w := newUsersAPIWorld(t) + temp := w.createTemp("content.read=own") + pending := w.invites.invites[3] + pending.RoleID = temp + w.invites.invites[3] = pending + delete(w.roles.roles, temp) + users := len(w.users.users) + rec := w.anonymous(t, http.MethodPost, "/v1/auth/invite/accept", `{"code": "code3", "username": "kim", "password": "password1"}`) + usersAPIAssertPlain(t, rec, http.StatusNotFound, apperr.InviteInvalidErrorMessage) + if len(w.users.users) != users { + t.Errorf("no user may be created") + } + }) +} diff --git a/test/users_api_usecases_test.go b/test/users_api_usecases_test.go new file mode 100644 index 0000000..19127f9 --- /dev/null +++ b/test/users_api_usecases_test.go @@ -0,0 +1,715 @@ +package test + +import ( + "RenewCMS/internal/domain/audit" + "RenewCMS/internal/domain/invite" + "RenewCMS/internal/domain/role" + "RenewCMS/internal/domain/user" + "RenewCMS/internal/infrastructure/useCases" + "errors" + "reflect" + "strconv" + "testing" + "time" +) + +func usersAPIEscalationKey(t *testing.T, err error) string { + t.Helper() + var escalation role.EscalationError + if !errors.As(err, &escalation) { + t.Fatalf("expected an escalation error, got %v", err) + } + return escalation.Key +} + +func (w *usersAPIWorld) roleIDFor(name string) uint32 { + switch name { + case "": + return 0 + case "unknown": + return uaUnknownRole + } + return w.ids[name] +} + +type usersAPIOutcome struct { + err error + escalation string +} + +func (o usersAPIOutcome) check(t *testing.T, err error) { + t.Helper() + switch { + case o.escalation != "": + if !errors.Is(err, role.ErrEscalation) || usersAPIEscalationKey(t, err) != o.escalation { + t.Fatalf("expected escalation on %q, got %v", o.escalation, err) + } + case o.err != nil: + if !errors.Is(err, o.err) { + t.Fatalf("expected %v, got %v", o.err, err) + } + default: + if err != nil { + t.Fatalf("expected success, got %v", err) + } + } +} + +func (o usersAPIOutcome) refused() bool { + return o.err != nil || o.escalation != "" +} + +func TestUsersAPIChangeUserRoleUseCase(t *testing.T) { + ok := usersAPIOutcome{} + rows := []struct { + name string + caller uint32 + target uint32 + role string + outcome usersAPIOutcome + }{ + {"Admin moves a Contributor to Writer", uaAdmin, 41, "Writer", ok}, + {"Admin covers Admin", uaAdmin, 41, "Admin", ok}, + {"Admin demotes another Admin", uaAdmin, uaOtherAdmin, "Contributor", ok}, + {"Owner moves an Admin to Content lead", uaOwner, uaAdmin, "Content lead", ok}, + {"People manager moves a Contributor to Intern", uaPeople, 41, "Intern", ok}, + {"People manager cannot give Writer", uaPeople, 41, "Writer", usersAPIOutcome{escalation: "content.update"}}, + {"People manager cannot give Content lead", uaPeople, 41, "Content lead", usersAPIOutcome{escalation: "content.update"}}, + {"People manager cannot touch a Content lead", uaPeople, uaContentLead, "Intern", usersAPIOutcome{escalation: "content.update"}}, + {"People manager cannot touch an Admin", uaPeople, uaAdmin, "Intern", usersAPIOutcome{escalation: "content.update"}}, + {"Admin cannot give Owner", uaAdmin, 41, "Owner", usersAPIOutcome{err: role.ErrRoleLocked}}, + {"Owner cannot give Owner", uaOwner, 41, "Owner", usersAPIOutcome{err: role.ErrRoleLocked}}, + {"Admin cannot change the owner", uaAdmin, uaOwner, "Admin", usersAPIOutcome{err: role.ErrRoleLocked}}, + {"Owner role is checked before the own account", uaOwner, uaOwner, "Admin", usersAPIOutcome{err: role.ErrRoleLocked}}, + {"Admin cannot change its own role", uaAdmin, uaAdmin, "Writer", usersAPIOutcome{err: role.ErrOwnRole}}, + {"People manager cannot change its own role", uaPeople, uaPeople, "Intern", usersAPIOutcome{err: role.ErrOwnRole}}, + {"unknown user", uaAdmin, 999, "Writer", usersAPIOutcome{err: user.ErrUserNotFound}}, + {"unknown role", uaAdmin, 41, "unknown", usersAPIOutcome{err: role.ErrUnknownRole}}, + {"role id zero", uaAdmin, 41, "", usersAPIOutcome{err: role.ErrUnknownRole}}, + {"unverified user may be given a role", uaAdmin, uaUnverified, "Writer", ok}, + {"unknown role before escalation on a higher role", uaPeople, 41, "unknown", usersAPIOutcome{err: role.ErrUnknownRole}}, + {"locked current role before an unknown role", uaAdmin, uaOwner, "unknown", usersAPIOutcome{err: role.ErrRoleLocked}}, + } + for _, row := range rows { + t.Run(row.name, func(t *testing.T) { + w := newUsersAPIWorld(t) + before := w.storedRoleID(row.target) + got, err := w.container.ChangeUserRoleUseCase.ChangeRole(testCtx, useCases.ChangeUserRoleCommand{Caller: w.caller(row.caller), TargetID: row.target, RoleID: w.roleIDFor(row.role)}) + row.outcome.check(t, err) + if row.outcome.refused() { + if w.users.updateRoleCalls != 0 { + t.Errorf("a refused call must not reach the repository write") + } + if len(w.recorder.events) != 0 { + t.Errorf("a refused call must record nothing, got %v", w.recorder.actions()) + } + if after := w.storedRoleID(row.target); after != before { + t.Errorf("role changed from %d to %d on a refusal", before, after) + } + return + } + if got.Role == nil || got.Role.ID != w.ids[row.role] || got.ID != row.target || got.Kind != useCases.EntryKindUser { + t.Errorf("unexpected result %+v", got) + } + if w.storedRoleID(row.target) != w.ids[row.role] { + t.Errorf("stored role is %d", w.storedRoleID(row.target)) + } + if before != w.ids[row.role] && len(w.recorder.events) != 1 { + t.Errorf("expected one event, got %v", w.recorder.actions()) + } + }) + } + + t.Run("giving the same role again writes nothing and records nothing", func(t *testing.T) { + w := newUsersAPIWorld(t) + got, err := w.container.ChangeUserRoleUseCase.ChangeRole(testCtx, useCases.ChangeUserRoleCommand{Caller: w.caller(uaAdmin), TargetID: 41, RoleID: w.id("Contributor")}) + if err != nil || got.Role == nil || got.Role.Name != "Contributor" { + t.Fatalf("unexpected %+v, %v", got, err) + } + if w.users.updateRoleCalls != 0 || len(w.recorder.events) != 0 { + t.Errorf("a no-op must not write or record") + } + }) + + t.Run("a same-role call by a caller who does not cover it is still an escalation", func(t *testing.T) { + w := newUsersAPIWorld(t) + changeOnly := w.createTemp("users.change_role=all") + w.addMember(70, "narrow", "narrow@renew.example", changeOnly, true) + _, err := w.container.ChangeUserRoleUseCase.ChangeRole(testCtx, useCases.ChangeUserRoleCommand{Caller: w.caller(70), TargetID: 41, RoleID: w.id("Contributor")}) + if !errors.Is(err, role.ErrEscalation) { + t.Fatalf("expected escalation, got %v", err) + } + }) + + t.Run("a stale read loses the compare-and-swap", func(t *testing.T) { + w := newUsersAPIWorld(t) + w.users.beforeUpdateRole = func() { + rival := w.users.users[41] + rival.RoleID = w.id("Intern") + w.users.users[41] = rival + } + _, err := w.container.ChangeUserRoleUseCase.ChangeRole(testCtx, useCases.ChangeUserRoleCommand{Caller: w.caller(uaAdmin), TargetID: 41, RoleID: w.id("Writer")}) + if !errors.Is(err, user.ErrRoleChanged) { + t.Fatalf("expected ErrRoleChanged, got %v", err) + } + if w.storedRoleID(41) != w.id("Intern") || len(w.recorder.events) != 0 { + t.Errorf("the winner must stay and nothing is recorded") + } + }) + + t.Run("a role deleted meanwhile is an invalid role", func(t *testing.T) { + w := newUsersAPIWorld(t) + temp := w.createTemp("content.read=own") + w.users.beforeUpdateRole = func() { delete(w.roles.roles, temp) } + _, err := w.container.ChangeUserRoleUseCase.ChangeRole(testCtx, useCases.ChangeUserRoleCommand{Caller: w.caller(uaAdmin), TargetID: 41, RoleID: temp}) + if !errors.Is(err, role.ErrUnknownRole) { + t.Fatalf("expected ErrUnknownRole, got %v", err) + } + if w.storedRoleID(41) != w.id("Contributor") || len(w.recorder.events) != 0 { + t.Errorf("the user must keep the Contributor role and nothing is recorded") + } + }) + + t.Run("the event names the roles and the user", func(t *testing.T) { + w := newUsersAPIWorld(t) + if _, err := w.container.ChangeUserRoleUseCase.ChangeRole(testCtx, useCases.ChangeUserRoleCommand{Caller: w.caller(uaAdmin), TargetID: 41, RoleID: w.id("Writer")}); err != nil { + t.Fatal(err) + } + event := w.recorder.events[0] + if event.Action != audit.ActionUserChangeRole || event.Category != audit.CategoryUsers || event.Outcome != audit.OutcomeSuccess || event.Security { + t.Errorf("unexpected event %+v", event) + } + if event.Target != audit.NewTarget(audit.TargetUser, 41, "user41") { + t.Errorf("unexpected target %+v", event.Target) + } + if len(event.Changes) != 1 || !reflect.DeepEqual(event.Changes[0], audit.TextChange(audit.FieldRole, "Contributor", "Writer")) { + t.Errorf("unexpected changes %+v", event.Changes) + } + if event.Params[audit.ParamFrom] != w.id("Contributor") || event.Params[audit.ParamTo] != w.id("Writer") { + t.Errorf("unexpected params %v", event.Params) + } + }) + + t.Run("a storage failure records a failed event", func(t *testing.T) { + w := newUsersAPIWorld(t) + w.users.fail.failNext("UpdateRole") + _, err := w.container.ChangeUserRoleUseCase.ChangeRole(testCtx, useCases.ChangeUserRoleCommand{Caller: w.caller(uaAdmin), TargetID: 41, RoleID: w.id("Writer")}) + if !errors.Is(err, errRepositoryFailure) { + t.Fatalf("expected the repository failure, got %v", err) + } + if len(w.recorder.events) != 1 || w.recorder.events[0].Outcome != audit.OutcomeFailed || w.recorder.events[0].FailureReason == nil { + t.Fatalf("expected one failed event, got %+v", w.recorder.events) + } + }) + + t.Run("a user whose role cannot be resolved can be repaired by a covering caller", func(t *testing.T) { + w := newUsersAPIWorld(t) + got, err := w.container.ChangeUserRoleUseCase.ChangeRole(testCtx, useCases.ChangeUserRoleCommand{Caller: w.caller(uaAdmin), TargetID: uaNoRole, RoleID: w.id("Writer")}) + if err != nil || got.Role == nil || got.Role.Name != "Writer" { + t.Fatalf("unexpected %+v, %v", got, err) + } + }) +} + +func TestUsersAPITransferOwnershipUseCase(t *testing.T) { + command := func(w *usersAPIWorld, target uint32, email string) useCases.TransferOwnershipCommand { + return useCases.TransferOwnershipCommand{Caller: w.caller(uaOwner), UserID: target, ConfirmEmail: email} + } + + t.Run("the owner role moves and the former owner becomes Admin", func(t *testing.T) { + w := newUsersAPIWorld(t) + got, err := w.container.TransferOwnershipUseCase.Transfer(testCtx, command(w, uaAdmin, "jonas@renew.example")) + if err != nil { + t.Fatal(err) + } + if w.storedRoleID(uaAdmin) != ownerRoleID || w.storedRoleID(uaOwner) != adminRoleID { + t.Errorf("roles after the transfer: %d and %d", w.storedRoleID(uaAdmin), w.storedRoleID(uaOwner)) + } + if got.Owner.ID != uaAdmin || got.Owner.Name != "jonas" || got.FormerOwner.ID != uaOwner || got.FormerOwner.Role.Name != "Admin" || got.FormerOwner.Role.ID != adminRoleID { + t.Errorf("unexpected result %+v", got) + } + owners := 0 + for _, member := range w.users.users { + if member.RoleID == ownerRoleID && member.IsVerified { + owners++ + } + } + if owners != 1 { + t.Errorf("expected exactly one owner, got %d", owners) + } + if w.users.users[uaAdmin].TokenVersion != 0 || w.users.users[uaOwner].TokenVersion != 0 { + t.Errorf("token versions must not change") + } + event := w.recorder.events[0] + if len(w.recorder.events) != 1 || event.Action != audit.ActionUserTransferOwnership || event.Category != audit.CategoryUsers || !event.Security || event.Outcome != audit.OutcomeSuccess { + t.Fatalf("unexpected events %+v", w.recorder.events) + } + if event.Target != audit.NewTarget(audit.TargetUser, uaAdmin, "jonas") || event.Params[audit.ParamFrom] != uaOwner || event.Params[audit.ParamTo] != uaAdmin { + t.Errorf("unexpected event %+v", event) + } + }) + + refusals := []struct { + name string + target uint32 + email string + want error + }{ + {"unknown user", 999, "x@renew.example", user.ErrInvalidTransfer}, + {"yourself", uaOwner, "marta@renew.example", user.ErrInvalidTransfer}, + {"an unverified user", uaUnverified, "sam@renew.example", user.ErrInvalidTransfer}, + {"a mismatching email", uaAdmin, "JONAS@renew.example", user.ErrConfirmationMismatch}, + {"an empty email", uaAdmin, "", user.ErrConfirmationMismatch}, + } + for _, row := range refusals { + t.Run("refuses "+row.name, func(t *testing.T) { + w := newUsersAPIWorld(t) + _, err := w.container.TransferOwnershipUseCase.Transfer(testCtx, command(w, row.target, row.email)) + if !errors.Is(err, row.want) { + t.Fatalf("expected %v, got %v", row.want, err) + } + if w.users.transferCalls != 0 || len(w.recorder.events) != 0 || w.storedRoleID(uaOwner) != ownerRoleID { + t.Errorf("a refusal must write and record nothing") + } + }) + } + + t.Run("a caller who lost the owner role meanwhile loses the swap", func(t *testing.T) { + w := newUsersAPIWorld(t) + w.users.beforeTransfer = func() { + _ = w.users.TransferOwnership(uaOwner, uaOtherAdmin, ownerRoleID, adminRoleID) + } + _, err := w.container.TransferOwnershipUseCase.Transfer(testCtx, command(w, uaAdmin, "jonas@renew.example")) + if !errors.Is(err, user.ErrRoleChanged) { + t.Fatalf("expected ErrRoleChanged, got %v", err) + } + if w.storedRoleID(uaOtherAdmin) != ownerRoleID || w.storedRoleID(uaAdmin) != adminRoleID || len(w.recorder.events) != 0 { + t.Errorf("the first transfer must stand and nothing is recorded") + } + }) + + t.Run("a storage failure changes nobody and records a failed event", func(t *testing.T) { + w := newUsersAPIWorld(t) + w.users.fail.failNext("TransferOwnership") + _, err := w.container.TransferOwnershipUseCase.Transfer(testCtx, command(w, uaAdmin, "jonas@renew.example")) + if !errors.Is(err, errRepositoryFailure) { + t.Fatalf("expected the repository failure, got %v", err) + } + if w.storedRoleID(uaOwner) != ownerRoleID || w.storedRoleID(uaAdmin) != adminRoleID { + t.Errorf("nobody must change") + } + if len(w.recorder.events) != 1 || w.recorder.events[0].Outcome != audit.OutcomeFailed || !w.recorder.events[0].Security { + t.Errorf("expected one failed security event, got %+v", w.recorder.events) + } + }) + + t.Run("a custom role named Admin is not the built-in Admin", func(t *testing.T) { + w := newUsersAPIWorld(t) + cmd := command(w, uaAdmin, "jonas@renew.example") + delete(w.roles.roles, adminRoleID) + _, _ = w.roles.Create(testRole("Admin")) + _, err := w.container.TransferOwnershipUseCase.Transfer(testCtx, cmd) + if !errors.Is(err, role.ErrRoleNotFound) || w.storedRoleID(uaOwner) != ownerRoleID || w.users.transferCalls != 0 { + t.Fatalf("expected ErrRoleNotFound and no write, got %v", err) + } + }) +} + +func testRole(name string) role.Role { + return role.Role{Name: name, Version: 1, Grants: grantRows("content.read=own")} +} + +func TestUsersAPICreateInviteUseCase(t *testing.T) { + ok := usersAPIOutcome{} + rows := []struct { + name string + caller uint32 + email string + role string + outcome usersAPIOutcome + }{ + {"default Writer", uaAdmin, "a@renew.example", "", ok}, + {"Contributor", uaAdmin, "a@renew.example", "Contributor", ok}, + {"Admin", uaAdmin, "a@renew.example", "Admin", ok}, + {"Owner gives a Content lead", uaOwner, "a@renew.example", "Content lead", ok}, + {"Owner role is locked", uaAdmin, "a@renew.example", "Owner", usersAPIOutcome{err: role.ErrRoleLocked}}, + {"unknown role", uaAdmin, "a@renew.example", "unknown", usersAPIOutcome{err: role.ErrUnknownRole}}, + {"People manager invites an Intern", uaPeople, "a@renew.example", "Intern", ok}, + {"People manager cannot invite the default Writer", uaPeople, "a@renew.example", "", usersAPIOutcome{escalation: "content.update"}}, + {"People manager cannot invite a Content lead", uaPeople, "a@renew.example", "Content lead", usersAPIOutcome{escalation: "content.update"}}, + {"Inviter cannot invite the default Writer", uaInviter, "a@renew.example", "", usersAPIOutcome{escalation: "content.read"}}, + {"pending invite", uaAdmin, "new3@renew.example", "", usersAPIOutcome{err: invite.ErrPendingInvite}}, + {"email with an account", uaAdmin, "ines@renew.example", "", usersAPIOutcome{err: invite.ErrEmailHasAccount}}, + } + for _, row := range rows { + t.Run(row.name, func(t *testing.T) { + w := newUsersAPIWorld(t) + invitesBefore := len(w.invites.invites) + got, err := w.container.CreateInviteUseCase.CreateInvite(testCtx, useCases.CreateInviteCommand{Caller: w.caller(row.caller), Email: row.email, InvitedBy: row.caller, RoleID: w.roleIDFor(row.role)}) + row.outcome.check(t, err) + if row.outcome.refused() { + if len(w.invites.invites) != invitesBefore || len(w.mail.SentEmails) != 0 { + t.Errorf("a refusal must create no invite and send no mail") + } + return + } + wantRole := row.role + if wantRole == "" { + wantRole = "Writer" + } + if got.RoleID != w.ids[wantRole] || w.invites.invites[got.ID].RoleID != w.ids[wantRole] { + t.Errorf("invite role is %d, want %s", got.RoleID, wantRole) + } + if len(w.mail.SentEmails) != 1 || w.mail.SentEmails[0].Template != "invitation" { + t.Errorf("expected one invitation mail, got %+v", w.mail.SentEmails) + } + params := w.recorder.events[len(w.recorder.events)-1].Params + if params[audit.ParamRoleID] != w.ids[wantRole] || params[audit.ParamRole] != wantRole { + t.Errorf("unexpected invite.create params %v", params) + } + }) + } + + t.Run("a refused role leaves the expired invite in place", func(t *testing.T) { + w := newUsersAPIWorld(t) + _, err := w.container.CreateInviteUseCase.CreateInvite(testCtx, useCases.CreateInviteCommand{Caller: w.caller(uaAdmin), Email: "old2@renew.example", InvitedBy: uaAdmin, RoleID: uaUnknownRole}) + if !errors.Is(err, role.ErrUnknownRole) { + t.Fatalf("expected ErrUnknownRole, got %v", err) + } + if _, present := w.invites.invites[2]; !present { + t.Errorf("expired invite 2 must not be deleted") + } + }) + + t.Run("an expired invite is replaced by a new one for the requested role", func(t *testing.T) { + w := newUsersAPIWorld(t) + got, err := w.container.CreateInviteUseCase.CreateInvite(testCtx, useCases.CreateInviteCommand{Caller: w.caller(uaAdmin), Email: "old2@renew.example", InvitedBy: uaAdmin, RoleID: writerRoleID}) + if err != nil { + t.Fatal(err) + } + if _, present := w.invites.invites[2]; present || got.RoleID != writerRoleID { + t.Errorf("expired invite 2 must be replaced for Writer") + } + }) + + t.Run("a failed mail rolls the invite back", func(t *testing.T) { + w := newUsersAPIWorld(t) + w.mail.fail.failNext("Send") + _, err := w.container.CreateInviteUseCase.CreateInvite(testCtx, useCases.CreateInviteCommand{Caller: w.caller(uaAdmin), Email: "a@renew.example", InvitedBy: uaAdmin}) + if !errors.Is(err, invite.ErrInviteEmailNotSent) || len(w.invites.invites) != 4 { + t.Fatalf("expected a rollback, got %v with %d invites", err, len(w.invites.invites)) + } + }) +} + +func TestUsersAPIRenewAndCancelInviteUseCases(t *testing.T) { + renew := func(w *usersAPIWorld, caller uint32, id uint32) (useCases.InviteView, error) { + return w.container.RenewInviteUseCase.RenewInvite(testCtx, useCases.RenewInviteCommand{Caller: w.caller(caller), ID: id, RenewedBy: caller}) + } + + t.Run("renewing keeps the role of an expired invite", func(t *testing.T) { + w := newUsersAPIWorld(t) + got, err := renew(w, uaAdmin, 2) + if err != nil || got.RoleID != w.id("Contributor") || got.StatusAt(time.Now()) != invite.StatusPending { + t.Fatalf("unexpected %+v, %v", got, err) + } + if len(w.mail.SentEmails) != 1 { + t.Errorf("a new code must be mailed") + } + }) + + t.Run("People manager renews a Contributor invite", func(t *testing.T) { + w := newUsersAPIWorld(t) + if _, err := renew(w, uaPeople, 3); err != nil { + t.Fatal(err) + } + }) + + t.Run("People manager cannot renew a Writer invite", func(t *testing.T) { + w := newUsersAPIWorld(t) + before := w.invites.invites[1] + _, err := renew(w, uaPeople, 1) + if usersAPIEscalationKey(t, err) != "content.update" { + t.Fatalf("unexpected %v", err) + } + if w.invites.invites[1].HashedCode != before.HashedCode || len(w.mail.SentEmails) != 0 { + t.Errorf("nothing may be renewed or mailed") + } + }) + + t.Run("not found comes before the coverage check", func(t *testing.T) { + w := newUsersAPIWorld(t) + if _, err := renew(w, uaPeople, 999); !errors.Is(err, invite.ErrInviteNotFound) { + t.Fatalf("unexpected %v", err) + } + }) + + t.Run("an accepted invite is a conflict before the coverage check", func(t *testing.T) { + w := newUsersAPIWorld(t) + if _, err := renew(w, uaPeople, 4); !errors.Is(err, invite.ErrInviteAlreadyAccepted) { + t.Fatalf("unexpected %v", err) + } + }) + + t.Run("cancelling needs no coverage", func(t *testing.T) { + w := newUsersAPIWorld(t) + if err := w.container.CancelInviteUseCase.CancelInvite(testCtx, 1); err != nil { + t.Fatal(err) + } + if _, present := w.invites.invites[1]; present { + t.Errorf("invite 1 must be gone") + } + }) +} + +func TestUsersAPIAcceptInviteUseCase(t *testing.T) { + for _, name := range []string{"Writer", "Contributor", "Admin"} { + t.Run("the new user gets the role of the invite: "+name, func(t *testing.T) { + w := newUsersAPIWorld(t) + pending := w.invites.invites[3] + pending.RoleID = w.ids[name] + w.invites.invites[3] = pending + created, err := w.container.AcceptInviteUseCase.AcceptInvite(testCtx, useCases.AcceptInviteCommand{Code: "code3", Username: "kim", Password: "password1"}) + if err != nil { + t.Fatal(err) + } + stored := w.users.users[created.ID] + if stored.RoleID != w.ids[name] || !stored.IsVerified || stored.Username != "kim" { + t.Errorf("unexpected stored user %+v", stored) + } + params := w.recorder.events[len(w.recorder.events)-1].Params + if params[audit.ParamRoleID] != w.ids[name] || params[audit.ParamRole] != name { + t.Errorf("unexpected invite.accept params %v", params) + } + }) + } + + for name, roleID := range map[string]uint32{"role id zero": 0, "a deleted role": 99} { + t.Run("an invite with "+name+" is invalid", func(t *testing.T) { + w := newUsersAPIWorld(t) + pending := w.invites.invites[3] + pending.RoleID = roleID + w.invites.invites[3] = pending + usersBefore := len(w.users.users) + _, err := w.container.AcceptInviteUseCase.AcceptInvite(testCtx, useCases.AcceptInviteCommand{Code: "code3", Username: "kim", Password: "password1"}) + if !errors.Is(err, invite.ErrInviteInvalid) { + t.Fatalf("expected ErrInviteInvalid, got %v", err) + } + if len(w.users.users) != usersBefore || w.invites.invites[3].AcceptedAt != nil { + t.Errorf("no user may be created and the invite stays open") + } + last := w.recorder.events[len(w.recorder.events)-1] + if last.Action != audit.ActionInviteAccept || last.Outcome != audit.OutcomeFailed { + t.Errorf("expected a failed invite.accept event, got %+v", last) + } + }) + } + + t.Run("grants edited before the accept are what the new user gets", func(t *testing.T) { + w := newUsersAPIWorld(t) + contributor := w.roles.roles[w.id("Contributor")] + contributor.Grants = grantRows("content.read=all", "media.view=all") + w.roles.roles[contributor.ID] = contributor + created, err := w.container.AcceptInviteUseCase.AcceptInvite(testCtx, useCases.AcceptInviteCommand{Code: "code3", Username: "kim", Password: "password1"}) + if err != nil { + t.Fatal(err) + } + access, err := w.container.GetMyAccessUseCase.Get(w.users.users[created.ID]) + if err != nil || access.Role.Name != "Contributor" || len(access.Permissions) != 2 { + t.Fatalf("unexpected access %+v, %v", access, err) + } + }) +} + +func TestUsersAPICreateUserUseCase(t *testing.T) { + t.Run("registration stores the Owner role id", func(t *testing.T) { + w := newUsersAPIWorld(t) + w.users.users = map[uint32]user.User{} + created, err := w.container.CreateUserUseCase.CreateUser(testCtx, useCases.CreateUserCommand{Username: "first", Password: "password1", Email: "first@renew.example"}) + if err != nil { + t.Fatal(err) + } + stored := w.users.users[created.ID] + if stored.RoleID != ownerRoleID || stored.IsVerified { + t.Errorf("unexpected stored user %+v", stored) + } + }) + + t.Run("an unseeded Owner role fails and creates no user", func(t *testing.T) { + users := newMockUserRepo() + uc := useCases.NewCreateUserUseCase(users, newMockRoleRepo(), &stubHasher{}, newMockMailRepo(), "https://cms.example.com", discardRecorder) + _, err := uc.CreateUser(testCtx, useCases.CreateUserCommand{Username: "first", Password: "password1", Email: "first@renew.example"}) + if !errors.Is(err, role.ErrRoleNotFound) || len(users.users) != 0 { + t.Fatalf("expected ErrRoleNotFound with no user, got %v and %d users", err, len(users.users)) + } + }) + + t.Run("a failed verification mail and a failed cleanup report both errors", func(t *testing.T) { + users := newMockUserRepo() + mailer := newMockMailRepo() + cleanup := errors.New("cleanup failed") + mailer.fail.failNextWith("Send", errors.New("smtp down")) + users.fail.failNextWith("Delete", cleanup) + uc := useCases.NewCreateUserUseCase(users, newSeededMockRoleRepo(), &stubHasher{}, mailer, "https://cms.example.com", discardRecorder) + _, err := uc.CreateUser(testCtx, useCases.CreateUserCommand{Username: "first", Password: "password1", Email: "first@renew.example"}) + if !errors.Is(err, user.ErrVerificationEmailNotSent) || !errors.Is(err, cleanup) { + t.Fatalf("expected both failures, got %v", err) + } + }) + + t.Run("a custom role named Owner is not the built-in Owner", func(t *testing.T) { + roles := newMockRoleRepo() + _, _ = roles.Create(role.Role{Name: role.OwnerName, Version: 1}) + users := newMockUserRepo() + uc := useCases.NewCreateUserUseCase(users, roles, &stubHasher{}, newMockMailRepo(), "https://cms.example.com", discardRecorder) + if _, err := uc.CreateUser(testCtx, useCases.CreateUserCommand{Username: "first", Password: "password1", Email: "first@renew.example"}); !errors.Is(err, role.ErrRoleNotFound) { + t.Fatalf("expected ErrRoleNotFound, got %v", err) + } + }) +} + +func TestUsersAPIListUsersAndInvitesUseCase(t *testing.T) { + t.Run("roles are read once and resolved per row", func(t *testing.T) { + w := newUsersAPIWorld(t) + w.roles.calls = map[string]int{} + entries, err := w.container.ListUsersAndInvitesUseCase.ListUsersAndInvites() + if err != nil { + t.Fatal(err) + } + if w.roles.calls["List"] != 1 || w.roles.calls["Get"] != 0 || w.roles.calls["GetByName"] != 0 { + t.Errorf("expected one List call, got %v", w.roles.calls) + } + byKey := map[string]useCases.DirectoryEntry{} + for _, entry := range entries { + byKey[entry.Kind+strconv.Itoa(int(entry.ID))] = entry + } + if owner := byKey["user3"]; owner.Role == nil || owner.Role.Name != "Owner" || !owner.Role.BuiltIn || !owner.Role.Locked || owner.Role.ID != ownerRoleID { + t.Errorf("unexpected owner entry %+v", owner) + } + if byKey["user13"].Role != nil || byKey["user14"].Role != nil { + t.Errorf("users without a usable role must show no role") + } + if byKey["invite2"].Role == nil || byKey["invite2"].Role.Name != "Contributor" || byKey["invite1"].Role.Name != "Writer" { + t.Errorf("invites must carry their role") + } + if _, present := byKey["invite4"]; present { + t.Errorf("accepted invites are history") + } + }) + + t.Run("a role list failure is an error", func(t *testing.T) { + w := newUsersAPIWorld(t) + w.roles.fail.failNext("List") + if _, err := w.container.ListUsersAndInvitesUseCase.ListUsersAndInvites(); !errors.Is(err, errRepositoryFailure) { + t.Fatalf("unexpected %v", err) + } + }) +} + +func TestUsersAPIResolution(t *testing.T) { + t.Run("role id zero and a deleted role resolve to nothing", func(t *testing.T) { + w := newUsersAPIWorld(t) + for _, id := range []uint32{uaNoRole, uaGhostRole} { + account := w.users.users[id] + if _, err := w.container.GetMyAccessUseCase.Get(account); !errors.Is(err, role.ErrRoleNotFound) { + t.Errorf("user %d: expected ErrRoleNotFound, got %v", id, err) + } + grants, err := w.container.CheckPermissionUseCase.Grants(account) + if err != nil || len(grants.Effective()) != 0 { + t.Errorf("user %d: expected an empty grant set, got %v, %v", id, grants.Effective(), err) + } + if _, err := w.container.CheckPermissionUseCase.Require(account, "content.read"); !errors.Is(err, role.ErrMissingPermission) { + t.Errorf("user %d: expected a missing permission, got %v", id, err) + } + } + }) +} + +func TestUsersAPIRoleMembers(t *testing.T) { + t.Run("members count users and open invites with one bulk call", func(t *testing.T) { + w := newUsersAPIWorld(t) + w.roles.calls = map[string]int{} + w.users.getAllCalls = 0 + listed, err := w.container.ListRolesUseCase.List() + if err != nil { + t.Fatal(err) + } + if w.roles.calls["MemberCounts"] != 1 || w.users.getAllCalls != 0 { + t.Errorf("expected one MemberCounts call and no GetAll, got %v and %d", w.roles.calls, w.users.getAllCalls) + } + want := map[string]int{"Owner": 1, "Admin": 3, "Writer": 2, "Contributor": 6, "Intern": 1, "Content lead": 1, "People manager": 1, "Viewer": 1, "Inviter": 1} + for _, view := range listed { + if view.Members != want[view.Name] { + t.Errorf("%s: expected %d members, got %d", view.Name, want[view.Name], view.Members) + } + } + }) + + for _, state := range []string{"pending", "expired"} { + t.Run("a role with only a "+state+" invite cannot be deleted, then can be moved and deleted", func(t *testing.T) { + w := newUsersAPIWorld(t) + temp := w.createTemp("content.read=own") + now := time.Now() + expires := now.Add(48 * time.Hour) + if state == "expired" { + expires = now.Add(-time.Hour) + } + w.invites.seed(invite.Invite{Email: "temp@renew.example", HashedCode: "hashed:t", InvitedBy: uaAdmin, RoleID: temp, CreatedAt: now.Add(-time.Hour), ExpiresAt: expires}) + err := w.container.DeleteRoleUseCase.Delete(testCtx, useCases.DeleteRoleCommand{Caller: w.caller(uaAdmin), ID: temp}) + if !errors.Is(err, role.ErrRoleHasMembers) { + t.Fatalf("expected ErrRoleHasMembers, got %v", err) + } + moved, err := w.container.ReassignRoleMembersUseCase.Reassign(testCtx, useCases.ReassignRoleMembersCommand{Caller: w.caller(uaAdmin), SourceID: temp, TargetID: w.id("Intern")}) + if err != nil || moved != 1 { + t.Fatalf("expected one moved invite, got %d, %v", moved, err) + } + if w.invites.invites[5].RoleID != w.id("Intern") { + t.Errorf("the invite must now be for Intern") + } + last := w.recorder.events[len(w.recorder.events)-1] + if last.Action != audit.ActionRoleReassign || last.Params[audit.ParamMoved] != 1 { + t.Errorf("unexpected reassign event %+v", last) + } + if err := w.container.DeleteRoleUseCase.Delete(testCtx, useCases.DeleteRoleCommand{Caller: w.caller(uaAdmin), ID: temp}); err != nil { + t.Fatalf("expected the delete to pass, got %v", err) + } + }) + } + + t.Run("moved counts users and invites together", func(t *testing.T) { + w := newUsersAPIWorld(t) + moved, err := w.container.ReassignRoleMembersUseCase.Reassign(testCtx, useCases.ReassignRoleMembersCommand{Caller: w.caller(uaAdmin), SourceID: w.id("Contributor"), TargetID: w.id("Intern")}) + if err != nil || moved != 6 { + t.Fatalf("expected 6, got %d, %v", moved, err) + } + }) + + t.Run("an accepted invite does not block the delete", func(t *testing.T) { + w := newUsersAPIWorld(t) + temp := w.createTemp("content.read=own") + accepted := time.Now() + w.invites.seed(invite.Invite{Email: "done@renew.example", HashedCode: "hashed:d", InvitedBy: uaAdmin, RoleID: temp, CreatedAt: accepted, ExpiresAt: accepted, AcceptedAt: &accepted}) + if err := w.container.DeleteRoleUseCase.Delete(testCtx, useCases.DeleteRoleCommand{Caller: w.caller(uaAdmin), ID: temp}); err != nil { + t.Fatalf("expected the delete to pass, got %v", err) + } + }) +} + +func TestUsersAPIActivityVocabulary(t *testing.T) { + t.Run("the new actions, field and params validate", func(t *testing.T) { + for _, action := range []string{audit.ActionUserChangeRole, audit.ActionUserTransferOwnership} { + event := audit.NewEvent(action, audit.CategoryUsers) + event.Actor = audit.SystemActor() + event.Source = audit.SourceWeb + event.Changes = append(event.Changes, audit.TextChange(audit.FieldRole, "a", "b")) + event.Params[audit.ParamRole] = "Writer" + event.Params[audit.ParamRoleID] = uint32(3) + if err := event.Validate(); err != nil { + t.Errorf("%s: %v", action, err) + } + } + if audit.ActionUserChangeRole != "user.change_role" || audit.ActionUserTransferOwnership != "user.transfer_ownership" || audit.FieldRole != "role" || audit.ParamRole != "role" || audit.ParamRoleID != "role_id" { + t.Errorf("unexpected vocabulary") + } + }) +} diff --git a/test/users_api_world_test.go b/test/users_api_world_test.go new file mode 100644 index 0000000..9059d90 --- /dev/null +++ b/test/users_api_world_test.go @@ -0,0 +1,183 @@ +package test + +import ( + "RenewCMS/internal/domain/contenttype" + "RenewCMS/internal/domain/invite" + "RenewCMS/internal/domain/role" + "RenewCMS/internal/domain/user" + "RenewCMS/internal/infrastructure/useCases" + "encoding/json" + "net/http" + "net/http/httptest" + "strconv" + "testing" + "time" +) + +const ( + uaOwner uint32 = 3 + uaAdmin uint32 = 5 + uaWriter uint32 = 9 + uaOtherAdmin uint32 = 10 + uaUnverified uint32 = 12 + uaNoRole uint32 = 13 + uaGhostRole uint32 = 14 + uaPeople uint32 = 31 + uaViewer uint32 = 32 + uaInviter uint32 = 33 + uaContributor uint32 = 41 + uaContentLead uint32 = 45 + uaIntern uint32 = 46 + uaUnknownRole uint32 = 999 +) + +type usersAPIRoleSpec struct { + name string + grants []string + members []uint32 +} + +var usersAPICustomRoles = []usersAPIRoleSpec{ + {"Contributor", []string{"content.read=all", "content.create=all", "content.update=own"}, []uint32{41, 42, 43, 44}}, + {"Intern", []string{"content.read=own"}, []uint32{uaIntern}}, + {"Content lead", []string{"content.read=all", "content.update=all", "content.publish=all", "content.delete=all"}, []uint32{uaContentLead}}, + {"People manager", []string{"users.view=all", "users.invite=all", "users.change_role=all", "content.read=all", "content.create=all", "content.update=own", "content.publish=own", "schema.view=all"}, []uint32{uaPeople}}, + {"Viewer", []string{"users.view=all", "roles.view=all"}, []uint32{uaViewer}}, + {"Inviter", []string{"users.invite=all"}, []uint32{uaInviter}}, +} + +type usersAPIWorld struct { + *apiKeyEnv + recorder *contentScopeRecorder + types *mockContentTypeRepo + ids map[string]uint32 +} + +func newUsersAPIWorld(t *testing.T) *usersAPIWorld { + t.Helper() + recorder := &contentScopeRecorder{} + env := newAPIKeyEnvRecording(t, recorder) + world := &usersAPIWorld{apiKeyEnv: env, recorder: recorder, types: newMockContentTypeRepo(), ids: map[string]uint32{}} + env.users.users = map[uint32]user.User{} + env.users.nextID = 100 + env.users.knownRoles = env.roles + env.roles.users = env.users + env.roles.invites = env.invites + world.seedUsers() + world.seedInvites() + world.seedContentTypes() + world.wire() + return world +} + +func (w *usersAPIWorld) addMember(id uint32, username string, email string, roleID uint32, verified bool) { + w.users.users[id] = user.User{ID: id, Username: username, Email: email, IsVerified: verified, RoleID: roleID, Password: "hashed:password1"} +} + +func (w *usersAPIWorld) seedUsers() { + w.ids["Owner"], w.ids["Admin"], w.ids["Writer"] = ownerRoleID, adminRoleID, writerRoleID + w.addMember(uaOwner, "marta", "marta@renew.example", ownerRoleID, true) + w.addMember(uaAdmin, "jonas", "jonas@renew.example", adminRoleID, true) + w.addMember(uaOtherAdmin, "lea", "lea@renew.example", adminRoleID, true) + w.addMember(uaUnverified, "sam", "sam@renew.example", adminRoleID, false) + w.addMember(uaWriter, "ines", "ines@renew.example", writerRoleID, true) + w.addMember(uaNoRole, "zero", "zero@renew.example", 0, true) + w.addMember(uaGhostRole, "ghost", "ghost@renew.example", 99, true) + for _, spec := range usersAPICustomRoles { + created, _ := w.roles.Create(role.Role{Name: spec.name, Version: 1, Grants: grantRows(spec.grants...)}) + w.ids[spec.name] = created.ID + for _, member := range spec.members { + w.addMember(member, "user"+strconv.Itoa(int(member)), "user"+strconv.Itoa(int(member))+"@renew.example", created.ID, true) + } + } +} + +func (w *usersAPIWorld) seedInvites() { + now := time.Now() + accepted := now.Add(-24 * time.Hour) + w.invites.seed(invite.Invite{Email: "new1@renew.example", HashedCode: "hashed:code1", InvitedBy: uaAdmin, RoleID: writerRoleID, CreatedAt: now.Add(-time.Hour), ExpiresAt: now.Add(48 * time.Hour)}) + w.invites.seed(invite.Invite{Email: "old2@renew.example", HashedCode: "hashed:code2", InvitedBy: uaAdmin, RoleID: w.ids["Contributor"], CreatedAt: now.Add(-73 * time.Hour), ExpiresAt: now.Add(-time.Hour)}) + w.invites.seed(invite.Invite{Email: "new3@renew.example", HashedCode: "hashed:code3", InvitedBy: uaAdmin, RoleID: w.ids["Contributor"], CreatedAt: now.Add(-2 * time.Hour), ExpiresAt: now.Add(48 * time.Hour)}) + w.invites.seed(invite.Invite{Email: "ines@renew.example", HashedCode: "hashed:code4", InvitedBy: uaAdmin, RoleID: writerRoleID, CreatedAt: now.Add(-48 * time.Hour), ExpiresAt: now.Add(24 * time.Hour), AcceptedAt: &accepted}) +} + +func (w *usersAPIWorld) seedContentTypes() { + title := contenttype.Field{Key: "title", Label: "Title", Type: contenttype.TypeText, Required: true, Options: []string{}} + w.types.types[1] = contenttype.ContentType{ID: 1, Key: "events", Name: "Events", Fields: []contenttype.Field{title}} + w.types.types[2] = contenttype.ContentType{ID: 2, Key: "venues", Name: "Venues", Fields: []contenttype.Field{title}} + w.types.nextID = 10 +} + +func (w *usersAPIWorld) wire() { + c := w.container + hasher := &stubHasher{} + url := "https://cms.example.com" + c.ChangeUserRoleUseCase = useCases.NewChangeUserRoleUseCase(w.users, w.roles, w.types, w.recorder) + c.TransferOwnershipUseCase = useCases.NewTransferOwnershipUseCase(w.users, w.roles, w.recorder) + c.CreateInviteUseCase = useCases.NewCreateInviteUseCase(w.users, w.invites, w.roles, w.types, hasher, w.mail, url, w.recorder) + c.RenewInviteUseCase = useCases.NewRenewInviteUseCase(w.users, w.invites, w.roles, w.types, hasher, w.mail, url, w.recorder) + c.CancelInviteUseCase = useCases.NewCancelInviteUseCase(w.invites, w.recorder) + c.ListUsersAndInvitesUseCase = useCases.NewListUsersAndInvitesUseCase(w.users, w.invites, w.roles) + c.AcceptInviteUseCase = useCases.NewAcceptInviteUseCase(w.users, w.invites, w.roles, hasher, w.recorder) + c.CreateUserUseCase = useCases.NewCreateUserUseCase(w.users, w.roles, hasher, w.mail, url, w.recorder) + c.ListRolesUseCase = useCases.NewListRolesUseCase(w.roles, w.users, w.types) + c.GetRoleUseCase = useCases.NewGetRoleUseCase(w.roles, w.users, w.types) + c.CreateRoleUseCase = useCases.NewCreateRoleUseCase(w.roles, w.users, w.types, w.recorder) + c.DeleteRoleUseCase = useCases.NewDeleteRoleUseCase(w.roles, w.users, w.types, w.recorder) + c.ReassignRoleMembersUseCase = useCases.NewReassignRoleMembersUseCase(w.roles, w.users, w.types, w.recorder) + w.rebuildRouter() +} + +func (w *usersAPIWorld) id(name string) uint32 { + return w.ids[name] +} + +func (w *usersAPIWorld) idText(name string) string { + return strconv.FormatUint(uint64(w.ids[name]), 10) +} + +func (w *usersAPIWorld) caller(userID uint32) useCases.ContentCaller { + return useCases.ContentCaller{UserID: userID, Grants: w.roles.roles[w.users.users[userID].RoleID].GrantSet()} +} + +func (w *usersAPIWorld) do(t *testing.T, caller uint32, method string, path string, body string) *httptest.ResponseRecorder { + t.Helper() + return w.send(t, apiKeyRequest{method: method, path: path, body: body, cookieUser: caller}) +} + +func (w *usersAPIWorld) putRole(t *testing.T, caller uint32, target string, roleID string) *httptest.ResponseRecorder { + t.Helper() + return w.do(t, caller, http.MethodPut, "/v1/user/"+target+"/role", `{"role_id": `+roleID+`}`) +} + +func (w *usersAPIWorld) storedRoleID(userID uint32) uint32 { + return w.users.users[userID].RoleID +} + +func (w *usersAPIWorld) createTemp(grants ...string) uint32 { + created, _ := w.roles.Create(role.Role{Name: "Temp", Version: 1, Grants: grantRows(grants...)}) + return created.ID +} + +func usersAPIDecode(t *testing.T, rec *httptest.ResponseRecorder) map[string]any { + t.Helper() + var object map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &object); err != nil { + t.Fatalf("expected a JSON object, got %q: %v", rec.Body.String(), err) + } + return object +} + +func usersAPIRoleRef(t *testing.T, object map[string]any) map[string]any { + t.Helper() + ref, ok := object["role"].(map[string]any) + if !ok { + t.Fatalf("expected role to be an object, got %v", object["role"]) + } + apiKeyAssertExactFields(t, ref, "id", "name", "built_in", "locked") + return ref +} + +func usersAPIEventActions(w *usersAPIWorld) []string { + return w.recorder.actions() +}