feat: more role backend and admin/writer roles additions and adjustments
This commit is contained in:
1 parent
8c6c6456da
commit
de142f6c5f
131 files changed
+2656
-428
No files matched your search
@@ -64,6 +64,7 @@ type UseCases struct {
|
||||
DeleteAuthorUseCase *useCases.DeleteAuthorUseCase
|
||||
GetMyAuthorUseCase *useCases.GetMyAuthorUseCase
|
||||
GetMyAccessUseCase *useCases.GetMyAccessUseCase
|
||||
CheckPermissionUseCase *useCases.CheckPermissionUseCase
|
||||
ListPermissionsUseCase *useCases.ListPermissionsUseCase
|
||||
ListSitesUseCase *useCases.ListSitesUseCase
|
||||
CreateSiteUseCase *useCases.CreateSiteUseCase
|
||||
@@ -372,6 +373,7 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings
|
||||
DeleteAuthorUseCase: useCases.NewDeleteAuthorUseCase(repos.AuthorRepo, recorder),
|
||||
GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(repos.AuthorRepo),
|
||||
GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(repos.RoleRepo),
|
||||
CheckPermissionUseCase: useCases.NewCheckPermissionUseCase(repos.RoleRepo),
|
||||
ListPermissionsUseCase: useCases.NewListPermissionsUseCase(),
|
||||
ListSitesUseCase: useCases.NewListSitesUseCase(repos.SiteRepo),
|
||||
CreateSiteUseCase: useCases.NewCreateSiteUseCase(repos.SiteRepo, repos.ImageRepo, recorder),
|
||||
@@ -426,7 +428,7 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings
|
||||
RecordRunningInfoUseCase: useCases.NewRecordRunningInfoUseCase(repos.RunningInfo, settings.Version, os.Getpid(), time.Now),
|
||||
CreateAPIKeyUseCase: useCases.NewCreateAPIKeyUseCase(repos.APIKeyRepo, keyGenerator, time.Now, recorder),
|
||||
ListAPIKeysUseCase: useCases.NewListAPIKeysUseCase(repos.APIKeyRepo, repos.UserRepo, time.Now),
|
||||
RevokeAPIKeyUseCase: useCases.NewRevokeAPIKeyUseCase(repos.APIKeyRepo, repos.UserRepo, time.Now, recorder),
|
||||
RevokeAPIKeyUseCase: useCases.NewRevokeAPIKeyUseCase(repos.APIKeyRepo, time.Now, recorder),
|
||||
AuthenticateAPIKeyUseCase: useCases.NewAuthenticateAPIKeyUseCase(repos.APIKeyRepo, keyGenerator, repos.UserRepo, time.Now),
|
||||
ListActivityUseCase: useCases.NewListActivityUseCase(repos.AuditRepo, repos.UserRepo, repos.ArticleRepo, repos.ImageRepo, repos.APIKeyRepo, time.Now, repos.SiteRepo, repos.AuthorRepo),
|
||||
GetActivitySummaryUseCase: useCases.NewGetActivitySummaryUseCase(repos.AuditRepo, time.Now),
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
package activity
|
||||
|
||||
import (
|
||||
"RenewCMS/api/pkg/access"
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
"RenewCMS/api/pkg/claims"
|
||||
validator "RenewCMS/api/pkg/validator"
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"RenewCMS/internal/domain/role"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -134,9 +135,8 @@ func writeActivityError(w http.ResponseWriter, err error) {
|
||||
case errors.Is(err, audit.ErrInvalidFilter):
|
||||
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
|
||||
case errors.Is(err, audit.ErrActorNotAllowed):
|
||||
http.Error(w, apperr.OwnerOrAdminRequiredErrorMessage, http.StatusForbidden)
|
||||
case errors.Is(err, user.ErrUserNotFound):
|
||||
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
|
||||
access.WriteMissingPermission(w, role.KeyActivityView)
|
||||
case access.WriteIfMissingPermission(w, err):
|
||||
default:
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
}
|
||||
@@ -156,7 +156,7 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
activityQuery := toActivityQuery(query.filterQuery)
|
||||
activityQuery.Page, activityQuery.Size = query.Page, query.Size
|
||||
page, err := h.ListUseCase.ListActivity(useCases.ListActivityCommand{ViewerID: viewerId, Query: activityQuery})
|
||||
page, err := h.ListUseCase.ListActivity(useCases.ListActivityCommand{ViewerID: viewerId, Grants: access.Grants(r), Query: activityQuery})
|
||||
if err != nil {
|
||||
writeActivityError(w, err)
|
||||
return
|
||||
@@ -220,7 +220,13 @@ func (h *Handler) Export(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
prepared, err := h.ExportUseCase.Prepare(toActivityQuery(query))
|
||||
viewerId, ok := claims.UserId(r)
|
||||
if !ok {
|
||||
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
prepared, err := h.ExportUseCase.Prepare(useCases.ExportActivityCommand{ViewerID: viewerId, Grants: access.Grants(r), Query: toActivityQuery(query)})
|
||||
if err != nil {
|
||||
writeActivityError(w, err)
|
||||
return
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package apikey
|
||||
|
||||
import (
|
||||
"RenewCMS/api/pkg/access"
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
"RenewCMS/api/pkg/claims"
|
||||
validator "RenewCMS/api/pkg/validator"
|
||||
@@ -109,8 +110,11 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
entries, err := h.ListUseCase.ListAPIKeys(userId)
|
||||
entries, err := h.ListUseCase.ListAPIKeys(userId, access.Grants(r))
|
||||
if err != nil {
|
||||
if access.WriteIfMissingPermission(w, err) {
|
||||
return
|
||||
}
|
||||
if errors.Is(err, user.ErrUserNotFound) {
|
||||
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
|
||||
return
|
||||
@@ -161,8 +165,9 @@ func (h *Handler) Revoke(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.RevokeUseCase.RevokeAPIKey(r.Context(), userId, uint32(id)); err != nil {
|
||||
if err := h.RevokeUseCase.RevokeAPIKey(r.Context(), userId, uint32(id), access.Grants(r)); err != nil {
|
||||
switch {
|
||||
case access.WriteIfMissingPermission(w, err):
|
||||
case errors.Is(err, domain.ErrAPIKeyNotFound):
|
||||
http.Error(w, apperr.APIKeyNotFoundErrorMessage, http.StatusNotFound)
|
||||
case errors.Is(err, user.ErrUserNotFound):
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package author
|
||||
|
||||
import (
|
||||
"RenewCMS/api/pkg/access"
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
"RenewCMS/api/pkg/claims"
|
||||
validator "RenewCMS/api/pkg/validator"
|
||||
@@ -33,11 +34,10 @@ type Handler struct {
|
||||
CreateMyUseCase *useCases.CreateMyAuthorUseCase
|
||||
UpdateUseCase *useCases.UpdateAuthorUseCase
|
||||
DeleteUseCase *useCases.DeleteAuthorUseCase
|
||||
GetUserUseCase *useCases.GetUserUseCase
|
||||
}
|
||||
|
||||
func NewHandler(list *useCases.ListAuthorsUseCase, create *useCases.CreateAuthorUseCase, createMy *useCases.CreateMyAuthorUseCase, update *useCases.UpdateAuthorUseCase, deleteUseCase *useCases.DeleteAuthorUseCase, getUser *useCases.GetUserUseCase) *Handler {
|
||||
return &Handler{ListUseCase: list, CreateUseCase: create, CreateMyUseCase: createMy, UpdateUseCase: update, DeleteUseCase: deleteUseCase, GetUserUseCase: getUser}
|
||||
func NewHandler(list *useCases.ListAuthorsUseCase, create *useCases.CreateAuthorUseCase, createMy *useCases.CreateMyAuthorUseCase, update *useCases.UpdateAuthorUseCase, deleteUseCase *useCases.DeleteAuthorUseCase) *Handler {
|
||||
return &Handler{ListUseCase: list, CreateUseCase: create, CreateMyUseCase: createMy, UpdateUseCase: update, DeleteUseCase: deleteUseCase}
|
||||
}
|
||||
|
||||
type socialLinkRequest struct {
|
||||
@@ -180,6 +180,7 @@ func writeJSON(w http.ResponseWriter, status int, payload any) {
|
||||
|
||||
func writeAuthorError(w http.ResponseWriter, err error) {
|
||||
switch {
|
||||
case access.WriteIfMissingPermission(w, err):
|
||||
case errors.Is(err, domain.ErrAuthorNotFound):
|
||||
http.Error(w, apperr.AuthorNotFoundErrorMessage, http.StatusNotFound)
|
||||
case errors.Is(err, domain.ErrSlugTaken):
|
||||
@@ -226,18 +227,6 @@ func writeValidationError(w http.ResponseWriter, err error) {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
}
|
||||
|
||||
func (h *Handler) currentUser(r *http.Request) (user.User, bool) {
|
||||
userId, ok := claims.UserId(r)
|
||||
if !ok {
|
||||
return user.User{}, false
|
||||
}
|
||||
current, err := h.GetUserUseCase.GetUser(userId)
|
||||
if err != nil {
|
||||
return user.User{}, false
|
||||
}
|
||||
return current, true
|
||||
}
|
||||
|
||||
type listQuery struct {
|
||||
Page int `validate:"gte=1"`
|
||||
Size int `validate:"oneof=5 25 50 100"`
|
||||
@@ -277,8 +266,7 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
current, _ := h.currentUser(r)
|
||||
page, err := h.ListUseCase.List(useCases.ListAuthorsCommand{Q: query.Q, Page: query.Page, Size: query.Size, CallerRole: current.Role})
|
||||
page, err := h.ListUseCase.List(useCases.ListAuthorsCommand{Q: query.Q, Page: query.Page, Size: query.Size, Grants: access.Grants(r)})
|
||||
if err != nil {
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
@@ -351,12 +339,12 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
current, ok := h.currentUser(r)
|
||||
callerID, ok := claims.UserId(r)
|
||||
if !ok {
|
||||
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
updated, err := h.UpdateUseCase.Update(r.Context(), id, useCases.AuthorCaller{UserID: current.ID, Role: current.Role}, request.toPatch())
|
||||
updated, err := h.UpdateUseCase.Update(r.Context(), id, useCases.AuthorCaller{UserID: callerID, Grants: access.Grants(r)}, request.toPatch())
|
||||
if err != nil {
|
||||
writeAuthorError(w, err)
|
||||
return
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"RenewCMS/api/pkg/access"
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
"RenewCMS/api/pkg/claims"
|
||||
validator "RenewCMS/api/pkg/validator"
|
||||
"RenewCMS/internal/domain/role"
|
||||
domain "RenewCMS/internal/domain/update"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -21,29 +22,23 @@ type Handler struct {
|
||||
CheckForUpdateUseCase *useCases.CheckForUpdateUseCase
|
||||
StartUpdateDownloadUseCase *useCases.StartUpdateDownloadUseCase
|
||||
RequestRestartUseCase *useCases.RequestRestartUseCase
|
||||
GetUserUseCase *useCases.GetUserUseCase
|
||||
}
|
||||
|
||||
type restartRequest struct {
|
||||
Kind *string `json:"kind" validate:"omitempty,oneof=update restart"`
|
||||
}
|
||||
|
||||
func NewHandler(getStatus *useCases.GetUpdateStatusUseCase, checkForUpdate *useCases.CheckForUpdateUseCase, startDownload *useCases.StartUpdateDownloadUseCase, requestRestart *useCases.RequestRestartUseCase, getUser *useCases.GetUserUseCase) *Handler {
|
||||
func NewHandler(getStatus *useCases.GetUpdateStatusUseCase, checkForUpdate *useCases.CheckForUpdateUseCase, startDownload *useCases.StartUpdateDownloadUseCase, requestRestart *useCases.RequestRestartUseCase) *Handler {
|
||||
return &Handler{
|
||||
GetUpdateStatusUseCase: getStatus,
|
||||
CheckForUpdateUseCase: checkForUpdate,
|
||||
StartUpdateDownloadUseCase: startDownload,
|
||||
RequestRestartUseCase: requestRestart,
|
||||
GetUserUseCase: getUser,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) GetStatus(w http.ResponseWriter, r *http.Request) {
|
||||
isOwner, ok := h.isOwner(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
writeStatus(w, http.StatusOK, h.GetUpdateStatusUseCase.GetStatus(isOwner))
|
||||
writeStatus(w, http.StatusOK, h.GetUpdateStatusUseCase.GetStatus(canApply(r)))
|
||||
}
|
||||
|
||||
func (h *Handler) Check(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -51,11 +46,7 @@ func (h *Handler) Check(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
isOwner, ok := h.isOwner(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
writeStatus(w, http.StatusOK, h.GetUpdateStatusUseCase.GetStatus(isOwner))
|
||||
writeStatus(w, http.StatusOK, h.GetUpdateStatusUseCase.GetStatus(canApply(r)))
|
||||
}
|
||||
|
||||
func (h *Handler) Download(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -105,22 +96,8 @@ func decodeRestartKind(w http.ResponseWriter, r *http.Request) (string, bool) {
|
||||
return *request.Kind, true
|
||||
}
|
||||
|
||||
func (h *Handler) isOwner(w http.ResponseWriter, r *http.Request) (bool, bool) {
|
||||
userId, ok := claims.UserId(r)
|
||||
if !ok {
|
||||
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
|
||||
return false, false
|
||||
}
|
||||
currentUser, err := h.GetUserUseCase.GetUser(userId)
|
||||
if err != nil {
|
||||
if errors.Is(err, user.ErrUserNotFound) {
|
||||
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
|
||||
return false, false
|
||||
}
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return false, false
|
||||
}
|
||||
return currentUser.Role == user.RoleOwner, true
|
||||
func canApply(r *http.Request) bool {
|
||||
return access.Grants(r).Holds(role.KeyApplyUpdates)
|
||||
}
|
||||
|
||||
func writeDownloadError(w http.ResponseWriter, err error) {
|
||||
|
||||
@@ -77,39 +77,3 @@ func RequireVerifiedUser(getUser *useCases.GetUserUseCase) func(http.Handler) ht
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func RequireOwner(getUser *useCases.GetUserUseCase) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
currentUser, ok := resolveCurrentUser(getUser, w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if currentUser.Role != user.RoleOwner {
|
||||
http.Error(w, apperr.OwnerRequiredErrorMessage, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func RequireOwnerOrAdmin(getUser *useCases.GetUserUseCase) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
currentUser, ok := resolveCurrentUser(getUser, w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if currentUser.Role != user.RoleOwner && currentUser.Role != user.RoleAdmin {
|
||||
http.Error(w, apperr.OwnerOrAdminRequiredErrorMessage, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"RenewCMS/api/pkg/access"
|
||||
"RenewCMS/internal/domain/role"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type grantCheck func(account user.User, key string) (role.GrantSet, error)
|
||||
|
||||
func RequirePermission(getUser *useCases.GetUserUseCase, check *useCases.CheckPermissionUseCase, key string) func(http.Handler) http.Handler {
|
||||
return enforce(getUser, check.Require, key)
|
||||
}
|
||||
|
||||
func RequireFullScope(getUser *useCases.GetUserUseCase, check *useCases.CheckPermissionUseCase, key string) func(http.Handler) http.Handler {
|
||||
return enforce(getUser, check.RequireAll, key)
|
||||
}
|
||||
|
||||
func enforce(getUser *useCases.GetUserUseCase, verify grantCheck, key string) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
currentUser, ok := resolveCurrentUser(getUser, w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
grants, err := verify(currentUser, key)
|
||||
if err != nil {
|
||||
if !access.WriteIfMissingPermission(w, err) {
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r.WithContext(access.NewContext(r.Context(), grants)))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package access
|
||||
|
||||
import (
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
"RenewCMS/internal/domain/role"
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type grantsContextKey struct{}
|
||||
|
||||
func NewContext(ctx context.Context, grants role.GrantSet) context.Context {
|
||||
return context.WithValue(ctx, grantsContextKey{}, grants)
|
||||
}
|
||||
|
||||
func Grants(r *http.Request) role.GrantSet {
|
||||
grants, _ := r.Context().Value(grantsContextKey{}).(role.GrantSet)
|
||||
return grants
|
||||
}
|
||||
|
||||
func WriteMissingPermission(w http.ResponseWriter, key string) {
|
||||
w.Header().Set(apperr.MissingPermissionHeader, key)
|
||||
http.Error(w, apperr.MissingPermissionErrorMessage, http.StatusForbidden)
|
||||
}
|
||||
|
||||
func WriteIfMissingPermission(w http.ResponseWriter, err error) bool {
|
||||
var missing role.MissingPermissionError
|
||||
if !errors.As(err, &missing) {
|
||||
return false
|
||||
}
|
||||
WriteMissingPermission(w, missing.Key)
|
||||
return true
|
||||
}
|
||||
@@ -24,7 +24,6 @@ const InviteNotFoundErrorMessage = "Invite not found"
|
||||
const InviteAlreadyAcceptedErrorMessage = "This invite has already been accepted."
|
||||
const InviteInvalidErrorMessage = "This invite link doesn't work."
|
||||
const InviteExpiredErrorMessage = "This invite has expired."
|
||||
const OwnerRequiredErrorMessage = "Only the owner can do this."
|
||||
const UsernameTakenErrorMessage = "That username is taken. Try another."
|
||||
const PasswordTooLongErrorMessage = "The password must be 72 bytes or fewer."
|
||||
const MediaQueryErrorMessage = "The media query is invalid."
|
||||
@@ -39,8 +38,9 @@ const UpdateRestartMismatchErrorMessage = "The update state changed. Reload the
|
||||
const ArticleBodyFormatErrorMessage = "The format must be one of: markdown, html, json."
|
||||
const APIKeyNotFoundErrorMessage = "API key not found"
|
||||
const APIKeyExpiryErrorMessage = "The expiry must be a date in the future."
|
||||
const MissingPermissionErrorMessage = "missing_permission"
|
||||
const MissingPermissionHeader = "X-Missing-Permission"
|
||||
const SessionRequiredErrorMessage = "This action needs a real session."
|
||||
const OwnerOrAdminRequiredErrorMessage = "Only owners and admins can do this."
|
||||
const PurgeConflictErrorMessage = "The library changed, so nothing was purged. Check the new summary and try again."
|
||||
const PurgeBusyErrorMessage = "A purge is already running."
|
||||
const PurgeIntervalErrorMessage = "Enter a whole number of days between 1 and 365."
|
||||
|
||||
+61
-66
@@ -58,6 +58,14 @@ func (c *UseCases) apiKeyAuth() func(http.Handler) http.Handler {
|
||||
return c.apiKeyAuthMiddleware
|
||||
}
|
||||
|
||||
func (c *UseCases) require(key string) func(http.Handler) http.Handler {
|
||||
return middleware.RequirePermission(c.GetUserUseCase, c.CheckPermissionUseCase, key)
|
||||
}
|
||||
|
||||
func (c *UseCases) requireAll(key string) func(http.Handler) http.Handler {
|
||||
return middleware.RequireFullScope(c.GetUserUseCase, c.CheckPermissionUseCase, key)
|
||||
}
|
||||
|
||||
func NewArticleRouter(container *UseCases, trustedProxies []string) http.Handler {
|
||||
r := chi.NewRouter()
|
||||
r.Use(container.apiKeyAuth())
|
||||
@@ -149,10 +157,10 @@ func NewUserRouter(container *UseCases, rateLimit config.AuthRateLimitSettings,
|
||||
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
||||
r.Patch("/me", userHandler.UpdateMe)
|
||||
r.Get("/", inviteHandler.ListUsers)
|
||||
r.With(container.require("users.view")).Get("/", inviteHandler.ListUsers)
|
||||
r.Get("/lookup", lookupHandler.Lookup)
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.RequireOwner(container.GetUserUseCase))
|
||||
r.Use(container.require("users.invite"))
|
||||
r.Use(middleware.TrustedProxyRealIP(trustedProxies))
|
||||
r.Use(middleware.NewRateLimiter(rateLimit.MaxAttempts, rateLimit.Window))
|
||||
r.Post("/invite", inviteHandler.CreateInvite)
|
||||
@@ -173,6 +181,7 @@ func NewSettingsRouter(container *UseCases, trustedProxies []string) http.Handle
|
||||
r.Use(authtoken.Authenticator)
|
||||
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
||||
r.Use(container.require("settings.view"))
|
||||
r.Get("/", settingsHandler.GetSettings)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
@@ -183,7 +192,7 @@ func NewSettingsRouter(container *UseCases, trustedProxies []string) http.Handle
|
||||
r.Use(authtoken.Authenticator)
|
||||
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
||||
r.Use(middleware.RequireOwner(container.GetUserUseCase))
|
||||
r.Use(container.require("system.edit_retention"))
|
||||
r.Patch("/", settingsHandler.UpdateSettings)
|
||||
})
|
||||
return r
|
||||
@@ -213,11 +222,10 @@ func NewSiteRouter(container *UseCases, trustedProxies []string) http.Handler {
|
||||
r.Use(authtoken.Authenticator)
|
||||
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
||||
r.Use(middleware.RequireOwner(container.GetUserUseCase))
|
||||
r.Post("/", siteHandler.Create)
|
||||
r.Patch("/{id}", siteHandler.Update)
|
||||
r.Patch("/{id}/default", siteHandler.SetDefault)
|
||||
r.Delete("/{id}", siteHandler.Delete)
|
||||
r.With(container.require("settings.manage_sites")).Post("/", siteHandler.Create)
|
||||
r.With(container.require("settings.manage_sites")).Patch("/{id}", siteHandler.Update)
|
||||
r.With(container.require("settings.manage_sites")).Patch("/{id}/default", siteHandler.SetDefault)
|
||||
r.With(container.require("settings.delete_site")).Delete("/{id}", siteHandler.Delete)
|
||||
})
|
||||
return r
|
||||
}
|
||||
@@ -225,7 +233,7 @@ func NewSiteRouter(container *UseCases, trustedProxies []string) http.Handler {
|
||||
func NewAuthorRouter(container *UseCases, trustedProxies []string) http.Handler {
|
||||
r := chi.NewRouter()
|
||||
|
||||
authorHandler := author.NewHandler(container.ListAuthorsUseCase, container.CreateAuthorUseCase, container.CreateMyAuthorUseCase, container.UpdateAuthorUseCase, container.DeleteAuthorUseCase, container.GetUserUseCase)
|
||||
authorHandler := author.NewHandler(container.ListAuthorsUseCase, container.CreateAuthorUseCase, container.CreateMyAuthorUseCase, container.UpdateAuthorUseCase, container.DeleteAuthorUseCase)
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(authtoken.Verifier(container.Token))
|
||||
@@ -233,6 +241,7 @@ func NewAuthorRouter(container *UseCases, trustedProxies []string) http.Handler
|
||||
r.Use(authtoken.Authenticator)
|
||||
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
||||
r.Use(container.require("authors.view"))
|
||||
r.Get("/", authorHandler.List)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
@@ -244,12 +253,9 @@ func NewAuthorRouter(container *UseCases, trustedProxies []string) http.Handler
|
||||
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
||||
r.Post("/me", authorHandler.CreateMine)
|
||||
r.Patch("/{id}", authorHandler.Update)
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.RequireOwnerOrAdmin(container.GetUserUseCase))
|
||||
r.Post("/", authorHandler.Create)
|
||||
r.Delete("/{id}", authorHandler.Delete)
|
||||
})
|
||||
r.With(container.require("authors.edit")).Patch("/{id}", authorHandler.Update)
|
||||
r.With(container.require("authors.create")).Post("/", authorHandler.Create)
|
||||
r.With(container.require("authors.delete")).Delete("/{id}", authorHandler.Delete)
|
||||
})
|
||||
return r
|
||||
}
|
||||
@@ -268,14 +274,11 @@ func NewContentTypeRouter(container *UseCases, trustedProxies []string) http.Han
|
||||
r.Use(authtoken.Authenticator)
|
||||
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
||||
r.Get("/", contentTypeHandler.List)
|
||||
r.Get("/{key}", contentTypeHandler.Get)
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.RequireOwnerOrAdmin(container.GetUserUseCase))
|
||||
r.Post("/", contentTypeHandler.Create)
|
||||
r.Put("/{key}", contentTypeHandler.Update)
|
||||
r.Delete("/{key}", contentTypeHandler.Delete)
|
||||
})
|
||||
r.With(container.require("schema.view")).Get("/", contentTypeHandler.List)
|
||||
r.With(container.require("schema.view")).Get("/{key}", contentTypeHandler.Get)
|
||||
r.With(container.require("schema.edit")).Post("/", contentTypeHandler.Create)
|
||||
r.With(container.require("schema.edit")).Put("/{key}", contentTypeHandler.Update)
|
||||
r.With(container.require("schema.delete")).Delete("/{key}", contentTypeHandler.Delete)
|
||||
r.Get("/{key}/entry", entryHandler.List)
|
||||
r.Post("/{key}/entry", entryHandler.Create)
|
||||
r.Get("/{key}/entry/{id}", entryHandler.Get)
|
||||
@@ -296,7 +299,7 @@ func NewBuiltinTypeRouter(container *UseCases, trustedProxies []string) http.Han
|
||||
r.Use(authtoken.Authenticator)
|
||||
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
||||
r.Use(middleware.RequireOwnerOrAdmin(container.GetUserUseCase))
|
||||
r.Use(container.require("schema.view"))
|
||||
r.Get("/", builtinTypeHandler.List)
|
||||
r.Get("/{key}", builtinTypeHandler.Get)
|
||||
return r
|
||||
@@ -314,12 +317,9 @@ func NewActivityRouter(container *UseCases, trustedProxies []string) http.Handle
|
||||
r.Use(authtoken.Authenticator)
|
||||
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
||||
r.Get("/", activityHandler.List)
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.RequireOwnerOrAdmin(container.GetUserUseCase))
|
||||
r.Get("/summary", activityHandler.Summary)
|
||||
r.Get("/export", activityHandler.Export)
|
||||
})
|
||||
r.With(container.require("activity.view")).Get("/", activityHandler.List)
|
||||
r.With(container.requireAll("activity.view")).Get("/summary", activityHandler.Summary)
|
||||
r.With(container.require("activity.export"), container.require("activity.view")).Get("/export", activityHandler.Export)
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -333,7 +333,7 @@ const (
|
||||
func NewSystemRouter(container *UseCases, trustedProxies []string) http.Handler {
|
||||
r := chi.NewRouter()
|
||||
|
||||
updateHandler := update.NewHandler(container.GetUpdateStatusUseCase, container.CheckForUpdateUseCase, container.StartUpdateDownloadUseCase, container.RequestRestartUseCase, container.GetUserUseCase)
|
||||
updateHandler := update.NewHandler(container.GetUpdateStatusUseCase, container.CheckForUpdateUseCase, container.StartUpdateDownloadUseCase, container.RequestRestartUseCase)
|
||||
|
||||
r.Use(middleware.RequireSession)
|
||||
r.Use(authtoken.Verifier(container.Token))
|
||||
@@ -341,19 +341,19 @@ func NewSystemRouter(container *UseCases, trustedProxies []string) http.Handler
|
||||
r.Use(authtoken.Authenticator)
|
||||
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
||||
r.Get("/update", updateHandler.GetStatus)
|
||||
r.With(container.require("settings.view")).Get("/update", updateHandler.GetStatus)
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.RequireOwner(container.GetUserUseCase))
|
||||
r.Use(container.require("system.apply_updates"))
|
||||
r.Use(middleware.NewUserRateLimiter(updateCheckMaxAttempts, updateCheckWindow))
|
||||
r.Post("/update/check", updateHandler.Check)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.RequireOwner(container.GetUserUseCase))
|
||||
r.Use(container.require("system.apply_updates"))
|
||||
r.Use(middleware.NewUserRateLimiter(updateDownloadMaxAttempts, updateDownloadWindow))
|
||||
r.Post("/update/download", updateHandler.Download)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.RequireOwner(container.GetUserUseCase))
|
||||
r.Use(container.require("system.apply_updates"))
|
||||
r.Post("/update/restart", updateHandler.Restart)
|
||||
})
|
||||
return r
|
||||
@@ -370,9 +370,9 @@ func NewAPIKeyRouter(container *UseCases, trustedProxies []string) http.Handler
|
||||
r.Use(authtoken.Authenticator)
|
||||
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
||||
r.Post("/", apiKeyHandler.Create)
|
||||
r.Get("/", apiKeyHandler.List)
|
||||
r.Delete("/{id}", apiKeyHandler.Revoke)
|
||||
r.With(container.require("apikeys.create")).Post("/", apiKeyHandler.Create)
|
||||
r.With(container.require("apikeys.view")).Get("/", apiKeyHandler.List)
|
||||
r.With(container.require("apikeys.revoke")).Delete("/{id}", apiKeyHandler.Revoke)
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -402,28 +402,24 @@ func NewMediaRouter(container *UseCases, trustedProxies []string) http.Handler {
|
||||
r.Use(authtoken.Authenticator)
|
||||
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.RequireOwnerOrAdmin(container.GetUserUseCase))
|
||||
r.Get("/purge-settings", mediaHandler.GetPurgeSettings)
|
||||
r.Patch("/purge-settings", mediaHandler.UpdatePurgeSettings)
|
||||
r.Get("/purge-preview", mediaHandler.PreviewPurge)
|
||||
r.Post("/purge", mediaHandler.RunPurge)
|
||||
})
|
||||
r.With(container.require("settings.view")).Get("/purge-settings", mediaHandler.GetPurgeSettings)
|
||||
r.With(container.require("settings.edit_general")).Patch("/purge-settings", mediaHandler.UpdatePurgeSettings)
|
||||
r.With(container.requireAll("media.delete")).Get("/purge-preview", mediaHandler.PreviewPurge)
|
||||
r.With(container.requireAll("media.delete")).Post("/purge", mediaHandler.RunPurge)
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.RejectAPIKey)
|
||||
r.Use(middleware.RequireOwnerOrAdmin(container.GetUserUseCase))
|
||||
r.Get("/{id}/variants", mediaHandler.ListVariants)
|
||||
r.Post("/{id}/variants", mediaHandler.GenerateAllVariants)
|
||||
r.Post("/{id}/variants/{name}", mediaHandler.GenerateVariant)
|
||||
r.Delete("/{id}/variants/{name}", mediaHandler.PurgeVariant)
|
||||
r.With(container.require("media.view")).Get("/{id}/variants", mediaHandler.ListVariants)
|
||||
r.With(container.require("settings.edit_general")).Post("/{id}/variants", mediaHandler.GenerateAllVariants)
|
||||
r.With(container.require("settings.edit_general")).Post("/{id}/variants/{name}", mediaHandler.GenerateVariant)
|
||||
r.With(container.require("settings.edit_general")).Delete("/{id}/variants/{name}", mediaHandler.PurgeVariant)
|
||||
})
|
||||
r.Get("/", mediaHandler.List)
|
||||
r.Post("/", mediaHandler.Upload)
|
||||
r.Post("/merge", mediaHandler.Merge)
|
||||
r.Patch("/{id}", mediaHandler.Rename)
|
||||
r.Delete("/{id}", mediaHandler.Delete)
|
||||
r.Get("/{id}", mediaHandler.Get)
|
||||
r.Get("/{id}/usage", mediaHandler.Usage)
|
||||
r.With(container.require("media.view")).Get("/", mediaHandler.List)
|
||||
r.With(container.require("media.upload")).Post("/", mediaHandler.Upload)
|
||||
r.With(container.requireAll("media.delete")).Post("/merge", mediaHandler.Merge)
|
||||
r.With(container.require("media.upload")).Patch("/{id}", mediaHandler.Rename)
|
||||
r.With(container.requireAll("media.delete")).Delete("/{id}", mediaHandler.Delete)
|
||||
r.With(container.require("media.view")).Get("/{id}", mediaHandler.Get)
|
||||
r.With(container.require("media.view")).Get("/{id}/usage", mediaHandler.Usage)
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -438,15 +434,14 @@ func NewImageVariantRouter(container *UseCases, trustedProxies []string) http.Ha
|
||||
r.Use(authtoken.Authenticator)
|
||||
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
||||
r.Use(middleware.RequireOwnerOrAdmin(container.GetUserUseCase))
|
||||
r.Get("/", variantHandler.List)
|
||||
r.Post("/", variantHandler.Create)
|
||||
r.Get("/cache", variantHandler.CacheStats)
|
||||
r.Post("/cache/purge", variantHandler.PurgeCache)
|
||||
r.Put("/{name}", variantHandler.Update)
|
||||
r.Patch("/{name}", variantHandler.SetEnabled)
|
||||
r.Delete("/{name}", variantHandler.Delete)
|
||||
r.Post("/{name}/purge", variantHandler.Purge)
|
||||
r.With(container.require("media.view")).Get("/", variantHandler.List)
|
||||
r.With(container.require("settings.edit_general")).Post("/", variantHandler.Create)
|
||||
r.With(container.require("settings.view")).Get("/cache", variantHandler.CacheStats)
|
||||
r.With(container.require("settings.edit_general")).Post("/cache/purge", variantHandler.PurgeCache)
|
||||
r.With(container.require("settings.edit_general")).Put("/{name}", variantHandler.Update)
|
||||
r.With(container.require("settings.edit_general")).Patch("/{name}", variantHandler.SetEnabled)
|
||||
r.With(container.require("settings.edit_general")).Delete("/{name}", variantHandler.Delete)
|
||||
r.With(container.require("settings.edit_general")).Post("/{name}/purge", variantHandler.Purge)
|
||||
return r
|
||||
}
|
||||
|
||||
|
||||
+11
-8
@@ -45,7 +45,7 @@ key never reads the audit trail. Events are append-only; there is no `POST`, `PU
|
||||
|
||||
| Route | Who | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `GET /v1/activity` | Any verified user | Filtered, paged events. Owner and admin see every event; any other role is scoped to its own events, and `actor` set to someone else answers 403 "Only owners and admins can do this." |
|
||||
| `GET /v1/activity` | Any verified user | Filtered, paged events. A role with `activity.view` at all sees every event; a role at own is scoped to its own events, and `actor` set to someone else answers 403 `missing_permission`. |
|
||||
| `GET /v1/activity/summary` | Owner, admin | Counts for the range, the previous period, active people and seven-bucket series. |
|
||||
| `GET /v1/activity/export` | Owner, admin | The same filters as the list without `page` or `size`, as a `text/csv; charset=utf-8` attachment (`when,actor,action,target,outcome,source,ip`). Cells starting with `=`, `+`, `-` or `@` get a leading quote. |
|
||||
|
||||
@@ -59,7 +59,7 @@ are UTC. The list answers `{ items, total, page, size }`, newest first (`created
|
||||
90), `activity_retention_max_gb` (0 for no size limit), `activity_log_size_bytes`,
|
||||
`activity_last_cleanup_at` and `activity_last_cleanup_removed`. `PATCH /v1/settings` with
|
||||
`{"activity_retention_days": 30}`, `{"activity_retention_max_gb": 5}` or both is session only and
|
||||
owner only (403 "Only the owner can do this."); any other value answers 400. The daily cleanup
|
||||
needs `system.edit_retention` (403 `missing_permission`); any other value answers 400. The daily cleanup
|
||||
removes events past the age limit, then the oldest events until the log fits the size limit.
|
||||
Whichever limit is reached first wins.
|
||||
|
||||
@@ -138,8 +138,8 @@ dashes (`^[a-z0-9]+(-[a-z0-9]+)*$`) and never changes, `base_url` is `https` (or
|
||||
stripped, uniqueness is not enforced), `default_robots` is `index` (default) or `noindex`, `locale`
|
||||
is `null` or a tag such as `en` or `fr-CA` (an empty string means none) and `default_social_image_id`
|
||||
is `null` or the id of an image. Read access needs a verified session or an API key; every write is
|
||||
session only and owner only (403 "Only the owner can do this.", or "This action needs a real
|
||||
session." with a key). The role is checked before the body.
|
||||
session only and need `settings.manage_sites` (`settings.delete_site` for DELETE): 403 `missing_permission`, or "This action needs a real
|
||||
session." with a key. The role is checked before the body.
|
||||
|
||||
| Route | Purpose |
|
||||
| --- | --- |
|
||||
@@ -255,8 +255,8 @@ article is allowed and never blocks publishing.
|
||||
| `PATCH /v1/author/{id}` | owner or admin on any author; any verified session on the author linked to it; session only | Edit `name`, `slug`, `bio`, `avatar_id`, `website`, `social_links`, `user_id`. Absent fields are untouched, `avatar_id`, `website` and `user_id` accept `null`, `social_links` is replaced as a whole. `user_id` links or unlinks the account and is owner or admin only. |
|
||||
| `DELETE /v1/author/{id}?reassign_to={id\|none}` | owner or admin, session only | 204. An author credited on articles needs `reassign_to`: an author id (that author takes the deleted one's position and is never duplicated on an article) or `none` (the credit is removed). The move and the delete are one transaction. `reassign_to` is ignored when the author has no article. The linked account is untouched. |
|
||||
|
||||
The role checks use the existing bodies: 403 "Only owners and admins can do this." for `POST /` and
|
||||
`DELETE`, 403 `author_rights` for a `PATCH` by an account that is not owner or admin on an author
|
||||
The role checks use the existing bodies: 403 `missing_permission` for `POST /` and
|
||||
`DELETE`, 403 `author_rights` for a `PATCH` by a role that holds `authors.edit` at own on an author
|
||||
that is not its own (or that sends `user_id`), and 403 "This action needs a real session." for a key.
|
||||
Errors are plain text whose body is exactly the token: 404 `author_not_found`, 404 `user_not_found`,
|
||||
409 `author_slug_taken`, 409 `author_has_articles`, 409 `user_already_linked`, 409
|
||||
@@ -284,8 +284,7 @@ repoint the avatars of the merged copies and report it as `authors_updated`.
|
||||
|
||||
## Image variants
|
||||
Owners and admins manage named variants of the stored images. These routes are session only (no API
|
||||
key) and sit behind the verified-user check; any other role gets 403 "Only owners and admins can do
|
||||
this." Behaviour, caching and the public `?v=<name>` contract are in [image-variants.md](image-variants.md).
|
||||
key) and sit behind the verified-user check; a role without the needed permission gets 403 `missing_permission`. Behaviour, caching and the public `?v=<name>` contract are in [image-variants.md](image-variants.md).
|
||||
|
||||
| Route | Purpose |
|
||||
| --- | --- |
|
||||
@@ -358,6 +357,10 @@ this format." and 500 "Internal server error".
|
||||
Every error is a plain-text body with one sentence; the status is in the HTTP status line. The
|
||||
statuses are 400, 401, 403, 404, 409, 429 and 500. Messages are listed on the Integration page.
|
||||
|
||||
A caller whose role lacks the permission a route needs gets 403 with the body `missing_permission` and the
|
||||
header `X-Missing-Permission` set to the permission key (for example `users.invite`). The role is read on
|
||||
every request, so an API key follows the current role of its creator.
|
||||
|
||||
## Notes
|
||||
- Images in `body` use paths like `/static/uploadedImages/<uuid>.png`. Prefix them with the base URL.
|
||||
- Browsers can call the API only from origins in the server's CORS setting.
|
||||
@@ -1,4 +1,5 @@
|
||||
Feature: The back-office keeps an append-only activity log that owners can read and retention can prune
|
||||
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.
|
||||
|
||||
As the owner of a RenewCMS site
|
||||
I want every important action recorded with who, what, when and from where
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# QA procedure: activity-log-backend
|
||||
|
||||
> 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.
|
||||
|
||||
Go only, no SPA. Package `test` (black-box), new files `test/activity_log_domain_test.go`, `test/activity_log_persistence_test.go`, `test/activity_log_http_test.go` and `test/activity_cleanup_test.go`. Fake and helper type names must be unique across the package. No live server, no real SMTP, no real SQLite file, no real sleeping, no `time.Ticker`. No env var is added. Never put a secret value in a test name or log assertion helper beyond the fixed test strings.
|
||||
|
||||
## Setup shared by every case
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
Feature: Activity screen, filter cards as toggles and the Export menu
|
||||
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.
|
||||
|
||||
As an owner or admin
|
||||
I want the Failed actions and Security events cards to toggle their filter, and Export to offer the current view or all activity as CSV
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# QA: Activity filter cards as toggles and the Export menu
|
||||
|
||||
> 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.
|
||||
|
||||
## Implementation note for coder (informational)
|
||||
|
||||
SPA slice with one small backend change: accept `range=all` in the list, summary and export range validation (use case range handling and the handler `validate` tag). An omitted range stays 7d. The range table in features/activity-log-backend.feature is updated accordingly.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
Feature: Recent activity card on Home and Retention card on Settings
|
||||
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.
|
||||
|
||||
As a RenewCMS user
|
||||
I want Home to show my last five activity events and the owner to choose how long activity is kept on Settings
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# QA: Recent activity card on Home and Retention card on Settings
|
||||
|
||||
> 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.
|
||||
|
||||
## Implementation note for coder (informational, not a QA assertion)
|
||||
|
||||
- Backend: add NextCleanupAt (nullable time) to the audit settings entity with a domain method computing the next 03:00 (audit.CleanupHour) strictly after a given time, in that time's location; nil when RetentionDays is audit.ForeverRetentionDays. GetActivityRetentionUseCase and UpdateActivityRetentionUseCase get an injected `now func() time.Time` (wired in api/dependecyInjection.go) and populate it. settingsResponse in api/handlers/settings gains `activity_next_cleanup_at` (`*time.Time`, RFC 3339, null when nil). No logic in the handler, none in the SPA.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
Feature: Developers authenticate against /v1 with named API keys instead of the jwt cookie
|
||||
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.
|
||||
|
||||
As a developer integrating with RenewCMS
|
||||
I want to create named API keys and send them as a Bearer token
|
||||
@@ -115,7 +116,7 @@ Feature: Developers authenticate against /v1 with named API keys instead of the
|
||||
When the client sends POST /v1/api-key with body {"name": "sneaky", "user_id": 1, "role": "owner"}
|
||||
Then the response status is 201
|
||||
And the stored key belongs to user 2
|
||||
And using that key on POST /v1/user/invite returns 403 with apperr.OwnerRequiredErrorMessage
|
||||
And using that key on POST /v1/user/invite returns 403 with apperr.MissingPermissionErrorMessage
|
||||
|
||||
Scenario Outline: Key management needs a verified real session
|
||||
Given <caller>
|
||||
@@ -186,7 +187,7 @@ Feature: Developers authenticate against /v1 with named API keys instead of the
|
||||
Given an active key created by admin 2
|
||||
When the client sends POST /v1/user/invite with that key and body {"email": "new@example.com"}
|
||||
Then the response status is 403
|
||||
And the response body is apperr.OwnerRequiredErrorMessage
|
||||
And the response body is apperr.MissingPermissionErrorMessage
|
||||
And no invite exists
|
||||
|
||||
Scenario Outline: A key that can't authenticate gets the same 401 as a bad jwt
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# QA procedure: API keys
|
||||
|
||||
> 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.
|
||||
|
||||
Package: `test` (black-box). No live server, no real SQLite file, no real SMTP.
|
||||
|
||||
## Setup shared by every case
|
||||
@@ -38,7 +40,7 @@ Package: `test` (black-box). No live server, no real SQLite file, no real SMTP.
|
||||
"yesterday" -> `400` `BodyErrorMessage`; past `expires_at` -> `400` `apperr.APIKeyExpiryErrorMessage`.
|
||||
Each: repository still empty.
|
||||
12. Body `{"name":"sneaky","user_id":1,"role":"owner"}` with admin 2 -> `201`; stored `UserID` 2; that
|
||||
key on `POST /v1/user/invite` -> `403` `apperr.OwnerRequiredErrorMessage`, no invite.
|
||||
key on `POST /v1/user/invite` -> `403` `apperr.MissingPermissionErrorMessage`, no invite.
|
||||
13. Access table: no credentials -> `401`; user 4 cookie -> `403` `apperr.VerificationRequiredErrorMessage`;
|
||||
stale `token_version` -> `401`. Each: repository empty.
|
||||
|
||||
@@ -59,7 +61,7 @@ Package: `test` (black-box). No live server, no real SQLite file, no real SMTP.
|
||||
`200`. No credentials -> list has only 6, `GET /5` is `404`. `Bearer rcms_unknown` on `GET /v1/article`
|
||||
-> `401` `apperr.UnauthorizedErrorMessage`.
|
||||
18. Owner key `POST /v1/user/invite` -> `201`, `invited_by` 1. Admin key -> `403`
|
||||
`apperr.OwnerRequiredErrorMessage`, no invite and no mail.
|
||||
`apperr.MissingPermissionErrorMessage`, no invite and no mail.
|
||||
19. Table of 401 cases on `GET /v1/settings`, all with body `apperr.UnauthorizedErrorMessage` exactly (the
|
||||
stored-hash case has no `rcms_` prefix, so it takes the jwt path: assert status `401` only):
|
||||
unknown well-formed key; expired (clock one second past `expires_at`); revoked; creator deleted from
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
Feature: Authors backend, public bylines that articles credit in order
|
||||
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.
|
||||
|
||||
As an owner or admin of a RenewCMS installation
|
||||
I want to manage authors and credit them on articles in a chosen order
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# QA procedure: Authors backend
|
||||
|
||||
> 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.
|
||||
|
||||
Spec: `features/authors_backend.feature`. Go tests only, in `test/` (package `test`), run with `task test`. No live server, no real SMTP, no real SQLite file outside the temp-dir repository-contract harness. Call `validator.InitValidator()` before any handler call. Every error assertion compares the plain-text body to the exact code token or constant named in the spec.
|
||||
|
||||
## Setup
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
Feature: Container-aware update guidance
|
||||
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.
|
||||
|
||||
As the owner of a RenewCMS site running in Docker
|
||||
I want Settings to say "update by pulling a new image" instead of "Not prepared"
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# QA: Container update guidance
|
||||
|
||||
> 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.
|
||||
|
||||
## Automated (Go, test/, httptest)
|
||||
1. Fakes: in-memory update status store, fake FeedClient, fake HelperDetector, fake JobStore and restart writer, validator.InitValidator(). Mount NewSystemRouter on a fresh chi.NewRouter() with jwtauth.Verifier + Authenticator and a test jwtauth.New("HS256", ...).
|
||||
2. Parse RENEWCMS_CONTAINER through config.NewSettingsFromEnv with t.Setenv over the table in the feature (true, TRUE, false, empty, yes).
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
Feature: Owners and admins define custom content types, and users manage entries of those types through a generic /v1 API
|
||||
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.
|
||||
|
||||
As a site owner or admin
|
||||
I want to define content types made of ordered scalar fields, and then create entries of those types
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# QA procedure: custom content types (backend core)
|
||||
|
||||
> 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.
|
||||
|
||||
Package: `test` (black-box). No live server, no real SQLite file, no real SMTP.
|
||||
|
||||
## Setup shared by every case
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
Feature: Content types carry an optional icon, built-in types are readable, and verified users can resolve user display names
|
||||
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.
|
||||
|
||||
As an owner, admin or verified user working in the SPA
|
||||
I want an icon per content type, a read-only view of the built-in types and the display names of entry authors
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# QA procedure: custom content types support (icon, built-in read model, user lookup)
|
||||
|
||||
> 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.
|
||||
|
||||
Package: `test` (black-box). No live server, no real SQLite file, no real SMTP.
|
||||
|
||||
## Setup shared by every case
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
Feature: Image variants are generated on first request, cached, and managed by admins
|
||||
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.
|
||||
|
||||
As an admin or owner of a RenewCMS site
|
||||
I want to define named variants of the original images and have them generated and cached on demand
|
||||
@@ -48,12 +49,12 @@ Feature: Image variants are generated on first request, cached, and managed by a
|
||||
| GET | /v1/image-variants | no cookie | 401 | apperr.UnauthorizedErrorMessage |
|
||||
| GET | /v1/image-variants | a stale token_version cookie | 401 | apperr.UnauthorizedErrorMessage |
|
||||
| GET | /v1/image-variants | the cookie of an unverified user| 403 | apperr.VerificationRequiredErrorMessage |
|
||||
| GET | /v1/image-variants | the cookie of a plain user | 403 | apperr.OwnerOrAdminRequiredErrorMessage |
|
||||
| POST | /v1/image-variants | the cookie of a plain user | 403 | apperr.OwnerOrAdminRequiredErrorMessage |
|
||||
| PUT | /v1/image-variants/hero | the cookie of a plain user | 403 | apperr.OwnerOrAdminRequiredErrorMessage |
|
||||
| PATCH | /v1/image-variants/hero | the cookie of a plain user | 403 | apperr.OwnerOrAdminRequiredErrorMessage |
|
||||
| DELETE | /v1/image-variants/hero | the cookie of a plain user | 403 | apperr.OwnerOrAdminRequiredErrorMessage |
|
||||
| POST | /v1/image-variants/hero/purge | the cookie of a plain user | 403 | apperr.OwnerOrAdminRequiredErrorMessage |
|
||||
| GET | /v1/image-variants | the cookie of a plain user | 403 | apperr.MissingPermissionErrorMessage |
|
||||
| POST | /v1/image-variants | the cookie of a plain user | 403 | apperr.MissingPermissionErrorMessage |
|
||||
| PUT | /v1/image-variants/hero | the cookie of a plain user | 403 | apperr.MissingPermissionErrorMessage |
|
||||
| PATCH | /v1/image-variants/hero | the cookie of a plain user | 403 | apperr.MissingPermissionErrorMessage |
|
||||
| DELETE | /v1/image-variants/hero | the cookie of a plain user | 403 | apperr.MissingPermissionErrorMessage |
|
||||
| POST | /v1/image-variants/hero/purge | the cookie of a plain user | 403 | apperr.MissingPermissionErrorMessage |
|
||||
| GET | /v1/image-variants | a valid API key | 401 | apperr.UnauthorizedErrorMessage |
|
||||
|
||||
Scenario Outline: Owners and admins are both allowed
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# QA procedure: image-variants
|
||||
|
||||
> 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.
|
||||
|
||||
Go only, no SPA. Package `test` (black-box). New files: `test/image_variants_admin_http_test.go`, `test/image_variants_serving_http_test.go`, `test/image_variants_geometry_test.go`, `test/image_variants_audit_test.go`, `test/image_variants_config_test.go`. Fake type names must be unique across the package. No live server, no real SMTP, no real SQLite file, no `time.Sleep`.
|
||||
|
||||
## Setup shared by every case
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
Feature: Image variants cache panel: cache statistics, purge by scope, request tracking and the Cache screen
|
||||
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.
|
||||
|
||||
As an owner or admin of a RenewCMS site
|
||||
I want to see what the variant cache holds and to purge all of it, the unused part, one variant or one image
|
||||
@@ -42,8 +43,8 @@ Feature: Image variants cache panel: cache statistics, purge by scope, request t
|
||||
| GET | /v1/image-variants/cache | no cookie | 401 | apperr.UnauthorizedErrorMessage |
|
||||
| GET | /v1/image-variants/cache | a stale token_version cookie | 401 | apperr.UnauthorizedErrorMessage |
|
||||
| GET | /v1/image-variants/cache | the cookie of an unverified user | 403 | apperr.VerificationRequiredErrorMessage |
|
||||
| GET | /v1/image-variants/cache | the cookie of a plain user | 403 | apperr.OwnerOrAdminRequiredErrorMessage |
|
||||
| POST | /v1/image-variants/cache/purge | the cookie of a plain user | 403 | apperr.OwnerOrAdminRequiredErrorMessage |
|
||||
| GET | /v1/image-variants/cache | the cookie of a plain user | 403 | apperr.MissingPermissionErrorMessage |
|
||||
| POST | /v1/image-variants/cache/purge | the cookie of a plain user | 403 | apperr.MissingPermissionErrorMessage |
|
||||
| GET | /v1/image-variants/cache | a valid API key | 401 | apperr.UnauthorizedErrorMessage |
|
||||
| POST | /v1/image-variants/cache/purge | a valid API key | 401 | apperr.UnauthorizedErrorMessage |
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# QA procedure: image_variants_cache
|
||||
|
||||
> 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.
|
||||
|
||||
Two parts: Go for the API and tracking, Vitest for the Cache screen. No live server, no real SQLite file, no real SMTP, no `time.Sleep` (use an injected clock and manual flushes).
|
||||
|
||||
## Go (package `test`, black-box)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
Feature: Image variants per image: library fields, per-image routes and generate on upload
|
||||
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.
|
||||
|
||||
As an owner or admin of a RenewCMS site
|
||||
I want to see, generate and purge the variants of one image, and to have variants generated when I upload
|
||||
@@ -36,10 +37,10 @@ Feature: Image variants per image: library fields, per-image routes and generate
|
||||
| GET | /v1/media/1/variants | no cookie | 401 | apperr.UnauthorizedErrorMessage |
|
||||
| GET | /v1/media/1/variants | a stale token_version cookie | 401 | apperr.UnauthorizedErrorMessage |
|
||||
| GET | /v1/media/1/variants | the cookie of an unverified user | 403 | apperr.VerificationRequiredErrorMessage |
|
||||
| GET | /v1/media/1/variants | the cookie of a plain user | 403 | apperr.OwnerOrAdminRequiredErrorMessage |
|
||||
| POST | /v1/media/1/variants | the cookie of a plain user | 403 | apperr.OwnerOrAdminRequiredErrorMessage |
|
||||
| POST | /v1/media/1/variants/hero | the cookie of a plain user | 403 | apperr.OwnerOrAdminRequiredErrorMessage |
|
||||
| DELETE | /v1/media/1/variants/hero | the cookie of a plain user | 403 | apperr.OwnerOrAdminRequiredErrorMessage |
|
||||
| GET | /v1/media/1/variants | the cookie of a plain user | 403 | apperr.MissingPermissionErrorMessage |
|
||||
| POST | /v1/media/1/variants | the cookie of a plain user | 403 | apperr.MissingPermissionErrorMessage |
|
||||
| POST | /v1/media/1/variants/hero | the cookie of a plain user | 403 | apperr.MissingPermissionErrorMessage |
|
||||
| DELETE | /v1/media/1/variants/hero | the cookie of a plain user | 403 | apperr.MissingPermissionErrorMessage |
|
||||
| GET | /v1/media/1/variants | a valid API key | 401 | apperr.UnauthorizedErrorMessage |
|
||||
| POST | /v1/media/1/variants | a valid API key | 401 | apperr.UnauthorizedErrorMessage |
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# QA procedure: image_variants_per_image_api
|
||||
|
||||
> 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.
|
||||
|
||||
Go only, no SPA. Package `test` (black-box). New files: `test/image_variants_per_image_http_test.go`, `test/image_variants_generate_on_upload_test.go`, `test/media_variants_list_http_test.go`, `test/image_variants_per_image_audit_test.go`. Extend `test/image_variants_support_test.go`; fake type names must stay unique in the package. No live server, no real SQLite file, no real SMTP, no `time.Sleep`.
|
||||
|
||||
## Setup shared by every case
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
Feature: Integration screen (API reference)
|
||||
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.
|
||||
|
||||
As a developer building a frontend or an API-key client for a RenewCMS site
|
||||
I want /integration to be a complete, searchable API reference
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# QA: Integration screen (API reference)
|
||||
|
||||
> 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.
|
||||
|
||||
## Automated (Vitest + Vue Test Utils, `task test:web`)
|
||||
|
||||
Mount IntegrationPage inside DashboardShell with a resolved verified session and a test router at
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
Feature: Integration page documents every route an API key can call
|
||||
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.
|
||||
|
||||
As a developer building on RenewCMS
|
||||
I want the API reference to cover content types, entries, built-in types, sites, authors and media purge
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# QA procedure: Integration page, every API-key route documented
|
||||
|
||||
> 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.
|
||||
|
||||
No Go test: SPA only, no /v1 call, no API change. Samples and counts follow the real API; where the board differs (purge response, built-in type samples) the API wins.
|
||||
|
||||
## Automated SPA (Vitest + Vue Test Utils, `task test:web`)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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.
|
||||
|
||||
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
|
||||
@@ -11,7 +12,7 @@ Feature: A verified user can invite another person by email, and the users list
|
||||
And POST /v1/user/invite and GET /v1/user/ are mounted in NewUserRouter, inside the group that
|
||||
requires a valid jwt cookie, a current token_version and a verified user
|
||||
And POST /v1/user/invite additionally requires the owner role (403
|
||||
apperr.OwnerRequiredErrorMessage for an admin); GET /v1/user/ is open to every verified user
|
||||
apperr.MissingPermissionErrorMessage for an admin); GET /v1/user/ is open to every verified user
|
||||
And wherever a scenario has verified user id 7 post an invite, user 7 is the owner
|
||||
And an invite has an id, an email stored in lower case, a hashed code, an expires_at equal to
|
||||
its creation time plus 3 days, an invited_by user id, a nullable accepted_at and a created_at
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# QA procedure: invitations
|
||||
|
||||
> 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.
|
||||
|
||||
Package: `test` (black-box). New fakes must have unique type names across the package:
|
||||
`mockInviteRepo` (implements `invite.Repository` with an in-memory slice and a `failSwitch`),
|
||||
plus a `fixedClock` only if the use case takes one. Reuse `mockUserRepo` and `stubHasher`
|
||||
@@ -50,7 +52,7 @@ plus a `fixedClock` only if the use case takes one. Reuse `mockUserRepo` and `st
|
||||
failing send -> `500` and no invite for that address remains.
|
||||
13. Auth table (`POST`): no cookie -> `401`; cookie of unverified user 8 -> `403`; cookie with stale
|
||||
`token_version` -> `401`; cookie of a verified admin (seeded with `Role: admin`) -> `403`, plain
|
||||
text body exactly `apperr.OwnerRequiredErrorMessage`. Each asserts no invite and no mail.
|
||||
text body exactly `apperr.MissingPermissionErrorMessage`. Each asserts no invite and no mail.
|
||||
14. Rate limit: limiter of 2 per minute, `RemoteAddr` `203.0.113.20:1234`, three valid POSTs -> `201`,
|
||||
`201`, `429`; third body is exactly `apperr.TooManyRequestsErrorMessage`, has `Retry-After`, and adds no
|
||||
invite or mail. `GET /` from the same address afterwards is not `429`.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# QA procedure: invite management
|
||||
|
||||
> 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.
|
||||
|
||||
Package: `test` (black-box). Reuse `mockUserRepo`, `stubHasher`, `mockMailRepo` and `mockInviteRepo`.
|
||||
Extend `mockInviteRepo` with the new renew method and a `failSwitch` entry for it (unique type names
|
||||
across the package). No live server, no real SQLite (except case 27), no real SMTP.
|
||||
@@ -45,7 +47,7 @@ across the package). No live server, no real SQLite (except case 27), no real SM
|
||||
|
||||
## Access and ids (table over both endpoints)
|
||||
22. No cookie -> `401`; stale `token_version` -> `401`; unverified -> `403`; admin -> `403` with body
|
||||
`apperr.OwnerRequiredErrorMessage`; admin on an unknown id -> `403`. Each: invite unchanged, no mail.
|
||||
`apperr.MissingPermissionErrorMessage`; admin on an unknown id -> `403`. Each: invite unchanged, no mail.
|
||||
23. Ids `abc`, `-1`, `4294967296` -> `400` `apperr.IdUint32ErrorMessage` on both endpoints.
|
||||
|
||||
## Rate limit
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
Feature: Media cleanup in the SPA: Settings card, purge dialog and Media Duplicates button
|
||||
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.
|
||||
|
||||
As a RenewCMS owner or admin
|
||||
I want to turn on automatic duplicate purging and to purge duplicates by hand
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# QA: Media cleanup in the SPA (Settings card, purge dialog, Media Duplicates button)
|
||||
|
||||
> 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.
|
||||
|
||||
No Go test: the API contract is already covered by media_purge.qa.md and media_purge_scheduler.qa.md. This slice has no Go change. The SPA never reaches `persistence`, `mailer` or `gorm`, and every `/v1` call goes through `useApi`.
|
||||
|
||||
Settled decisions: a non-admin sees the Read-only card with placeholders and no settings GET; Partial is a 200 with `failed_groups > 0`; a 403 on PATCH reverts silently, refreshes the session and switches the card to Read-only; dates use the browser timezone.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
Feature: Owners and admins can merge duplicate images in use, with persisted purge settings
|
||||
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.
|
||||
|
||||
As a RenewCMS owner or admin
|
||||
I want to see what a duplicate purge would do, run it, and keep the automatic-purge settings
|
||||
@@ -35,7 +36,7 @@ Feature: Owners and admins can merge duplicate images in use, with persisted pur
|
||||
RequireVerifiedUser), then a group guarded by the new middleware.RequireOwnerOrAdmin, and
|
||||
registered before the /{id} routes
|
||||
And RequireOwnerOrAdmin is an allowlist: only the roles owner and admin pass; any other value,
|
||||
including an empty role, answers 403 with apperr.OwnerOrAdminRequiredErrorMessage
|
||||
including an empty role, answers 403 with apperr.MissingPermissionErrorMessage
|
||||
("Only owners and admins can do this.") as plain text from http.Error
|
||||
And a stale fingerprint answers 409 with apperr.PurgeConflictErrorMessage ("The library changed,
|
||||
so nothing was purged. Check the new summary and try again.") and a busy run answers 409 with
|
||||
@@ -59,14 +60,14 @@ Feature: Owners and admins can merge duplicate images in use, with persisted pur
|
||||
| POST | /v1/media/purge | no cookie | 401 | the existing jwtauth response (status only) |
|
||||
| GET | /v1/media/purge-settings | a stale token_version cookie | 401 | apperr.UnauthorizedErrorMessage |
|
||||
| GET | /v1/media/purge-settings | cookie of an unverified owner | 403 | apperr.VerificationRequiredErrorMessage |
|
||||
| GET | /v1/media/purge-settings | cookie of a verified user, role "editor" | 403 | apperr.OwnerOrAdminRequiredErrorMessage |
|
||||
| PATCH | /v1/media/purge-settings | cookie of a verified user, role "editor" | 403 | apperr.OwnerOrAdminRequiredErrorMessage |
|
||||
| GET | /v1/media/purge-preview | cookie of a verified user, role "editor" | 403 | apperr.OwnerOrAdminRequiredErrorMessage |
|
||||
| POST | /v1/media/purge | cookie of a verified user, role "editor" | 403 | apperr.OwnerOrAdminRequiredErrorMessage |
|
||||
| GET | /v1/media/purge-settings | cookie of a verified user, empty role | 403 | apperr.OwnerOrAdminRequiredErrorMessage |
|
||||
| PATCH | /v1/media/purge-settings | cookie of a verified user, empty role | 403 | apperr.OwnerOrAdminRequiredErrorMessage |
|
||||
| GET | /v1/media/purge-preview | cookie of a verified user, empty role | 403 | apperr.OwnerOrAdminRequiredErrorMessage |
|
||||
| POST | /v1/media/purge | cookie of a verified user, empty role | 403 | apperr.OwnerOrAdminRequiredErrorMessage |
|
||||
| GET | /v1/media/purge-settings | cookie of a verified user, role "editor" | 403 | apperr.MissingPermissionErrorMessage |
|
||||
| PATCH | /v1/media/purge-settings | cookie of a verified user, role "editor" | 403 | apperr.MissingPermissionErrorMessage |
|
||||
| GET | /v1/media/purge-preview | cookie of a verified user, role "editor" | 403 | apperr.MissingPermissionErrorMessage |
|
||||
| POST | /v1/media/purge | cookie of a verified user, role "editor" | 403 | apperr.MissingPermissionErrorMessage |
|
||||
| GET | /v1/media/purge-settings | cookie of a verified user, empty role | 403 | apperr.MissingPermissionErrorMessage |
|
||||
| PATCH | /v1/media/purge-settings | cookie of a verified user, empty role | 403 | apperr.MissingPermissionErrorMessage |
|
||||
| GET | /v1/media/purge-preview | cookie of a verified user, empty role | 403 | apperr.MissingPermissionErrorMessage |
|
||||
| POST | /v1/media/purge | cookie of a verified user, empty role | 403 | apperr.MissingPermissionErrorMessage |
|
||||
|
||||
Scenario Outline: Owners and admins both pass the role guard
|
||||
When the client sends <method> <path> with a valid cookie of a verified <role>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# QA procedure: media_purge
|
||||
|
||||
> 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.
|
||||
|
||||
Package: `test` (black-box). New files `test/media_purge_http_test.go` and `test/media_purge_test.go` (use case level). Fake type names must be unique in the package. Go only: no SPA, no scheduler, no env var.
|
||||
|
||||
## Setup shared by every case
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
Feature: Roles and permissions enforcement: permission checks replace the owner and admin gates
|
||||
|
||||
As the owner of a RenewCMS site
|
||||
I want every owner and admin gate replaced by a check of the caller's role permissions
|
||||
So that built-in roles are enforced by what they hold, API keys follow their creator's live role,
|
||||
and later slices can add custom roles without touching the routes again
|
||||
|
||||
Background:
|
||||
Given the spec of record is docs/design/screens/roles.md (catalogue, "Built-in roles", "HTTP status to copy", section 7)
|
||||
And features/roles_permissions_foundation.feature provides the catalogue, the Owner (34), Admin (29) and Writer (9) roles,
|
||||
users.role_id, the legacy fallback and GetMyAccessUseCase
|
||||
And "ok" in a table means the request passes the permission gate. A later 400, 404 or 409 from the handler is not a gate failure
|
||||
And "403" in a table means status 403, body "missing_permission\n" (http.Error, plain text, like the other snake_case codes
|
||||
in api/pkg/apperr) and the use case or handler body is not invoked
|
||||
And the code is apperr.MissingPermissionErrorMessage = "missing_permission"
|
||||
And the response carries the header X-Missing-Permission with the catalogue key that failed, so the SPA can word
|
||||
"Your role doesn't include {permission name}"
|
||||
And api/middleware gains RequirePermission(key) for on/off keys and for the minimum "not none" check of a scoped key. It uses
|
||||
a use case that depends on domain interfaces only. It resolves the caller's role live on every request, with no cache
|
||||
And a request that fails the gate answers only after the existing steps: authtoken.Authenticator, RequireCurrentToken (401),
|
||||
RequireVerifiedUser (403 verification), RequireSession (403 session_required for an API key) and RejectAPIKey (401)
|
||||
And RequireOwner, RequireOwnerOrAdmin, apperr.OwnerRequiredErrorMessage and apperr.OwnerOrAdminRequiredErrorMessage are removed
|
||||
And no use case, handler or middleware reads user.Role for an authorization decision. user.Role stays only for the
|
||||
role_id fallback, for CreateUser and AcceptInvite assigning it, for ListUsersAndInvites output and for migrations
|
||||
And a scoped decision is taken from the caller's resolved grants. Use cases receive them, not user.Role
|
||||
And the role is resolved as in GetMyAccessUseCase: role_id first, else the built-in named by the legacy string
|
||||
And a role that cannot be resolved (role_id unknown, or no role_id and a legacy string other than "owner" or "admin")
|
||||
is refused with 403 missing_permission on every gated route. A repository failure other than role.ErrRoleNotFound
|
||||
answers 500 "Internal server error"
|
||||
And enforcement honours any grant it finds, including an owner-only key placed on a custom role by hand. Refusing to
|
||||
grant owner-only keys belongs to slice 3 (ValidateGrants at write time)
|
||||
And the users, tokens and cookies are the ones of the foundation feature: user 3 Owner, user 5 Admin, user 9 Writer,
|
||||
all verified with a valid jwt cookie and token_version 0
|
||||
And routes with no obvious catalogue key reuse existing keys: media rename = media.upload; purge settings GET = settings.view,
|
||||
PATCH = settings.edit_general; purge preview and run = media.delete at scope all (own fails closed, images record no creator);
|
||||
per-image variant mutations and image-variants configuration = settings.edit_general; media variants GET and image-variants
|
||||
list = media.view; image-variants cache stats = settings.view; builtin types GET = schema.view
|
||||
And GET /v1/site stays open to any verified user
|
||||
And settings.manage_sites covers PATCH /v1/site/{id} including site SEO defaults, so Admin may edit them
|
||||
|
||||
Scenario: A refused request carries the missing_permission contract
|
||||
Given user 9 (Writer)
|
||||
When the client sends POST /v1/user/invite with body {"email": "new@example.com"} and user 9's cookie
|
||||
Then the response status is 403 and the body is "missing_permission\n"
|
||||
And the header X-Missing-Permission is "users.invite"
|
||||
And no invite is created and no mail is sent
|
||||
|
||||
Scenario: Verification and token checks still come first
|
||||
Given user 12 is not verified and holds the Admin role
|
||||
When the client sends POST /v1/user/invite with user 12's cookie
|
||||
Then the status is 403 and the body is apperr.VerificationRequiredErrorMessage
|
||||
And a stale token_version gives 401 apperr.UnauthorizedErrorMessage and no cookie gives 401, as before
|
||||
|
||||
Scenario Outline: Users and invites
|
||||
Given the caller is <role>
|
||||
When the client sends <call> with that caller's cookie
|
||||
Then the gate result is <result>
|
||||
|
||||
Examples:
|
||||
| role | call | key | result |
|
||||
| Owner | GET /v1/user | users.view | ok |
|
||||
| Admin | GET /v1/user | users.view | ok |
|
||||
| Writer | GET /v1/user | users.view | 403 |
|
||||
| Owner | POST /v1/user/invite | users.invite | ok |
|
||||
| Admin | POST /v1/user/invite | users.invite | ok |
|
||||
| Writer | POST /v1/user/invite | users.invite | 403 |
|
||||
| Admin | DELETE /v1/user/invite/1 | users.invite | ok |
|
||||
| Writer | DELETE /v1/user/invite/1 | users.invite | 403 |
|
||||
| Admin | POST /v1/user/invite/1/renew | users.invite | ok |
|
||||
| Writer | POST /v1/user/invite/1/renew | users.invite | 403 |
|
||||
|
||||
Scenario Outline: Settings, sites and system
|
||||
Given the caller is <role>
|
||||
When the client sends <call> with that caller's cookie (and a real session, not an API key, where the route requires one)
|
||||
Then the gate result is <result>
|
||||
|
||||
Examples:
|
||||
| role | call | key | result |
|
||||
| Owner | GET /v1/settings | settings.view | ok |
|
||||
| Admin | GET /v1/settings | settings.view | ok |
|
||||
| Writer | GET /v1/settings | settings.view | 403 |
|
||||
| Owner | PATCH /v1/settings | system.edit_retention | ok |
|
||||
| Admin | PATCH /v1/settings | system.edit_retention | 403 |
|
||||
| Writer | PATCH /v1/settings | system.edit_retention | 403 |
|
||||
| Owner | POST /v1/site | settings.manage_sites | ok |
|
||||
| Admin | POST /v1/site | settings.manage_sites | ok |
|
||||
| Writer | POST /v1/site | settings.manage_sites | 403 |
|
||||
| Admin | PATCH /v1/site/2 | settings.manage_sites | ok |
|
||||
| Admin | PATCH /v1/site/2/default | settings.manage_sites | ok |
|
||||
| Writer | PATCH /v1/site/2 | settings.manage_sites | 403 |
|
||||
| Owner | DELETE /v1/site/2 | settings.delete_site | ok |
|
||||
| Admin | DELETE /v1/site/2 | settings.delete_site | 403 |
|
||||
| Writer | DELETE /v1/site/2 | settings.delete_site | 403 |
|
||||
| Writer | GET /v1/site | (none) | ok |
|
||||
| Owner | GET /v1/system/update | settings.view | ok |
|
||||
| Admin | GET /v1/system/update | settings.view | ok |
|
||||
| Writer | GET /v1/system/update | settings.view | 403 |
|
||||
| Owner | POST /v1/system/update/check | system.apply_updates | ok |
|
||||
| Admin | POST /v1/system/update/check | system.apply_updates | 403 |
|
||||
| Owner | POST /v1/system/update/download | system.apply_updates | ok |
|
||||
| Admin | POST /v1/system/update/download | system.apply_updates | 403 |
|
||||
| Owner | POST /v1/system/update/restart | system.apply_updates | ok |
|
||||
| Admin | POST /v1/system/update/restart | system.apply_updates | 403 |
|
||||
| Writer | POST /v1/system/update/restart | system.apply_updates | 403 |
|
||||
|
||||
Scenario: The permission gate runs before the update rate limiters
|
||||
Given the update-check limiter allows 5 attempts per minute per user
|
||||
When the Admin sends POST /v1/system/update/check 6 times
|
||||
Then every response is 403 missing_permission and none is 429
|
||||
|
||||
Scenario: The permission gate runs before the invite rate limiter
|
||||
Given the invite limiter is exhausted for user 9
|
||||
When the Writer sends POST /v1/user/invite
|
||||
Then the response is 403 missing_permission and none is 429
|
||||
|
||||
Scenario: An admin reads the update status but cannot apply it
|
||||
Given the Admin and the Owner each send GET /v1/system/update
|
||||
Then both get 200
|
||||
And the Admin's body has can_apply false
|
||||
And the Owner's body has can_apply true when the update helper is installed, false otherwise, as before
|
||||
|
||||
Scenario: An admin reads the activity retention but cannot change it
|
||||
Given the Admin sends GET /v1/settings
|
||||
Then the status is 200 and the body holds activity_retention_days and activity_retention_max_gb
|
||||
And PATCH /v1/settings with {"activity_retention_days": 30} gives 403 missing_permission and nothing is stored
|
||||
|
||||
Scenario Outline: Authors, schema, media and API key creation
|
||||
Given the caller is <role>
|
||||
When the client sends <call> with that caller's cookie
|
||||
Then the gate result is <result>
|
||||
|
||||
Examples:
|
||||
| role | call | key | result |
|
||||
| Owner | GET /v1/author | authors.view | ok |
|
||||
| Writer | GET /v1/author | authors.view | ok |
|
||||
| Admin | POST /v1/author | authors.create | ok |
|
||||
| Writer | POST /v1/author | authors.create | 403 |
|
||||
| Admin | DELETE /v1/author/4 | authors.delete | ok |
|
||||
| Writer | DELETE /v1/author/4 | authors.delete | 403 |
|
||||
| Writer | POST /v1/author/me | (none) | ok |
|
||||
| Admin | GET /v1/content-type | schema.view | ok |
|
||||
| Writer | GET /v1/content-type | schema.view | ok |
|
||||
| Writer | GET /v1/content-type/events | schema.view | ok |
|
||||
| Admin | POST /v1/content-type | schema.edit | ok |
|
||||
| Writer | POST /v1/content-type | schema.edit | 403 |
|
||||
| Writer | PUT /v1/content-type/events | schema.edit | 403 |
|
||||
| Admin | DELETE /v1/content-type/events | schema.delete | ok |
|
||||
| Writer | DELETE /v1/content-type/events | schema.delete | 403 |
|
||||
| Writer | GET /v1/builtin-type | schema.view | ok |
|
||||
| Writer | GET /v1/media | media.view | ok |
|
||||
| Writer | GET /v1/media/1 | media.view | ok |
|
||||
| Writer | GET /v1/media/1/usage | media.view | ok |
|
||||
| Writer | POST /v1/media (multipart image) | media.upload | ok |
|
||||
| Writer | PATCH /v1/media/1 | media.upload | ok |
|
||||
| Writer | DELETE /v1/media/1 | media.delete | 403 |
|
||||
| Writer | POST /v1/media/merge | media.delete | 403 |
|
||||
| Admin | DELETE /v1/media/1 | media.delete | ok |
|
||||
| Writer | GET /v1/media/purge-settings | settings.view | 403 |
|
||||
| Writer | PATCH /v1/media/purge-settings | settings.edit_general| 403 |
|
||||
| Writer | GET /v1/media/purge-preview | media.delete | 403 |
|
||||
| Writer | POST /v1/media/purge | media.delete | 403 |
|
||||
| Admin | POST /v1/media/purge | media.delete | ok |
|
||||
| Writer | GET /v1/media/1/variants | media.view | ok |
|
||||
| Writer | POST /v1/media/1/variants | settings.edit_general| 403 |
|
||||
| Writer | DELETE /v1/media/1/variants/hero | settings.edit_general| 403 |
|
||||
| Writer | GET /v1/image-variants | media.view | ok |
|
||||
| Writer | GET /v1/image-variants/cache | settings.view | 403 |
|
||||
| Writer | POST /v1/image-variants | settings.edit_general| 403 |
|
||||
| Writer | POST /v1/image-variants/cache/purge | settings.edit_general| 403 |
|
||||
| Admin | PUT /v1/image-variants/hero | settings.edit_general| ok |
|
||||
| Admin | POST /v1/api-key | apikeys.create | ok |
|
||||
| Writer | POST /v1/api-key | apikeys.create | 403 |
|
||||
|
||||
Scenario: Routes that keep their old behaviour for API keys
|
||||
Given a valid API key whose creator is the Admin
|
||||
Then POST /v1/api-key, GET /v1/activity and POST /v1/system/update/check answer 403 apperr.SessionRequiredErrorMessage
|
||||
And GET /v1/media/1/variants answers 401 apperr.UnauthorizedErrorMessage (RejectAPIKey runs before the permission check)
|
||||
|
||||
Scenario Outline: Roles are judged by the keys they hold, with no implied keys
|
||||
Given user 20 has a custom role that grants only <grants>
|
||||
When the client sends <call> with user 20's cookie
|
||||
Then the gate result is <result>
|
||||
|
||||
Examples:
|
||||
| grants | call | result |
|
||||
| schema.edit=all | POST /v1/content-type | ok |
|
||||
| schema.edit=all | GET /v1/content-type | 403 |
|
||||
| schema.view=all | PUT /v1/content-type/events | 403 |
|
||||
| users.invite=all | GET /v1/user | 403 |
|
||||
| users.view=all | POST /v1/user/invite | 403 |
|
||||
| settings.manage_sites=all | DELETE /v1/site/2 | 403 |
|
||||
| settings.delete_site=all | PATCH /v1/site/2 | 403 |
|
||||
| media.upload=all | GET /v1/media | 403 |
|
||||
| media.view=all | POST /v1/media | 403 |
|
||||
| apikeys.create=all | GET /v1/api-key | 403 |
|
||||
| settings.edit_general=all | PATCH /v1/media/purge-settings | ok |
|
||||
| system.apply_updates=all | POST /v1/system/update/check | ok |
|
||||
| (no grants) | GET /v1/author | 403 |
|
||||
|
||||
Scenario: A user whose role cannot be resolved is refused everywhere that is gated
|
||||
Given user 13 has role_id 99 (no such role) and legacy role ""
|
||||
When user 13 sends GET /v1/user, GET /v1/content-type and POST /v1/media
|
||||
Then every response is 403 missing_permission
|
||||
|
||||
Scenario: A repository failure while resolving the role is a server error
|
||||
Given the role repository fails with a non-sentinel error
|
||||
When user 3 sends GET /v1/user
|
||||
Then the status is 500 and the body is "Internal server error"
|
||||
|
||||
Scenario Outline: The legacy role string is no longer read for authorization
|
||||
Given user <id> has legacy role "<legacy>" and role_id <role_id>
|
||||
When the client sends <call> with that user's cookie
|
||||
Then the gate result is <result>
|
||||
|
||||
Examples:
|
||||
| id | legacy | role_id | call | result |
|
||||
| 14 | owner | Writer's id | POST /v1/user/invite | 403 |
|
||||
| 15 | admin | Writer's id | POST /v1/content-type| 403 |
|
||||
| 16 | "" | Admin's id | POST /v1/content-type| ok |
|
||||
| 17 | owner | 0 | POST /v1/user/invite | ok |
|
||||
| 18 | admin | 0 | POST /v1/content-type| ok |
|
||||
| 19 | admin | 0 | PATCH /v1/settings | 403 |
|
||||
|
||||
Scenario: A session follows its role live
|
||||
Given user 9 is on the Writer role and GET /v1/media/purge-settings gives 403
|
||||
When Writer's grants are edited in the repository to add settings.view=all
|
||||
Then the next GET /v1/settings with the same cookie answers ok, with no re-login and no cache
|
||||
|
||||
Scenario: Admin relaxations
|
||||
Given the Admin
|
||||
Then the Admin can now call POST /v1/user/invite, DELETE /v1/user/invite/{id} and POST /v1/user/invite/{id}/renew
|
||||
And can now call POST /v1/site, PATCH /v1/site/{id} and PATCH /v1/site/{id}/default
|
||||
And can now list every API key with created_by_name and revoke any key
|
||||
And can read every setting: GET /v1/settings, GET /v1/system/update and the retention values
|
||||
|
||||
Scenario: Owner-only items stay owner-only
|
||||
Given the Admin
|
||||
Then DELETE /v1/site/{id}, PATCH /v1/settings, POST /v1/system/update/check, /download and /restart answer 403 missing_permission
|
||||
And the Owner passes all of them
|
||||
|
||||
Scenario: Writer is effective
|
||||
Given user 9 on the Writer role
|
||||
Then GET /v1/content-type and GET /v1/content-type/events answer ok
|
||||
And GET /v1/media, GET /v1/media/1 and POST /v1/media answer ok
|
||||
And GET /v1/author answers ok and the author items carry no user_email
|
||||
And GET /v1/user, GET /v1/settings, GET /v1/system/update, POST /v1/user/invite, POST /v1/site and POST /v1/api-key answer 403
|
||||
And GET /v1/activity answers ok and returns only the events whose actor is user 9
|
||||
And GET /v1/permission, GET /v1/auth/me, PATCH /v1/user/me and POST /v1/author/me answer ok
|
||||
|
||||
Scenario Outline: activity.view scope decides what the list returns
|
||||
Given events by user 3, user 5 and user 9 exist
|
||||
And the caller's activity.view grant is <scope>
|
||||
When the client sends GET /v1/activity<query>
|
||||
Then the result is <result>
|
||||
|
||||
Examples:
|
||||
| scope | query | result |
|
||||
| all | | 200, events of users 3, 5 and 9 |
|
||||
| all | ?actor=user:5 | 200, events of user 5 only |
|
||||
| own | | 200, only the caller's own events |
|
||||
| own | ?actor=user:9 | 200, only the caller's own events (self is allowed) |
|
||||
| own | ?actor=user:3 | 403 missing_permission |
|
||||
| none | | 403 missing_permission |
|
||||
|
||||
Scenario Outline: Activity summary and export follow the scope
|
||||
Given the caller holds <grants>
|
||||
When the client sends <call>
|
||||
Then the result is <result>
|
||||
|
||||
Examples:
|
||||
| grants | call | result |
|
||||
| activity.view=all | GET /v1/activity/summary| 200 |
|
||||
| activity.view=own | GET /v1/activity/summary| 403 missing_permission |
|
||||
| activity.view=all, activity.export=all | GET /v1/activity/export | 200 CSV of every event |
|
||||
| activity.view=own, activity.export=all | GET /v1/activity/export | 200 CSV of the caller's own events only |
|
||||
| activity.view=none, activity.export=all | GET /v1/activity/export | 403 missing_permission |
|
||||
| activity.view=all | GET /v1/activity/export | 403 missing_permission |
|
||||
|
||||
Scenario Outline: apikeys.view scope decides which keys are listed
|
||||
Given keys 1 (created by user 3), 2 (user 5) and 3 (user 9) exist
|
||||
And the caller's apikeys.view grant is <scope>
|
||||
When the client sends GET /v1/api-key
|
||||
Then the result is <result>
|
||||
|
||||
Examples:
|
||||
| scope | result |
|
||||
| all | 200, keys 1, 2 and 3, each with created_by_name |
|
||||
| own | 200, only the caller's own keys, without created_by_name |
|
||||
| none | 403 missing_permission |
|
||||
|
||||
Scenario Outline: apikeys.revoke scope decides which keys can be revoked
|
||||
Given key 2 was created by user 5
|
||||
And the caller is user 9 and holds apikeys.revoke = <scope>
|
||||
When the client sends DELETE /v1/api-key/2
|
||||
Then the result is <result>
|
||||
|
||||
Examples:
|
||||
| scope | result |
|
||||
| all | 204 and key 2 is revoked |
|
||||
| own | 404 apperr.APIKeyNotFoundErrorMessage and key 2 is not revoked |
|
||||
| none | 403 missing_permission, key 2 is not revoked and no lookup occurs |
|
||||
|
||||
Scenario: The Admin lists and revokes every key
|
||||
Given the Admin and a key created by the Owner
|
||||
When the Admin sends GET /v1/api-key and DELETE /v1/api-key/<that id>
|
||||
Then the list contains the Owner's key with created_by_name, and the delete returns 204
|
||||
|
||||
Scenario Outline: authors.edit scope decides who can edit an author
|
||||
Given author 4 is linked to user 9 and author 5 is linked to user 3
|
||||
And the caller is user 9 with authors.edit = <scope>
|
||||
When the client sends PATCH <path> with body <body>
|
||||
Then the result is <result>
|
||||
|
||||
Examples:
|
||||
| scope | path | body | result |
|
||||
| all | /v1/author/5 | {"bio": "x"} | 200 |
|
||||
| all | /v1/author/5 | {"user_id": 9} | 200 or the existing link errors |
|
||||
| own | /v1/author/4 | {"bio": "x"} | 200 |
|
||||
| own | /v1/author/5 | {"bio": "x"} | 403 author_rights (existing contract) |
|
||||
| own | /v1/author/4 | {"user_id": null} | 403 author_rights (existing contract) |
|
||||
| none | /v1/author/4 | {"bio": "x"} | 403 missing_permission |
|
||||
| none | /v1/author/999 | {"bio": "x"} | 403 missing_permission (before any lookup) |
|
||||
|
||||
Scenario Outline: user_email on authors needs users.view
|
||||
Given author 4 is linked to a user with an email
|
||||
And the caller holds authors.view and users.view = <users_view>
|
||||
When the client sends GET /v1/author
|
||||
Then user_email is <shown>
|
||||
|
||||
Examples:
|
||||
| users_view | shown |
|
||||
| all | present |
|
||||
| none | absent |
|
||||
|
||||
Scenario: An API key resolves its creator's current role on every request
|
||||
Given a valid API key created by user 5 (Admin)
|
||||
And GET /v1/content-type with that key answers 200 and POST /v1/user/invite answers ok
|
||||
When user 5's role_id is changed to the Writer role in the repository
|
||||
Then the next POST /v1/user/invite with the same key answers 403 missing_permission
|
||||
And GET /v1/content-type with the same key still answers 200
|
||||
And POST /v1/content-type with the same key answers 403 missing_permission
|
||||
And no new key and no new login is needed for the change to apply
|
||||
|
||||
Scenario: A key grows when its creator's role grows
|
||||
Given a valid API key created by user 9 (Writer), and POST /v1/content-type with it gives 403
|
||||
When user 9's role_id is changed to the Admin role
|
||||
Then the next POST /v1/content-type with the same key passes the gate
|
||||
|
||||
Scenario: Editing the creator's role grants shrinks the key as well
|
||||
Given a valid API key created by user 20 whose custom role grants media.view and media.upload
|
||||
When the role's media.upload grant is removed in the repository
|
||||
Then POST /v1/media with the key gives 403 missing_permission and GET /v1/media still passes
|
||||
|
||||
Scenario: A key whose creator's role cannot be resolved is refused by the gate
|
||||
Given a valid API key created by user 13 (role_id 99)
|
||||
When the client sends GET /v1/media with the key
|
||||
Then the status is 403 missing_permission
|
||||
|
||||
Scenario: Key authentication failures are unchanged
|
||||
Then an unknown, revoked or expired key, or one whose creator is deleted or unverified, still gives 401 apperr.UnauthorizedErrorMessage
|
||||
|
||||
Scenario: Public and self-service routes keep no permission gate
|
||||
Given no cookie
|
||||
Then GET /v1/article, GET /v1/article/{id}, GET /v1/article/{id}/seo and GET /v1/site/{key}/sitemap answer as before
|
||||
And user 9 on a role with no grants still gets 200 on GET /v1/auth/me, GET /v1/permission, GET /v1/site and GET /v1/user/lookup?ids=1, and ok on PATCH /v1/user/me
|
||||
|
||||
Scenario: Articles and entries are not gated in this slice
|
||||
Given user 20 has a custom role with no content grants
|
||||
Then the article and entry routes answer exactly as before this slice (own/all, publish and draft are slice 2b)
|
||||
|
||||
Scenario Outline: Existing owner and admin behaviour does not regress
|
||||
Given the caller is <role>
|
||||
When the client sends <call>
|
||||
Then the status is <status> exactly as before this slice
|
||||
|
||||
Examples:
|
||||
| role | call | status |
|
||||
| Owner | GET /v1/user | 200 |
|
||||
| Admin | GET /v1/user | 200 |
|
||||
| Owner | POST /v1/content-type | as before |
|
||||
| Admin | POST /v1/content-type | as before |
|
||||
| Admin | GET /v1/builtin-type | 200 |
|
||||
| Admin | GET /v1/activity/summary | 200 |
|
||||
| Admin | GET /v1/activity/export | 200 CSV |
|
||||
| Admin | GET /v1/media/purge-settings | 200 |
|
||||
| Admin | PATCH /v1/media/purge-settings | as before |
|
||||
| Admin | GET /v1/image-variants | 200 |
|
||||
| Admin | GET /v1/media/1/variants | as before |
|
||||
| Owner | PATCH /v1/settings | as before |
|
||||
| Admin | DELETE /v1/author/4 | as before |
|
||||
|
||||
Scenario: The owner and admin gates are gone from the code
|
||||
Then api/router.go and api/middleware hold no RequireOwner or RequireOwnerOrAdmin
|
||||
And no file under api/ or internal/infrastructure/useCases compares a user.Role value for authorization,
|
||||
apart from the role_id fallback in GetMyAccessUseCase, CreateUser and AcceptInvite assigning a role, and the ListUsersAndInvites output
|
||||
And the apperr constants OwnerRequiredErrorMessage and OwnerOrAdminRequiredErrorMessage no longer exist
|
||||
@@ -0,0 +1,152 @@
|
||||
# QA procedure: roles_permissions_enforcement
|
||||
|
||||
Package: `test` (black-box). Name new fakes uniquely, for example `enforcementRoleRepo`, `enforcementUserRepo`.
|
||||
Do not copy the old `Role ==` checks into new code.
|
||||
|
||||
## Deferred to later slices
|
||||
|
||||
- 2b: own/all enforcement after loading an article or entry, publish separate from update, Draft behaviour. Articles and entries stay ungated here.
|
||||
- 3: /v1/role CRUD, no-escalation, ValidateGrants at write time, per-type override keys.
|
||||
- 4: role change, invite with a role, transfer ownership, removal of the legacy role column.
|
||||
- SPA (sidebar, read-only pages, 403 toasts) is out of scope. It reacts to the status code only.
|
||||
|
||||
## Design notes for the user
|
||||
|
||||
- docs/design/screens/roles.md "Owner-only items" disables View roles and Manage roles for an admin. This slice follows the user's decision (Admin has 29 keys). The design needs a fix by the user.
|
||||
- docs/design/change-list.md item 21 ("Sites: owner only, hidden for other roles") and its SEO "Permissions" paragraph ("only the owner edits site SEO defaults", 403 `owner_only`), plus the `owner_only` clause in routes.md "SEO", disagree with this slice. Admin has `settings.manage_sites`, so an admin can now edit sites and their SEO defaults.
|
||||
- roles.md is inconsistent about Writer and Schema. The "Writer: Articles list" state hides Schema from the sidebar, while the catalogue, the built-in table and change-list ("Schema read-only for view-only roles") give Writer `schema.view`.
|
||||
- change-list line 100 mentions a "restricted `user` role" that does not exist.
|
||||
- The docs name `GET /v1/me`. The implemented route is `GET /v1/auth/me` (resolved in slice 1).
|
||||
- change-list item 15 and routes.md word activity export as "owner and admin"; it is now `activity.export` plus `activity.view`.
|
||||
- change-list item 22 says GET /v1/author works for any role; under 2a a role without `authors.view` gets 403.
|
||||
- The Home activity card may call activity summary, so a Writer would get 403 there; check before the SPA slice.
|
||||
|
||||
## Inventory (route, current gate, new key, Owner / Admin / Writer)
|
||||
|
||||
Routes gated today by RequireOwner or RequireOwnerOrAdmin:
|
||||
|
||||
| Route | New key (scope) | Owner / Admin / Writer | Change |
|
||||
|---|---|---|---|
|
||||
| POST /v1/user/invite, DELETE /v1/user/invite/{id}, POST /v1/user/invite/{id}/renew | users.invite | ok / ok / 403 | Admin gains |
|
||||
| PATCH /v1/settings | system.edit_retention | ok / 403 / 403 | none |
|
||||
| POST /v1/site, PATCH /v1/site/{id}, PATCH /v1/site/{id}/default | settings.manage_sites | ok / ok / 403 | Admin gains |
|
||||
| DELETE /v1/site/{id} | settings.delete_site | ok / 403 / 403 | none |
|
||||
| POST /v1/author | authors.create | ok / ok / 403 | none |
|
||||
| DELETE /v1/author/{id} | authors.delete | ok / ok / 403 | none |
|
||||
| POST, PUT /v1/content-type | schema.edit | ok / ok / 403 | none |
|
||||
| DELETE /v1/content-type/{key} | schema.delete | ok / ok / 403 | none |
|
||||
| GET /v1/builtin-type, /{key} | schema.view | ok / ok / ok | Writer gains |
|
||||
| GET /v1/activity/summary | activity.view at all | ok / ok / 403 | none |
|
||||
| GET /v1/activity/export | activity.export plus activity.view (same scoping as the list) | ok / ok / 403 | none |
|
||||
| POST /v1/system/update/check, /download, /restart | system.apply_updates | ok / 403 / 403 | none |
|
||||
| GET /v1/media/purge-settings | settings.view | ok / ok / 403 | none |
|
||||
| PATCH /v1/media/purge-settings | settings.edit_general | ok / ok / 403 | none |
|
||||
| GET /v1/media/purge-preview, POST /v1/media/purge | media.delete at all | ok / ok / 403 | none |
|
||||
| GET /v1/media/{id}/variants (after RejectAPIKey) | media.view | ok / ok / ok | Writer gains |
|
||||
| POST, DELETE /v1/media/{id}/variants... (after RejectAPIKey) | settings.edit_general | ok / ok / 403 | none |
|
||||
| GET /v1/image-variants | media.view | ok / ok / ok | Writer gains |
|
||||
| GET /v1/image-variants/cache | settings.view | ok / ok / 403 | none |
|
||||
| other image-variants mutations and cache purge | settings.edit_general | ok / ok / 403 | none |
|
||||
|
||||
Gated by a legacy `Role ==` inside a use case or handler:
|
||||
|
||||
| Place | New |
|
||||
|---|---|
|
||||
| ListActivity.scopeToViewer, activityHandler.writeActivityError | activity.view: all unfiltered, own forced to self (another actor gives 403 missing_permission), none gives 403 |
|
||||
| ListAPIKeys | apikeys.view: all lists everything with created_by_name, own only the caller's keys without it, none 403 |
|
||||
| RevokeAPIKey | apikeys.revoke: all any key, own only the caller's (404 API key not found on another's), none 403 |
|
||||
| ListAuthors (CallerRole, isAdminRole) | gate authors.view; user_email only if the caller holds users.view |
|
||||
| UpdateAuthor / AuthorCaller.isAdmin | authors.edit: all any author and the link, own the linked author only (else author_rights), none 403 missing_permission |
|
||||
| updateHandler.isOwner | can_apply true only with system.apply_updates (and helper installed, as today) |
|
||||
| middleware.RequireOwner, RequireOwnerOrAdmin | deleted; replaced by RequirePermission(key) and a scope-aware variant resolving the live role |
|
||||
|
||||
Previously ungated or verification-only:
|
||||
|
||||
| Route | New |
|
||||
|---|---|
|
||||
| GET /v1/user | users.view |
|
||||
| GET /v1/settings | settings.view |
|
||||
| GET /v1/system/update | settings.view |
|
||||
| GET /v1/author | authors.view |
|
||||
| GET /v1/content-type, /{key} | schema.view |
|
||||
| GET /v1/media, /{id}, /{id}/usage | media.view |
|
||||
| POST /v1/media | media.upload |
|
||||
| PATCH /v1/media/{id} | media.upload |
|
||||
| DELETE /v1/media/{id}, POST /v1/media/merge | media.delete at all (own fails closed) |
|
||||
| POST /v1/api-key | apikeys.create (RequireSession stays) |
|
||||
| GET /v1/api-key, DELETE /v1/api-key/{id} | apikeys.view, apikeys.revoke (scoped) |
|
||||
| GET /v1/activity | activity.view (scoped) |
|
||||
| GET /v1/site, GET /v1/user/lookup, PATCH /v1/user/me, POST /v1/author/me, GET /v1/permission, GET /v1/auth/me | ungated |
|
||||
| /v1/article/*, /v1/content-type/{key}/entry/*, public reads | ungated in 2a |
|
||||
|
||||
## Test infrastructure first
|
||||
|
||||
1. `enforcementRoleRepo` implements the role `Repository` in memory, seeded with the three built-ins through the same Go function the migration uses. It supports editing grants and a switchable failure.
|
||||
2. The user fake carries `RoleID`. Add users 3 (Owner), 5 (Admin), 9 (Writer), 12 (unverified Admin), 13 (role_id 99), 14-19 (legacy and role_id combinations from the Outline), 20 (custom role).
|
||||
3. A helper `mintCookie(userID, tokenVersion)` and a helper to mint API keys backed by the existing `apikey` fakes (see `test/api_keys_http_test.go`).
|
||||
4. HTTP harness. Call `validator.InitValidator()`. Build each router with the same middleware chain as `api/router.go` for that group, now with the permission middleware, over use cases wired to fakes.
|
||||
- Follow whichever approach the closest existing HTTP test uses. Existing tests for `NewUserRouter`, `NewSettingsRouter`, `NewSiteRouter`, `NewAuthorRouter`, `NewContentTypeRouter`, `NewBuiltinTypeRouter`, `NewActivityRouter`, `NewSystemRouter`, `NewAPIKeyRouter`, `NewMediaRouter` and `NewImageVariantRouter` need the role repository added to their fixtures.
|
||||
- Mount `authtoken.Verifier` and `container.apiKeyAuth()` equivalents where the real router has them, so key scenarios traverse the same order.
|
||||
- No live server, no real SQLite file, no real SMTP, no real update helper.
|
||||
5. A gate-matrix helper `assertGate(t, rec, want)`. For "ok" assert the status is not the `missing_permission` 403 and the handler or use case spy was reached. For "403" assert status 403, body `missing_permission\n`, header `X-Missing-Permission` equals the key, and the spy was not reached.
|
||||
|
||||
## A. Domain and use cases (fakes only)
|
||||
|
||||
6. Permission check use case (name left to coder, for example `CheckPermission`): table-test owner, admin, writer, a custom role, unresolvable role (expect `role.ErrMissingPermission` via `errors.Is`; the failing key travels in a typed error via `errors.As`), a failing repository (expect a non-sentinel error), and a live grant change between two calls.
|
||||
7. `ListActivity`: all, own (self filter forced, other actor returns `audit.ErrActorNotAllowed`), none. No `user.Role`.
|
||||
8. `ListAPIKeys` and `RevokeAPIKey`: all, own, none. `created_by_name` follows scope all. Own on another's key returns `apikey.ErrAPIKeyNotFound`.
|
||||
9. `ListAuthors` and `UpdateAuthor`: `user_email` follows `users.view`. `authors.edit` own, all, none, including `author.ErrAuthorRights` for own violations.
|
||||
10. The `can_apply` computation in the update status follows `system.apply_updates`.
|
||||
11. Hygiene tests, in the style of the existing `*_hygiene_test.go`:
|
||||
- `api/router.go` and `api/middleware` contain no `RequireOwner`.
|
||||
- Non-test Go under `api/` and `internal/infrastructure/useCases` has no `user.RoleOwner` or `user.RoleAdmin` comparison apart from the allowed spots listed in the feature.
|
||||
- `internal/domain/role` still imports only the stdlib.
|
||||
|
||||
## B. HTTP
|
||||
|
||||
12. Gate matrices: run every Scenario Outline row. One test per outline, subtests per row, named after the row.
|
||||
13. Contract: status 403, body `missing_permission\n`, `X-Missing-Permission`, no side effect (no invite stored, no mail sent, no site created, retention unchanged).
|
||||
14. Order: stale token 401, no cookie 401, unverified 403 verification, API key on session-only routes 403 `session_required`, API key on `/v1/media/1/variants` 401, all before the permission check. Limiter order: 6 admin update checks give 6 times 403; exhausted invite limiter gives 403 for a Writer.
|
||||
15. Scoped: activity (list, summary, export), api keys (list, revoke), authors (edit, `user_email`) per the Outlines.
|
||||
16. API key live role: change `role_id` between requests, edit grants between requests, and unresolvable role. Use the same key value throughout.
|
||||
17. Session live role: edit grants between two requests with the same cookie.
|
||||
18. Legacy string: the six rows of the "legacy role string" Outline.
|
||||
19. Ungated: public routes, `/v1/auth/me`, `/v1/permission`, `/v1/site`, `/v1/user/lookup`, `PATCH /v1/user/me`, `POST /v1/author/me` answer for a user whose role has no grants. Article and entry routes behave as in their existing tests.
|
||||
20. Regression: run the existing suites for the owner and admin cases in the "does not regress" Outline unchanged, after the assertion edits listed below.
|
||||
|
||||
## Existing tests that must change (found by grep, so recheck with `go vet`)
|
||||
|
||||
Assertions of the removed constants become `missing_permission`, or the expectation flips where Admin gains access.
|
||||
|
||||
| File (lines) | Change |
|
||||
|---|---|
|
||||
| test/roles_permissions_foundation_http_test.go (around 320, the "Existing role checks are untouched" Outline) | Superseded. The admin rows for invite and `/system/update/check` move to this slice (invite becomes ok, update check becomes `missing_permission`). Keep the owner rows. |
|
||||
| test/update_http_test.go:248; test/update_download_http_test.go:187; test/update_restart_http_test.go:263-264 | Admin 403 body becomes `missing_permission` |
|
||||
| test/activity_log_backend_http_test.go:280, 735; test/activity_filter_cards_and_export_http_test.go:75; test/activity_next_cleanup_test.go:199 | Message becomes `missing_permission`. The "editor" fixtures need a Writer or custom role_id. Admin retention read stays 200. |
|
||||
| test/user_roles_test.go:280; test/invite_management_test.go:410; test/api_keys_http_test.go:434, 573 | Admin invite is now allowed. Other Owner-required assertions become `missing_permission`. API key list and revoke fixtures need roles. |
|
||||
| test/seo_output_sitemap_http_test.go:167; test/seo_data_site_http_test.go; test/site_acceptance_test.go; test/authors_backend_http_test.go | Admin PATCH /v1/site/{id} is now allowed. Message assertions for non-admins become `missing_permission`. |
|
||||
| test/custom_content_types_test.go:131 | "member without a role" gets 403 `missing_permission`. The fixture needs an unresolvable or empty role. |
|
||||
| test/media_purge_http_test.go:160-167; test/media_purge_qa_test.go:31; test/image_variants_per_image_http_test.go:152-155; test/image_variants_cache_http_test.go:32-33; test/image_variants_admin_http_test.go:42-47 | The "editor", "plain" and "empty role" users need role_ids. Expected body becomes `missing_permission`. |
|
||||
| every test that builds users with a legacy `Role` and no `RoleID` | Add `RoleID` (or rely on the fallback for "owner" and "admin" only). Users with legacy roles like "editor" or "" are now unresolvable. |
|
||||
|
||||
## Existing features to amend or mark superseded
|
||||
|
||||
Edit the owner/admin 403 clauses in place or add a "superseded by roles_permissions_enforcement" note.
|
||||
|
||||
- roles_permissions_foundation (last Outline)
|
||||
- user_roles, invite_management, invitations
|
||||
- sites_backend, sites_authors_integration, seo_data_backend, seo_output_backend, seo_sites_articles_integration
|
||||
- authors_backend
|
||||
- activity-log-backend, activity_filter_cards_and_export, activity_home_card_and_retention
|
||||
- api_keys, integration_api_key_routes, integration
|
||||
- media_purge, media_cleanup_spa, image-variants, image_variants_cache, image_variants_per_image_api
|
||||
- updates_download_stage, updates_restart_helper, updates_release_feed, container_update_guidance
|
||||
- custom_content_types, custom_content_types_support
|
||||
|
||||
Also check the matching `.qa.md` files and these non-test texts: `web/src/data/apiCatalogue.ts` (OWNER_REQUIRED_MESSAGE, OWNER_OR_ADMIN_MESSAGE), `web/src/views/IntegrationView.vue` ("Owner and admin routes answer 403…") and anything under `internal/infrastructure/llmdocs` that documents the old messages. The SPA stores handle 403 by status only, so they should be unaffected; this is a manual check.
|
||||
|
||||
## Manual verification
|
||||
|
||||
- Start `task dev:api` on a copy of a migrated database. As the admin, check that settings are readable and software and retention updates return 403. As the owner, check everything still works.
|
||||
- Create an API key as an admin, change that user to Writer by editing `users.role_id` in the database copy, and confirm the same key now gets 403 on a schema write.
|
||||
- The SPA is untouched, so the admin will still see owner-only UI hidden. That is expected until the SPA slices.
|
||||
@@ -1,4 +1,5 @@
|
||||
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.
|
||||
|
||||
As the owner of a RenewCMS site
|
||||
I want permissions to be a fixed catalogue and roles to be stored sets of (permission, scope)
|
||||
@@ -287,7 +288,7 @@ Feature: Roles and permissions foundation: catalogue, built-in roles, backfills
|
||||
Examples:
|
||||
| id | legacy | call | status |
|
||||
| 3 | owner | POST /v1/user/invite | allowed (not 403) |
|
||||
| 5 | admin | POST /v1/user/invite | 403 apperr.OwnerRequiredErrorMessage |
|
||||
| 5 | admin | POST /v1/user/invite | allowed (not 403) |
|
||||
| 5 | admin | POST /v1/content-type | allowed (not 403) |
|
||||
| 5 | admin | PATCH /v1/settings | 403 apperr.OwnerRequiredErrorMessage |
|
||||
| 5 | admin | POST /v1/system/update/check | 403 apperr.OwnerRequiredErrorMessage |
|
||||
| 5 | admin | PATCH /v1/settings | 403 apperr.MissingPermissionErrorMessage |
|
||||
| 5 | admin | POST /v1/system/update/check | 403 apperr.MissingPermissionErrorMessage |
|
||||
@@ -1,5 +1,7 @@
|
||||
# QA procedure: roles_permissions_foundation
|
||||
|
||||
> 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.
|
||||
|
||||
Package: `test` (black-box). Name new fakes uniquely, for example `mockRoleRepo`, `rolesFoundationUserRepo`.
|
||||
Do not copy the old `Role ==` checks into new code.
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
Feature: SEO data backend, article and site search fields, unique slugs and media usage
|
||||
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.
|
||||
|
||||
As an editor or the owner of a RenewCMS installation
|
||||
I want to store search and sharing data on articles and sites, with a slug that is unique per site
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# QA procedure: SEO data backend (slice 1 of 4)
|
||||
|
||||
> 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.
|
||||
|
||||
Spec: `features/seo_data_backend.feature`. Go tests only, in `test/` (package `test`), run with `task test`. No live server, no real SMTP, no real SQLite file outside the temp-dir repository-contract harness. Call `validator.InitValidator()` before any handler call. Error assertions compare the plain-text body to the exact token or constant named in the spec. The 409 `slug_taken` assertion checks the plain-text body `slug_taken` and the `X-Slug-Suggestion` header.
|
||||
|
||||
## Divergences from docs/design
|
||||
@@ -7,7 +9,7 @@ Spec: `features/seo_data_backend.feature`. Go tests only, in `test/` (package `t
|
||||
`docs/design` is read-only; the SPA slices (3 and 4) align to the code, not to these design words.
|
||||
|
||||
- **Site image and robots names:** the API uses `default_social_image_id` and `default_robots` (existing site contract). The design writes `social_image_id` for the site and, in places, `robots`. The SPA uses the code names.
|
||||
- **Non-owner site writes:** the API answers 403 with the existing text "Only the owner can do this." (`apperr.OwnerRequiredErrorMessage`). The design mentions a 403 `owner_only` token, which does not exist.
|
||||
- **Non-owner site writes:** the API answers 403 with the existing text "Only the owner can do this." (`apperr.MissingPermissionErrorMessage`). The design mentions a 403 `owner_only` token, which does not exist.
|
||||
- **Empty-slug fallback:** one rule everywhere. A title with no letter or digit becomes `article`, then `article-2`, `article-3` on conflict, in the API and in the migration. `docs/design/seo-slices.md` (decision 5) says `article-<id>` for the migration; that wording is not followed.
|
||||
- **Slug conflict transport:** the conflict follows the authors slug conflict (plain text `slug_taken`, 409); the suggestion travels in the `X-Slug-Suggestion` response header, not in a JSON body. The `slug.suggestion` of the slice 2 check endpoint is separate.
|
||||
- **Empty slug:** `slug: ""` regenerates the slug from the title (the SPA "Reset to match the title" sends `""`); 409 only if the regenerated slug belongs to another article on the site.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
Feature: SEO output backend, resolved SEO object, non-persisting check, list status and sitemap
|
||||
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.
|
||||
|
||||
As a frontend developer or the RenewCMS back-office editor
|
||||
I want the resolved SEO data of an article, a non-persisting check, an SEO status on the article list and a sitemap per site
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# QA procedure: SEO output backend (slice 2 of 4)
|
||||
|
||||
> 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.
|
||||
|
||||
Spec: `features/seo_output_backend.feature`. Go tests only, in `test/` (package `test`), run with `task test`. No live server, no real SMTP, no real SQLite file outside the temp-dir repository-contract harness. Call `validator.InitValidator()` before any handler call. Error assertions compare the plain-text body to the exact token or constant named in the spec. Success bodies are JSON. Assert exact key sets by decoding into `map[string]any` and comparing sorted key lists.
|
||||
|
||||
## Divergences from docs/design
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
Feature: SEO round, slice 4: Sites defaults, Articles search status, Integration "Search and sharing" and suggestion dismissal
|
||||
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.
|
||||
|
||||
As the owner of a RenewCMS install, and as anyone who edits articles or builds a frontend on the API
|
||||
I want to set the site-wide SEO defaults, see which articles need attention, dismiss suggestions I do not want, and read how the SEO routes work
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# QA: SEO round, slice 4 (Sites defaults, Articles search status, Integration, suggestion dismissal)
|
||||
|
||||
> 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.
|
||||
|
||||
SPA slice only. No Go change here: the API of `features/seo_data_backend.feature` and `features/seo_output_backend.feature` already exists, and `counts.suggestions` is covered by the httptest part of `features/articles_pagination_api.qa.md`, so this file has no httptest part. A wrong contract is reported as a backend defect.
|
||||
|
||||
Roles: the domain defines only `owner` and `admin`. "verified" is a verified account without either role.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
Feature: Sites and authors in the Editor, Articles and Profile screens
|
||||
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.
|
||||
|
||||
As a RenewCMS user who can edit articles
|
||||
I want to choose an article's site and bylines in the editor, see them in the articles list, and create my own author profile
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# QA: Sites and authors in Editor, Articles and Profile
|
||||
|
||||
> 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.
|
||||
|
||||
SPA slice only. No Go change: the API already exists (commits "Sites feature backend" and "Authors feature backend"). A wrong contract is reported as a backend defect.
|
||||
|
||||
Roles: the domain defines only `owner` and `admin` (`internal/domain/user`). There is no "member" constant. "verified" in the feature and below is a verified account without the owner or admin role, which has no author rights. The earlier `sites_authors_screens` files still say "member" for the same account; that wording is left as is.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
Feature: Sites backend, the owner manages sites and every article belongs to exactly 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.
|
||||
|
||||
As the owner of a RenewCMS installation
|
||||
I want to create, edit, delete and choose a default among several sites, and assign each article to one
|
||||
@@ -14,7 +15,7 @@ Feature: Sites backend, the owner manages sites and every article belongs to exa
|
||||
And GET /v1/site/ accepts a session or an API key; every write is session-only and goes through RequireOwner, like PATCH /v1/settings
|
||||
And PATCH /v1/article/{id} with site_id stays callable with an API key
|
||||
And error responses are plain text from http.Error whose body is exactly the code token: site_key_taken, site_has_articles, default_site, key_immutable, invalid_site_url, invalid_locale, site_not_found, image_not_found, use_set_default, invalid_reassign
|
||||
And a non-owner write answers 403 with the existing RequireOwner body "Only the owner can do this." (apperr.OwnerRequiredErrorMessage), with no owner_only token
|
||||
And a non-owner write answers 403 with the existing RequireOwner body "Only the owner can do this." (apperr.MissingPermissionErrorMessage), with no owner_only token
|
||||
And a request body that cannot be decoded answers 400 with apperr.BodyErrorMessage ("The request cannot be processed due to a mismatch in the format of the body."), a malformed id answers 400 with apperr.IdUint32ErrorMessage, and a validate-tag failure answers 400 with the validator error text, like POST /v1/article
|
||||
And a missing article answers 404 "Article not found" (apperr.ArticleNotFoundErrorMessage)
|
||||
And the roles are "owner" and "admin" only, and a "verified" account is one with is_verified true
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# QA procedure: Sites backend
|
||||
|
||||
> 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.
|
||||
|
||||
Spec: `features/sites_backend.feature`. Go tests only, in `test/` (package `test`), run with `task test`. No live server, no real SMTP, no real SQLite file outside the temp-dir repository-contract harness. Call `validator.InitValidator()` before any handler call. Every error assertion compares the plain-text body to the exact code token.
|
||||
|
||||
## Setup
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
Feature: Updates part 2: the Update button (download, verify, stage)
|
||||
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.
|
||||
|
||||
As the owner of a RenewCMS site
|
||||
I want one button that downloads the new release, proves it is genuine, backs up the database and stages it
|
||||
@@ -100,7 +101,7 @@ Feature: Updates part 2: the Update button (download, verify, stage)
|
||||
|
||||
Scenario: Only the owner
|
||||
When an admin sends POST /v1/system/update/download
|
||||
Then the status is 403 and the body is apperr.OwnerRequiredErrorMessage
|
||||
Then the status is 403 and the body is apperr.MissingPermissionErrorMessage
|
||||
And no job starts and the fetcher is not called
|
||||
|
||||
Scenario Outline: POST is guarded like the other system routes
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# QA: Updates part 2 (download, verify, stage)
|
||||
|
||||
> 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.
|
||||
|
||||
## Automated Go (`task test`, package `test`, black-box, no live network, no real SMTP)
|
||||
|
||||
Files: `test/update_download_test.go` (domain, use case), `test/update_download_http_test.go` (HTTP),
|
||||
@@ -29,7 +31,7 @@ the TLS cases, a small size cap and a short timeout. Never relax the policy by e
|
||||
5. Persistence: JSON job store round trip in a temp dir; "downloading" at startup reads failed/"download"; "ready" with the staged
|
||||
binary deleted reads idle; a missing or corrupt file reads idle without error. The real helper detector reads
|
||||
`<data dir>/update-helper.json`: absent file gives false, a present valid file gives true.
|
||||
6. Guards: admin 403 with `apperr.OwnerRequiredErrorMessage+"\n"`; no cookie 401; stale token 401; unverified 403; none start a job.
|
||||
6. Guards: admin 403 with `apperr.MissingPermissionErrorMessage+"\n"`; no cookie 401; stale token 401; unverified 403; none start a job.
|
||||
7. Status codes: available false or nothing stored gives 400; detector off gives 409; state downloading, verifying, ready each give
|
||||
409; after "failed" a new POST gives 202 with a new started_at. Order check: detector off and available false gives 409.
|
||||
8. Concurrency: two simultaneous POSTs (goroutines) give one 202 and one 409; each asset fetched once (run with `-race`).
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
Feature: Updates part 1: signed releases, release feed and read-only update notification
|
||||
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.
|
||||
|
||||
As the owner of a RenewCMS site
|
||||
I want to see when a newer signed release exists, and have admins see the same status read-only
|
||||
@@ -142,7 +143,7 @@ Feature: Updates part 1: signed releases, release feed and read-only update noti
|
||||
Scenario: An admin cannot check
|
||||
Given the caller is an admin
|
||||
When the client sends POST /v1/system/update/check
|
||||
Then the status is 403 and the body is apperr.OwnerRequiredErrorMessage
|
||||
Then the status is 403 and the body is apperr.MissingPermissionErrorMessage
|
||||
And the feed is not contacted
|
||||
|
||||
Scenario: Feed failure keeps the last result
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# QA: Updates part 1 (signed releases, release feed, notification)
|
||||
|
||||
> 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.
|
||||
|
||||
## Automated Go (`task test`, package `test`, black-box)
|
||||
|
||||
Files: `test/update_test.go` (domain, use cases), `test/update_http_test.go` (HTTP), `test/update_feed_test.go`
|
||||
@@ -24,7 +26,7 @@ server, no SQLite, no SMTP.
|
||||
`apperr.VerificationRequiredErrorMessage`.
|
||||
6. `POST /v1/system/update/check` as owner: 200, store updated with latest, `release_url` and `checked_at`, feed called
|
||||
once, a later GET equals it. Nothing newer: `available` false. Running version "dev": `available` false.
|
||||
7. POST check as admin: 403, body `apperr.OwnerRequiredErrorMessage+"\n"`, feed not called.
|
||||
7. POST check as admin: 403, body `apperr.MissingPermissionErrorMessage+"\n"`, feed not called.
|
||||
8. Feed error: 500, store unchanged (latest and `checked_at` as before), body contains no feed error text.
|
||||
9. Rate limit: owner A sends 6 checks: first 5 are 200, the 6th is 429 with `apperr.TooManyRequestsErrorMessage+"\n"`
|
||||
and an integer `Retry-After` of at least 1; feed called 5 times; owner B's first check still 200; a login-limiter
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
Feature: Updates part 3: Restart, the privileged helper and rollback
|
||||
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.
|
||||
|
||||
As the owner of a RenewCMS site
|
||||
I want one Restart button that installs the staged release, or just restarts the site, and puts the old version back if the new one does not come up
|
||||
@@ -122,7 +123,7 @@ Feature: Updates part 3: Restart, the privileged helper and rollback
|
||||
| no jwt cookie | 401 | the existing 401 text |
|
||||
| a stale token version | 401 | the existing 401 text |
|
||||
| an unverified owner | 403 | the existing unverified text |
|
||||
| an admin | 403 | apperr.OwnerRequiredErrorMessage |
|
||||
| an admin | 403 | apperr.MissingPermissionErrorMessage |
|
||||
|
||||
Scenario Outline: Restart is refused with 409
|
||||
Given the owner and <condition>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# QA: Updates part 3 (restart, helper, rollback)
|
||||
|
||||
> 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.
|
||||
|
||||
## Automated Go (`task test`, package `test`, black-box, no live systemd, no real SMTP, no network)
|
||||
|
||||
Files: `test/update_restart_http_test.go` (HTTP), `test/update_restart_usecase_test.go` (request and status use cases),
|
||||
@@ -41,7 +43,7 @@ plus the fakes where noted. Helper time is driven by `fakeClock`: no `time.Sleep
|
||||
"Restart site never installs": owner A's body `{"kind":"restart"}` after the fake job store turned "ready" gives 409, no marker, staged files untouched.
|
||||
4. Bad body: `{"kind":"upgrade"}`, `{"kind":""}`, `{"kind":7}`, truncated JSON, `not json` give 400 plain text, no marker. The struct uses
|
||||
`validate:"omitempty,oneof=update restart"` and goes through `validator.Validate.Struct`; an empty body and `{}` are accepted.
|
||||
5. Guards: admin 403 with `apperr.OwnerRequiredErrorMessage+"\n"`; no cookie 401; stale token 401; unverified 403; none writes a marker or deletes the result.
|
||||
5. Guards: admin 403 with `apperr.MissingPermissionErrorMessage+"\n"`; no cookie 401; stale token 401; unverified 403; none writes a marker or deletes the result.
|
||||
Order: an admin with a bad body gets 403, an owner with a bad body and can_apply off gets 400, can_apply off and a kind mismatch gives 409.
|
||||
6. 409 table: can_apply off, downloading, verifying, marker pending, result "running": no second marker, existing files untouched.
|
||||
7. Concurrency (`-race`): two simultaneous POSTs give one 202, one 409, one marker.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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.
|
||||
|
||||
As the person who registered the workspace
|
||||
I want my account to be the owner and everyone I invite to be an admin
|
||||
@@ -22,7 +23,7 @@ Feature: Users have a role, owner or admin, and only the owner can invite
|
||||
created_at; invite items are unchanged: id, kind, email, status, created_at
|
||||
And POST /v1/user/invite is owner-only: after the jwt, current-token and verified checks, a
|
||||
RequireOwner check loads the user with GetUserUseCase and refuses any role other than
|
||||
"owner" with 403 and the plain-text body apperr.OwnerRequiredErrorMessage
|
||||
"owner" with 403 and the plain-text body apperr.MissingPermissionErrorMessage
|
||||
"Only the owner can do this.", before the rate limiter, the body decoding and the validation
|
||||
And GET /v1/user/ stays open to every verified user, owner and admin alike
|
||||
And error responses are plain text from http.Error, success responses are JSON
|
||||
@@ -82,7 +83,7 @@ Feature: Users have a role, owner or admin, and only the owner can invite
|
||||
Given a verified admin id 2 "jonas" and a valid jwt cookie for user id 2
|
||||
When the client sends POST /v1/user/invite with body {"email": "sam.oliveira@renew.example"}
|
||||
Then the response status is 403
|
||||
And the response body is exactly apperr.OwnerRequiredErrorMessage, as plain text
|
||||
And the response body is exactly apperr.MissingPermissionErrorMessage, as plain text
|
||||
And the invite repository is still empty
|
||||
And no email was sent
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# QA procedure: user_roles
|
||||
|
||||
> 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.
|
||||
|
||||
Package: `test` (black-box). Reuse `mockUserRepo`, `stubHasher`, `mockMailRepo`, `mockInviteRepo`.
|
||||
No live server, no real SQLite file, no real SMTP.
|
||||
|
||||
@@ -34,7 +36,7 @@ No live server, no real SQLite file, no real SMTP.
|
||||
|
||||
## POST /v1/user/invite
|
||||
13. Owner cookie, valid body -> 201, one invite, one "invitation" mail.
|
||||
14. Admin cookie, valid body -> 403, body exactly `apperr.OwnerRequiredErrorMessage`, `Content-Type`
|
||||
14. Admin cookie, valid body -> 403, body exactly `apperr.MissingPermissionErrorMessage`, `Content-Type`
|
||||
is not JSON-decoded (plain text), no invite, no mail.
|
||||
15. Admin cookie with `{"email":"not-an-email"}` and with `not json` -> 403 both.
|
||||
16. Rate limit 2: admin sends 3 requests -> 403, 403, 403.
|
||||
|
||||
@@ -11,6 +11,8 @@ var (
|
||||
ErrRoleConflict = errors.New("role was modified concurrently")
|
||||
ErrInvalidScope = errors.New("invalid scope for permission")
|
||||
ErrOwnerOnlyGrant = errors.New("owner-only permission granted to a non-owner role")
|
||||
|
||||
ErrMissingPermission = errors.New("missing permission")
|
||||
)
|
||||
|
||||
type Scope string
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package role
|
||||
|
||||
type MissingPermissionError struct {
|
||||
Key string
|
||||
}
|
||||
|
||||
func (e MissingPermissionError) Error() string {
|
||||
return ErrMissingPermission.Error() + ": " + e.Key
|
||||
}
|
||||
|
||||
func (e MissingPermissionError) Is(target error) bool {
|
||||
return target == ErrMissingPermission
|
||||
}
|
||||
|
||||
type GrantSet map[string]Scope
|
||||
|
||||
func NewGrantSet(grants []Grant) GrantSet {
|
||||
set := make(GrantSet, len(grants))
|
||||
for _, grant := range grants {
|
||||
set[grant.Key] = grant.Scope
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
func (g GrantSet) Scope(key string) Scope {
|
||||
scope, present := g[key]
|
||||
if !present {
|
||||
return ScopeNone
|
||||
}
|
||||
return scope
|
||||
}
|
||||
|
||||
func (g GrantSet) Holds(key string) bool {
|
||||
scope := g.Scope(key)
|
||||
return scope == ScopeOwn || scope == ScopeAll
|
||||
}
|
||||
|
||||
func (g GrantSet) HoldsAll(key string) bool {
|
||||
return g.Scope(key) == ScopeAll
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package role
|
||||
|
||||
const (
|
||||
KeyActivityView = "activity.view"
|
||||
KeyActivityExport = "activity.export"
|
||||
KeyAPIKeysView = "apikeys.view"
|
||||
KeyAPIKeysRevoke = "apikeys.revoke"
|
||||
KeyAuthorsEdit = "authors.edit"
|
||||
KeyUsersView = "users.view"
|
||||
KeyApplyUpdates = "system.apply_updates"
|
||||
)
|
||||
@@ -0,0 +1,55 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/role"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"errors"
|
||||
)
|
||||
|
||||
type CheckPermissionUseCase struct {
|
||||
roles role.Repository
|
||||
}
|
||||
|
||||
func NewCheckPermissionUseCase(roles role.Repository) *CheckPermissionUseCase {
|
||||
return &CheckPermissionUseCase{roles: roles}
|
||||
}
|
||||
|
||||
func (c *CheckPermissionUseCase) Require(account user.User, key string) (role.GrantSet, error) {
|
||||
grants, err := c.grantsOf(account, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !grants.Holds(key) {
|
||||
return nil, role.MissingPermissionError{Key: key}
|
||||
}
|
||||
return grants, nil
|
||||
}
|
||||
|
||||
func (c *CheckPermissionUseCase) RequireAll(account user.User, key string) (role.GrantSet, error) {
|
||||
grants, err := c.grantsOf(account, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !grants.HoldsAll(key) {
|
||||
return nil, role.MissingPermissionError{Key: key}
|
||||
}
|
||||
return grants, nil
|
||||
}
|
||||
|
||||
func (c *CheckPermissionUseCase) grantsOf(account user.User, key string) (role.GrantSet, error) {
|
||||
resolved, err := resolveRole(c.roles, account)
|
||||
if errors.Is(err, role.ErrRoleNotFound) {
|
||||
return nil, role.MissingPermissionError{Key: key}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
granted, err := c.roles.ListGrants(resolved.ID)
|
||||
if errors.Is(err, role.ErrRoleNotFound) {
|
||||
return nil, role.MissingPermissionError{Key: key}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return role.NewGrantSet(granted), nil
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/domain/role"
|
||||
"encoding/csv"
|
||||
"io"
|
||||
"strings"
|
||||
@@ -29,11 +30,20 @@ func NewExportActivityUseCase(events audit.Repository, now func() time.Time) *Ex
|
||||
return &ExportActivityUseCase{events, now}
|
||||
}
|
||||
|
||||
func (e *ExportActivityUseCase) Prepare(query ActivityQuery) (*ActivityExport, error) {
|
||||
filter, err := buildActivityFilter(query, e.now())
|
||||
type ExportActivityCommand struct {
|
||||
ViewerID uint32
|
||||
Grants role.GrantSet
|
||||
Query ActivityQuery
|
||||
}
|
||||
|
||||
func (e *ExportActivityUseCase) Prepare(command ExportActivityCommand) (*ActivityExport, error) {
|
||||
filter, err := buildActivityFilter(command.Query, e.now())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := scopeFilterToViewer(&filter, command.ViewerID, command.Grants); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ActivityExport{events: e.events, filter: filter}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -23,15 +23,15 @@ var legacyBuiltInNames = map[user.Role]string{
|
||||
user.RoleAdmin: role.AdminName,
|
||||
}
|
||||
|
||||
func (u *GetMyAccessUseCase) resolveRole(account user.User) (role.Role, error) {
|
||||
func resolveRole(roles role.Repository, account user.User) (role.Role, error) {
|
||||
if account.RoleID != 0 {
|
||||
return u.roles.Get(account.RoleID)
|
||||
return roles.Get(account.RoleID)
|
||||
}
|
||||
name, known := legacyBuiltInNames[account.Role]
|
||||
if !known {
|
||||
return role.Role{}, role.ErrRoleNotFound
|
||||
}
|
||||
found, err := u.roles.GetByName(name)
|
||||
found, err := roles.GetByName(name)
|
||||
if err != nil {
|
||||
return role.Role{}, err
|
||||
}
|
||||
@@ -42,7 +42,7 @@ func (u *GetMyAccessUseCase) resolveRole(account user.User) (role.Role, error) {
|
||||
}
|
||||
|
||||
func (u *GetMyAccessUseCase) Get(account user.User) (Access, error) {
|
||||
resolved, err := u.resolveRole(account)
|
||||
resolved, err := resolveRole(u.roles, account)
|
||||
if err != nil {
|
||||
return Access{}, err
|
||||
}
|
||||
|
||||
@@ -19,8 +19,8 @@ func NewGetUpdateStatusUseCase(store update.StatusStore, jobs update.JobStore, h
|
||||
}
|
||||
}
|
||||
|
||||
func (g *GetUpdateStatusUseCase) GetStatus(isOwner bool) update.UpdateStatus {
|
||||
status := g.status.build(isOwner && g.helper.Installed())
|
||||
func (g *GetUpdateStatusUseCase) GetStatus(canApply bool) update.UpdateStatus {
|
||||
status := g.status.build(canApply && g.helper.Installed())
|
||||
status.Container = g.container
|
||||
return status
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/apikey"
|
||||
"RenewCMS/internal/domain/role"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"errors"
|
||||
"sort"
|
||||
@@ -25,13 +26,17 @@ func NewListAPIKeysUseCase(apiKeyRepository apikey.Repository, userRepository us
|
||||
return &ListAPIKeysUseCase{apiKeyRepository, userRepository, now}
|
||||
}
|
||||
|
||||
func (l *ListAPIKeysUseCase) ListAPIKeys(requesterId uint32) ([]APIKeyEntry, error) {
|
||||
func (l *ListAPIKeysUseCase) ListAPIKeys(requesterId uint32, grants role.GrantSet) ([]APIKeyEntry, error) {
|
||||
scope := grants.Scope(role.KeyAPIKeysView)
|
||||
if scope == role.ScopeNone {
|
||||
return nil, role.MissingPermissionError{Key: role.KeyAPIKeysView}
|
||||
}
|
||||
requester, err := l.userRepository.Get(requesterId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
keys, err := l.keysVisibleTo(requester)
|
||||
keys, err := l.keysVisibleTo(requester, scope)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -43,7 +48,7 @@ func (l *ListAPIKeysUseCase) ListAPIKeys(requesterId uint32) ([]APIKeyEntry, err
|
||||
return keys[i].CreatedAt.After(keys[j].CreatedAt)
|
||||
})
|
||||
|
||||
creatorNames, err := l.creatorNamesFor(requester, keys)
|
||||
creatorNames, err := l.creatorNamesFor(requester, scope, keys)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -51,14 +56,14 @@ func (l *ListAPIKeysUseCase) ListAPIKeys(requesterId uint32) ([]APIKeyEntry, err
|
||||
now := l.now()
|
||||
entries := make([]APIKeyEntry, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
entries = append(entries, APIKeyEntry{Key: key, Status: key.StatusAt(now), CreatorName: creatorNames[key.UserID], ListsCreator: requester.Role == user.RoleOwner})
|
||||
entries = append(entries, APIKeyEntry{Key: key, Status: key.StatusAt(now), CreatorName: creatorNames[key.UserID], ListsCreator: scope == role.ScopeAll})
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func (l *ListAPIKeysUseCase) creatorNamesFor(requester user.User, keys []apikey.APIKey) (map[uint32]string, error) {
|
||||
func (l *ListAPIKeysUseCase) creatorNamesFor(requester user.User, scope role.Scope, keys []apikey.APIKey) (map[uint32]string, error) {
|
||||
names := map[uint32]string{}
|
||||
if requester.Role != user.RoleOwner {
|
||||
if scope != role.ScopeAll {
|
||||
return names, nil
|
||||
}
|
||||
names[requester.ID] = requester.NameOrUsername()
|
||||
@@ -79,8 +84,8 @@ func (l *ListAPIKeysUseCase) creatorNamesFor(requester user.User, keys []apikey.
|
||||
return names, nil
|
||||
}
|
||||
|
||||
func (l *ListAPIKeysUseCase) keysVisibleTo(requester user.User) ([]apikey.APIKey, error) {
|
||||
if requester.Role == user.RoleOwner {
|
||||
func (l *ListAPIKeysUseCase) keysVisibleTo(requester user.User, scope role.Scope) ([]apikey.APIKey, error) {
|
||||
if scope == role.ScopeAll {
|
||||
return l.apiKeyRepository.ListAll()
|
||||
}
|
||||
return l.apiKeyRepository.ListByUser(requester.ID)
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/domain/author"
|
||||
"RenewCMS/internal/domain/image"
|
||||
"RenewCMS/internal/domain/role"
|
||||
"RenewCMS/internal/domain/site"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"errors"
|
||||
@@ -26,6 +27,7 @@ type ListActivityUseCase struct {
|
||||
|
||||
type ListActivityCommand struct {
|
||||
ViewerID uint32
|
||||
Grants role.GrantSet
|
||||
Query ActivityQuery
|
||||
}
|
||||
|
||||
@@ -53,7 +55,7 @@ func (l *ListActivityUseCase) ListActivity(command ListActivityCommand) (Activit
|
||||
if filter.Page, filter.Size, err = resolveActivityPaging(command.Query); err != nil {
|
||||
return ActivityPage{}, err
|
||||
}
|
||||
if err := l.scopeToViewer(&filter, command.ViewerID); err != nil {
|
||||
if err := scopeFilterToViewer(&filter, command.ViewerID, command.Grants); err != nil {
|
||||
return ActivityPage{}, err
|
||||
}
|
||||
|
||||
@@ -68,22 +70,6 @@ func (l *ListActivityUseCase) ListActivity(command ListActivityCommand) (Activit
|
||||
return ActivityPage{Items: entries, Total: found.Total, Page: filter.Page, Size: filter.Size}, nil
|
||||
}
|
||||
|
||||
func (l *ListActivityUseCase) scopeToViewer(filter *audit.Filter, viewerID uint32) error {
|
||||
viewer, err := l.users.Get(viewerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if viewer.Role == user.RoleOwner || viewer.Role == user.RoleAdmin {
|
||||
return nil
|
||||
}
|
||||
own := audit.ActorRef{Type: audit.ActorUser, ID: &viewer.ID}
|
||||
if filter.Actor != nil && !sameActor(*filter.Actor, own) {
|
||||
return audit.ErrActorNotAllowed
|
||||
}
|
||||
filter.Actor = &own
|
||||
return nil
|
||||
}
|
||||
|
||||
func sameActor(a audit.ActorRef, b audit.ActorRef) bool {
|
||||
return a.Type == b.Type && a.ID != nil && b.ID != nil && *a.ID == *b.ID
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/author"
|
||||
"RenewCMS/internal/domain/role"
|
||||
"RenewCMS/internal/domain/user"
|
||||
)
|
||||
|
||||
@@ -14,7 +15,7 @@ type ListAuthorsCommand struct {
|
||||
Q string
|
||||
Page int
|
||||
Size int
|
||||
CallerRole user.Role
|
||||
Grants role.GrantSet
|
||||
}
|
||||
|
||||
type AuthorPage struct {
|
||||
@@ -35,7 +36,7 @@ func (u *ListAuthorsUseCase) List(command ListAuthorsCommand) (AuthorPage, error
|
||||
}
|
||||
items := make([]author.Author, 0, len(found.Items))
|
||||
for _, item := range found.Items {
|
||||
if isAdminRole(command.CallerRole) {
|
||||
if command.Grants.Holds(role.KeyUsersView) {
|
||||
item = withUserEmail(u.users, item)
|
||||
}
|
||||
items = append(items, item)
|
||||
|
||||
@@ -3,34 +3,33 @@ package useCases
|
||||
import (
|
||||
"RenewCMS/internal/domain/apikey"
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"RenewCMS/internal/domain/role"
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
type RevokeAPIKeyUseCase struct {
|
||||
apiKeyRepository apikey.Repository
|
||||
userRepository user.Repository
|
||||
now func() time.Time
|
||||
recorder audit.Recorder
|
||||
}
|
||||
|
||||
func NewRevokeAPIKeyUseCase(apiKeyRepository apikey.Repository, userRepository user.Repository, now func() time.Time, recorder audit.Recorder) *RevokeAPIKeyUseCase {
|
||||
return &RevokeAPIKeyUseCase{apiKeyRepository, userRepository, now, recorder}
|
||||
func NewRevokeAPIKeyUseCase(apiKeyRepository apikey.Repository, now func() time.Time, recorder audit.Recorder) *RevokeAPIKeyUseCase {
|
||||
return &RevokeAPIKeyUseCase{apiKeyRepository, now, recorder}
|
||||
}
|
||||
|
||||
func (r *RevokeAPIKeyUseCase) RevokeAPIKey(ctx context.Context, requesterId uint32, keyId uint32, grants role.GrantSet) error {
|
||||
scope := grants.Scope(role.KeyAPIKeysRevoke)
|
||||
if scope == role.ScopeNone {
|
||||
return role.MissingPermissionError{Key: role.KeyAPIKeysRevoke}
|
||||
}
|
||||
|
||||
func (r *RevokeAPIKeyUseCase) RevokeAPIKey(ctx context.Context, requesterId uint32, keyId uint32) error {
|
||||
key, err := r.apiKeyRepository.Get(keyId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
requester, err := r.userRepository.Get(requesterId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if requester.Role != user.RoleOwner && key.UserID != requester.ID {
|
||||
if scope != role.ScopeAll && key.UserID != requesterId {
|
||||
return apikey.ErrAPIKeyNotFound
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/domain/author"
|
||||
"RenewCMS/internal/domain/image"
|
||||
"RenewCMS/internal/domain/role"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"context"
|
||||
"strings"
|
||||
@@ -21,6 +22,9 @@ func NewUpdateAuthorUseCase(authors author.Repository, users user.Repository, im
|
||||
}
|
||||
|
||||
func (u *UpdateAuthorUseCase) Update(ctx context.Context, id uint32, caller AuthorCaller, patch author.Patch) (author.Author, error) {
|
||||
if !caller.Grants.Holds(role.KeyAuthorsEdit) {
|
||||
return author.Author{}, role.MissingPermissionError{Key: role.KeyAuthorsEdit}
|
||||
}
|
||||
before, err := u.authors.Get(id)
|
||||
if err != nil {
|
||||
return author.Author{}, err
|
||||
@@ -50,7 +54,7 @@ func (u *UpdateAuthorUseCase) Update(ctx context.Context, id uint32, caller Auth
|
||||
}
|
||||
|
||||
func (u *UpdateAuthorUseCase) present(a author.Author, caller AuthorCaller) author.Author {
|
||||
if caller.isAdmin() {
|
||||
if caller.seesUserEmail() {
|
||||
return withUserEmail(u.users, a)
|
||||
}
|
||||
a.UserEmail = nil
|
||||
@@ -89,7 +93,7 @@ func (u *UpdateAuthorUseCase) prepare(before author.Author, caller AuthorCaller,
|
||||
}
|
||||
|
||||
func authorizeEdit(before author.Author, caller AuthorCaller, patch author.Patch) error {
|
||||
if caller.isAdmin() {
|
||||
if caller.Grants.HoldsAll(role.KeyAuthorsEdit) {
|
||||
return nil
|
||||
}
|
||||
if before.UserID == nil || *before.UserID != caller.UserID || patch.UserID.Set {
|
||||
|
||||
@@ -2,6 +2,7 @@ package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/domain/role"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -32,6 +33,22 @@ type ActivityQuery struct {
|
||||
Size int
|
||||
}
|
||||
|
||||
func scopeFilterToViewer(filter *audit.Filter, viewerID uint32, grants role.GrantSet) error {
|
||||
switch grants.Scope(role.KeyActivityView) {
|
||||
case role.ScopeAll:
|
||||
return nil
|
||||
case role.ScopeOwn:
|
||||
own := audit.ActorRef{Type: audit.ActorUser, ID: &viewerID}
|
||||
if filter.Actor != nil && !sameActor(*filter.Actor, own) {
|
||||
return audit.ErrActorNotAllowed
|
||||
}
|
||||
filter.Actor = &own
|
||||
return nil
|
||||
default:
|
||||
return role.MissingPermissionError{Key: role.KeyActivityView}
|
||||
}
|
||||
}
|
||||
|
||||
type activityWindow struct {
|
||||
from time.Time
|
||||
to time.Time
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/domain/author"
|
||||
"RenewCMS/internal/domain/image"
|
||||
"RenewCMS/internal/domain/role"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"errors"
|
||||
"strconv"
|
||||
@@ -14,15 +15,11 @@ const authorChangeSeparator = ", "
|
||||
|
||||
type AuthorCaller struct {
|
||||
UserID uint32
|
||||
Role user.Role
|
||||
Grants role.GrantSet
|
||||
}
|
||||
|
||||
func isAdminRole(role user.Role) bool {
|
||||
return role == user.RoleOwner || role == user.RoleAdmin
|
||||
}
|
||||
|
||||
func (c AuthorCaller) isAdmin() bool {
|
||||
return isAdminRole(c.Role)
|
||||
func (c AuthorCaller) seesUserEmail() bool {
|
||||
return c.Grants.Holds(role.KeyUsersView)
|
||||
}
|
||||
|
||||
func authorTarget(a author.Author) audit.Target {
|
||||
|
||||
+5
-1
@@ -54,6 +54,10 @@ coverpkg=""
|
||||
for scope in "${scopes[@]}"; do
|
||||
coverpkg="${coverpkg:+$coverpkg,}$scope/..."
|
||||
done
|
||||
memcap=()
|
||||
if [ "${MUTATE_MEMORY_MAX:-12G}" != "0" ] && command -v systemd-run >/dev/null 2>&1 && systemd-run --user --scope -q true >/dev/null 2>&1; then
|
||||
memcap=(systemd-run --user --scope -q -p "MemoryMax=${MUTATE_MEMORY_MAX:-12G}" -p MemorySwapMax=0)
|
||||
fi
|
||||
parts=()
|
||||
i=0
|
||||
for scope in "${scopes[@]}"; do
|
||||
@@ -73,7 +77,7 @@ for scope in "${scopes[@]}"; do
|
||||
done
|
||||
if [ "$keep" -eq 0 ]; then continue; fi
|
||||
fi
|
||||
gremlins unleash --integration --tags nofrontend --workers "${MUTATE_WORKERS:-4}" --coverpkg="$coverpkg" --timeout-coefficient 10 ${excludes[@]+"${excludes[@]}"} --output "$(native "$part")" "$scope" || rc=$?
|
||||
${memcap[@]+"${memcap[@]}"} gremlins unleash --integration --tags nofrontend --workers "${MUTATE_WORKERS:-4}" --coverpkg="$coverpkg" --timeout-coefficient 10 ${excludes[@]+"${excludes[@]}"} --output "$(native "$part")" "$scope" || rc=$?
|
||||
if [ "$rc" -ge 128 ]; then exit "$rc"; fi
|
||||
if [ "$rc" -ne 0 ]; then code=$rc; fi
|
||||
if [ -f "$part" ]; then parts+=("$(native "$part")"); fi
|
||||
|
||||
@@ -72,6 +72,6 @@ func TestActivityRangeAll(t *testing.T) {
|
||||
|
||||
t.Run("A non-owner export with range=all is still 403", func(t *testing.T) {
|
||||
e := newActivityHTTPEnv(t, 1000)
|
||||
assertPlain(t, e.get("/v1/activity/export?range=all", asUser(e, activityEditorID)), http.StatusForbidden, apperr.OwnerOrAdminRequiredErrorMessage)
|
||||
assertPlain(t, e.get("/v1/activity/export?range=all", asUser(e, activityEditorID)), http.StatusForbidden, apperr.MissingPermissionErrorMessage)
|
||||
})
|
||||
}
|
||||
@@ -41,6 +41,7 @@ const (
|
||||
type activityHTTPEnv struct {
|
||||
*activityHarness
|
||||
users *mockUserRepo
|
||||
roles *mockRoleRepo
|
||||
keys *mockAPIKeyRepo
|
||||
articles *mockArticleRepo
|
||||
mail *mockMailRepo
|
||||
@@ -56,6 +57,7 @@ func newActivityHTTPEnv(t *testing.T, attempts int) *activityHTTPEnv {
|
||||
e := &activityHTTPEnv{
|
||||
activityHarness: h,
|
||||
users: newMockUserRepo(),
|
||||
roles: newSeededMockRoleRepo(),
|
||||
keys: newMockAPIKeyRepo(),
|
||||
articles: newMockArticleRepo(),
|
||||
mail: newMockMailRepo(),
|
||||
@@ -64,7 +66,7 @@ func newActivityHTTPEnv(t *testing.T, attempts int) *activityHTTPEnv {
|
||||
}
|
||||
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"}
|
||||
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.nextID = 4
|
||||
|
||||
@@ -73,14 +75,15 @@ func newActivityHTTPEnv(t *testing.T, attempts int) *activityHTTPEnv {
|
||||
container := &api.UseCases{
|
||||
Token: e.token,
|
||||
GetUserUseCase: useCases.NewGetUserUseCase(e.users),
|
||||
CheckPermissionUseCase: useCases.NewCheckPermissionUseCase(e.roles),
|
||||
GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()),
|
||||
GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()),
|
||||
GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(e.roles),
|
||||
AuthenticateUserUseCase: useCases.NewAuthenticateUserUseCase(e.users, hasher, h.recorder),
|
||||
RequestPasswordResetUseCase: useCases.NewRequestPasswordResetUseCase(e.users, hasher, e.mail, "https://cms.example.com", h.recorder),
|
||||
ResetPasswordUseCase: useCases.NewResetPasswordUseCase(e.users, hasher, e.keys, resetClock, h.recorder),
|
||||
CreateAPIKeyUseCase: useCases.NewCreateAPIKeyUseCase(e.keys, e.generator, h.clock, h.recorder),
|
||||
ListAPIKeysUseCase: useCases.NewListAPIKeysUseCase(e.keys, e.users, h.clock),
|
||||
RevokeAPIKeyUseCase: useCases.NewRevokeAPIKeyUseCase(e.keys, e.users, h.clock, h.recorder),
|
||||
RevokeAPIKeyUseCase: useCases.NewRevokeAPIKeyUseCase(e.keys, h.clock, h.recorder),
|
||||
AuthenticateAPIKeyUseCase: useCases.NewAuthenticateAPIKeyUseCase(e.keys, e.generator, e.users, h.clock),
|
||||
GetSettingsUseCase: useCases.NewGetSettingsUseCase("v-test"),
|
||||
GetActivityRetentionUseCase: useCases.NewGetActivityRetentionUseCase(h.settings, h.probe, h.clock),
|
||||
@@ -277,7 +280,7 @@ func TestActivityLogBackendAudienceGates(t *testing.T) {
|
||||
t.Run("A scoped caller cannot read the summary or the export", func(t *testing.T) {
|
||||
for _, path := range []string{"/v1/activity/summary", "/v1/activity/export"} {
|
||||
e := newActivityHTTPEnv(t, 1000)
|
||||
assertPlain(t, e.get(path, asUser(e, activityEditorID)), http.StatusForbidden, apperr.OwnerOrAdminRequiredErrorMessage)
|
||||
assertPlain(t, e.get(path, asUser(e, activityEditorID)), http.StatusForbidden, apperr.MissingPermissionErrorMessage)
|
||||
if e.probe.reads != 0 {
|
||||
t.Error("the audit repository was read")
|
||||
}
|
||||
@@ -670,7 +673,7 @@ func TestActivityLogBackendRetentionSetting(t *testing.T) {
|
||||
t.Run("The default is 90 days", func(t *testing.T) {
|
||||
e := newActivityHTTPEnv(t, 1000)
|
||||
|
||||
rec := e.get("/v1/settings", asUser(e, activityEditorID))
|
||||
rec := e.get("/v1/settings", asUser(e, activityAdminID))
|
||||
|
||||
assertJSONEqual(t, `{"version":"v-test","activity_retention_days":90,"activity_retention_max_gb":0,"activity_log_size_bytes":0,"activity_last_cleanup_at":null,"activity_last_cleanup_removed":0,"activity_next_cleanup_at":"2026-10-07T03:00:00Z"}`, rec.Body.String())
|
||||
})
|
||||
@@ -680,7 +683,7 @@ func TestActivityLogBackendRetentionSetting(t *testing.T) {
|
||||
e.settings.settings.LastCleanupAt = activityPtr(time.Date(2026, time.October, 6, 3, 0, 0, 0, time.UTC))
|
||||
e.settings.settings.LastCleanupRemoved = 142
|
||||
|
||||
body := settingsBodyOf(e.get("/v1/settings", asUser(e, activityEditorID)))
|
||||
body := settingsBodyOf(e.get("/v1/settings", asUser(e, activityAdminID)))
|
||||
|
||||
if body["activity_last_cleanup_at"] != "2026-10-06T03:00:00Z" || body["activity_last_cleanup_removed"] != float64(142) {
|
||||
t.Errorf("unexpected body %v", body)
|
||||
@@ -732,7 +735,7 @@ func TestActivityLogBackendRetentionSetting(t *testing.T) {
|
||||
|
||||
rec := e.send(http.MethodPatch, "/v1/settings", `{"activity_retention_days": 30}`, asUser(e, activityAdminID))
|
||||
|
||||
assertPlain(t, rec, http.StatusForbidden, apperr.OwnerRequiredErrorMessage)
|
||||
assertPlain(t, rec, http.StatusForbidden, apperr.MissingPermissionErrorMessage)
|
||||
if e.settings.settings.RetentionDays != 90 {
|
||||
t.Error("the retention changed")
|
||||
}
|
||||
|
||||
@@ -106,7 +106,7 @@ func TestSettingsNextCleanupHttp(t *testing.T) {
|
||||
e.settings.settings.LastCleanupAt = activityPtr(time.Date(2026, time.October, 6, 1, 0, 0, 0, time.UTC))
|
||||
e.settings.settings.LastCleanupRemoved = 142
|
||||
|
||||
rec := e.get("/v1/settings", asUser(e, activityEditorID))
|
||||
rec := e.get("/v1/settings", asUser(e, activityAdminID))
|
||||
|
||||
body := keys(t, rec.Body.String())
|
||||
if rec.Code != http.StatusOK || len(body) != 7 || body["activity_next_cleanup_at"] != "2026-10-07T03:00:00Z" || body["activity_last_cleanup_removed"] != float64(142) {
|
||||
@@ -179,13 +179,14 @@ func TestSettingsNextCleanupQAFlow(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GET is open to owner, admin and editor and refused without a cookie", func(t *testing.T) {
|
||||
t.Run("GET is open to owner and admin and refused to an editor and without a cookie", func(t *testing.T) {
|
||||
e := newActivityHTTPEnv(t, 1000)
|
||||
for _, id := range []uint32{activityOwnerID, activityAdminID, activityEditorID} {
|
||||
for _, id := range []uint32{activityOwnerID, activityAdminID} {
|
||||
if rec := e.get("/v1/settings", asUser(e, id)); rec.Code != http.StatusOK {
|
||||
t.Errorf("user %d: expected 200, got %d", id, rec.Code)
|
||||
}
|
||||
}
|
||||
assertPlain(t, e.get("/v1/settings", asUser(e, activityEditorID)), http.StatusForbidden, apperr.MissingPermissionErrorMessage)
|
||||
if rec := e.get("/v1/settings"); rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected 401, got %d", rec.Code)
|
||||
}
|
||||
@@ -196,7 +197,7 @@ func TestSettingsNextCleanupQAFlow(t *testing.T) {
|
||||
|
||||
rec := e.send(http.MethodPatch, "/v1/settings", `{"activity_retention_days": 30}`, asUser(e, activityEditorID))
|
||||
|
||||
assertPlain(t, rec, http.StatusForbidden, apperr.OwnerRequiredErrorMessage)
|
||||
assertPlain(t, rec, http.StatusForbidden, apperr.MissingPermissionErrorMessage)
|
||||
if e.settings.retentionOK != 0 || len(e.all(t)) != 0 {
|
||||
t.Error("the editor changed something")
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ func TestSettingsRetentionSizeHttp(t *testing.T) {
|
||||
e.settings.settings.RetentionMaxGB = c.gb
|
||||
e.probe.sizeBytes = &c.bytes
|
||||
|
||||
rec := e.get("/v1/settings", asUser(e, activityEditorID))
|
||||
rec := e.get("/v1/settings", asUser(e, activityAdminID))
|
||||
|
||||
body := sizeSettingsBody(t, rec.Body.Bytes())
|
||||
var got []string
|
||||
@@ -61,7 +61,7 @@ func TestSettingsRetentionSizeHttp(t *testing.T) {
|
||||
t.Run("Settings API, a fresh install defaults to no size limit", func(t *testing.T) {
|
||||
e := newActivityHTTPEnv(t, 1000)
|
||||
|
||||
body := sizeSettingsBody(t, e.get("/v1/settings", asUser(e, activityEditorID)).Body.Bytes())
|
||||
body := sizeSettingsBody(t, e.get("/v1/settings", asUser(e, activityAdminID)).Body.Bytes())
|
||||
|
||||
if body["activity_retention_days"] != float64(90) || body["activity_retention_max_gb"] != float64(0) || body["activity_log_size_bytes"] != float64(0) {
|
||||
t.Errorf("unexpected body %v", body)
|
||||
@@ -153,7 +153,7 @@ func TestSettingsRetentionSizeHttp(t *testing.T) {
|
||||
|
||||
rec := e.send(http.MethodPatch, "/v1/settings", `{"activity_retention_max_gb": 5}`, asUser(e, activityAdminID))
|
||||
|
||||
assertPlain(t, rec, http.StatusForbidden, "Only the owner can do this.")
|
||||
assertPlain(t, rec, http.StatusForbidden, apperr.MissingPermissionErrorMessage)
|
||||
if e.settings.retentionOK != 0 {
|
||||
t.Error("the admin changed something")
|
||||
}
|
||||
@@ -164,7 +164,7 @@ func TestSettingsRetentionSizeHttp(t *testing.T) {
|
||||
e := newActivityHTTPEnv(t, 1000)
|
||||
e.probe.sizeErr = errRepositoryFailure
|
||||
|
||||
assertPlain(t, e.get("/v1/settings", asUser(e, activityEditorID)), http.StatusInternalServerError, "Internal server error")
|
||||
assertPlain(t, e.get("/v1/settings", asUser(e, activityAdminID)), http.StatusInternalServerError, "Internal server error")
|
||||
})
|
||||
|
||||
t.Run("Settings API, the change is audited", func(t *testing.T) {
|
||||
|
||||
@@ -26,6 +26,7 @@ type activityProbeRepo struct {
|
||||
deleteErr error
|
||||
deleteCalls int
|
||||
readErr error
|
||||
maxReads int
|
||||
sizeErr error
|
||||
sizeBytes *int64
|
||||
oldestErr error
|
||||
@@ -47,6 +48,9 @@ func (p *activityProbeRepo) Append(event audit.Event) (audit.Event, error) {
|
||||
func (p *activityProbeRepo) List(filter audit.Filter) (audit.Page, error) {
|
||||
p.reads++
|
||||
p.listCalls = append(p.listCalls, filter)
|
||||
if p.maxReads > 0 && p.reads > p.maxReads {
|
||||
return audit.Page{}, errors.New("read budget exceeded")
|
||||
}
|
||||
if p.readErr != nil {
|
||||
return audit.Page{}, p.readErr
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"RenewCMS/internal/domain/apikey"
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/domain/role"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"bytes"
|
||||
@@ -36,6 +37,7 @@ func newActivityWorld(t *testing.T) *activityWorld {
|
||||
images: newMockImageRepo(),
|
||||
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"}
|
||||
@@ -62,7 +64,7 @@ func TestActivityListUseCase(t *testing.T) {
|
||||
recent := w.seedAt(t, 2*day, nil)
|
||||
w.seedAt(t, 10*day, nil)
|
||||
|
||||
page, err := w.list.ListActivity(useCases.ListActivityCommand{ViewerID: 1})
|
||||
page, err := w.list.ListActivity(useCases.ListActivityCommand{ViewerID: 1, Grants: activityViewAllGrants})
|
||||
|
||||
if err != nil || len(page.Items) != 1 || page.Items[0].Event.ID != recent.ID {
|
||||
t.Fatalf("expected only the recent event, got %+v, %v", page, err)
|
||||
@@ -94,7 +96,7 @@ func TestActivityListUseCase(t *testing.T) {
|
||||
w := newActivityWorld(t)
|
||||
w.seedAt(t, c.ago, nil)
|
||||
|
||||
page, err := w.list.ListActivity(useCases.ListActivityCommand{ViewerID: 1, Query: c.query})
|
||||
page, err := w.list.ListActivity(useCases.ListActivityCommand{ViewerID: 1, Grants: activityViewAllGrants, Query: c.query})
|
||||
|
||||
if err != nil || (page.Total == 1) != c.found {
|
||||
t.Errorf("expected found=%v, got total %d, %v", c.found, page.Total, err)
|
||||
@@ -115,7 +117,7 @@ func TestActivityListUseCase(t *testing.T) {
|
||||
for i, query := range queries {
|
||||
w := newActivityWorld(t)
|
||||
|
||||
_, err := w.list.ListActivity(useCases.ListActivityCommand{ViewerID: 1, Query: query})
|
||||
_, err := w.list.ListActivity(useCases.ListActivityCommand{ViewerID: 1, Grants: activityViewAllGrants, Query: query})
|
||||
|
||||
if !errors.Is(err, audit.ErrInvalidFilter) || w.probe.reads != 0 {
|
||||
t.Errorf("query %d (%+v): expected ErrInvalidFilter without reading, got %v after %d reads", i, query, err, w.probe.reads)
|
||||
@@ -128,14 +130,14 @@ func TestActivityListUseCase(t *testing.T) {
|
||||
w.seedAt(t, day, nil)
|
||||
w.seedAt(t, day, func(e *audit.Event) { e.Outcome = audit.OutcomeFailed })
|
||||
|
||||
page, _ := w.list.ListActivity(useCases.ListActivityCommand{ViewerID: 1, Query: useCases.ActivityQuery{Outcome: "all"}})
|
||||
page, _ := w.list.ListActivity(useCases.ListActivityCommand{ViewerID: 1, Grants: activityViewAllGrants, Query: useCases.ActivityQuery{Outcome: "all"}})
|
||||
|
||||
if page.Total != 2 {
|
||||
t.Errorf("expected both outcomes, got %d", page.Total)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A caller who is not owner or admin is scoped to its own events", func(t *testing.T) {
|
||||
t.Run("A caller with activity.view at own is scoped to its own events", func(t *testing.T) {
|
||||
w := newActivityWorld(t)
|
||||
w.seedAt(t, day, nil)
|
||||
w.seedAt(t, day, func(e *audit.Event) {
|
||||
@@ -143,7 +145,7 @@ func TestActivityListUseCase(t *testing.T) {
|
||||
})
|
||||
w.seedAt(t, day, func(e *audit.Event) { e.Actor = audit.SystemActor() })
|
||||
|
||||
page, err := w.list.ListActivity(useCases.ListActivityCommand{ViewerID: 3})
|
||||
page, err := w.list.ListActivity(useCases.ListActivityCommand{ViewerID: 3, Grants: activityViewOwnGrants})
|
||||
|
||||
if err != nil || page.Total != 1 || *page.Items[0].Event.Actor.ID != 3 {
|
||||
t.Fatalf("expected only user 3's events, got %+v, %v", page, err)
|
||||
@@ -155,7 +157,7 @@ func TestActivityListUseCase(t *testing.T) {
|
||||
for actor, allowed := range cases {
|
||||
w := newActivityWorld(t)
|
||||
|
||||
_, err := w.list.ListActivity(useCases.ListActivityCommand{ViewerID: 3, Query: useCases.ActivityQuery{Actor: actor}})
|
||||
_, err := w.list.ListActivity(useCases.ListActivityCommand{ViewerID: 3, Grants: activityViewOwnGrants, Query: useCases.ActivityQuery{Actor: actor}})
|
||||
|
||||
if allowed && err != nil {
|
||||
t.Errorf("%s: expected success, got %v", actor, err)
|
||||
@@ -166,13 +168,13 @@ func TestActivityListUseCase(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("An unknown viewer is reported", func(t *testing.T) {
|
||||
t.Run("A caller without activity.view is refused before the repository is read", func(t *testing.T) {
|
||||
w := newActivityWorld(t)
|
||||
|
||||
_, err := w.list.ListActivity(useCases.ListActivityCommand{ViewerID: 99})
|
||||
_, err := w.list.ListActivity(useCases.ListActivityCommand{ViewerID: 3})
|
||||
|
||||
if !errors.Is(err, user.ErrUserNotFound) {
|
||||
t.Errorf("expected ErrUserNotFound, got %v", err)
|
||||
if !errors.Is(err, role.ErrMissingPermission) || w.probe.reads != 0 {
|
||||
t.Errorf("expected a missing permission without reading, got %v after %d reads", err, w.probe.reads)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -195,7 +197,7 @@ func TestActivityListUseCase(t *testing.T) {
|
||||
c.seed(w)
|
||||
w.seedAt(t, day, func(e *audit.Event) { e.Target = c.target })
|
||||
|
||||
page, err := w.list.ListActivity(useCases.ListActivityCommand{ViewerID: 1})
|
||||
page, err := w.list.ListActivity(useCases.ListActivityCommand{ViewerID: 1, Grants: activityViewAllGrants})
|
||||
|
||||
if err != nil || len(page.Items) != 1 {
|
||||
t.Fatalf("unexpected result %+v, %v", page, err)
|
||||
@@ -209,9 +211,10 @@ func TestActivityListUseCase(t *testing.T) {
|
||||
|
||||
t.Run("A repository failure is returned", func(t *testing.T) {
|
||||
w := newActivityWorld(t)
|
||||
w.seedAt(t, 24*time.Hour, func(e *audit.Event) { e.Target = audit.Target{Type: "user", ID: "3", Name: "Marta Alves"} })
|
||||
w.users.fail.failNext("Get")
|
||||
|
||||
_, err := w.list.ListActivity(useCases.ListActivityCommand{ViewerID: 1})
|
||||
_, err := w.list.ListActivity(useCases.ListActivityCommand{ViewerID: 1, Grants: activityViewAllGrants})
|
||||
|
||||
if !errors.Is(err, errRepositoryFailure) {
|
||||
t.Errorf("expected the repository failure, got %v", err)
|
||||
@@ -307,7 +310,7 @@ func TestActivitySummaryUseCase(t *testing.T) {
|
||||
|
||||
func exportToString(t *testing.T, w *activityWorld, query useCases.ActivityQuery) string {
|
||||
t.Helper()
|
||||
prepared, err := w.export.Prepare(query)
|
||||
prepared, err := w.export.Prepare(useCases.ExportActivityCommand{Grants: activityViewAllGrants, Query: query})
|
||||
if err != nil {
|
||||
t.Fatalf("prepare failed: %v", err)
|
||||
}
|
||||
@@ -445,7 +448,7 @@ func TestActivityExportUseCase(t *testing.T) {
|
||||
t.Run("An invalid filter is refused before anything is prepared", func(t *testing.T) {
|
||||
w := newActivityWorld(t)
|
||||
|
||||
_, err := w.export.Prepare(useCases.ActivityQuery{Outcome: "maybe"})
|
||||
_, err := w.export.Prepare(useCases.ExportActivityCommand{Grants: activityViewAllGrants, Query: useCases.ActivityQuery{Outcome: "maybe"}})
|
||||
|
||||
if !errors.Is(err, audit.ErrInvalidFilter) || w.probe.reads != 0 {
|
||||
t.Errorf("expected ErrInvalidFilter without reading, got %v", err)
|
||||
|
||||
@@ -3,6 +3,7 @@ package test
|
||||
import (
|
||||
"RenewCMS/api"
|
||||
"RenewCMS/internal/domain/apikey"
|
||||
"RenewCMS/internal/domain/role"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"net/http"
|
||||
@@ -86,13 +87,15 @@ func TestAPIKeysCreatorNameHttp(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("API: an admin's list is unchanged", func(t *testing.T) {
|
||||
t.Run("API: an own-scope caller's list is unchanged", func(t *testing.T) {
|
||||
env := newAPIKeyEnv(t)
|
||||
env.nameUsers()
|
||||
env.issueKey(apiKeyDevId, "a")
|
||||
env.issueKey(apiKeyDevId, "b")
|
||||
env.customRoleUser(6, role.Grant{Key: "apikeys.view", Scope: role.ScopeOwn})
|
||||
env.issueKey(6, "a")
|
||||
env.issueKey(6, "b")
|
||||
env.issueKey(apiKeyDevId, "other")
|
||||
|
||||
rec := env.withCookie(t, http.MethodGet, "/v1/api-key", "", apiKeyDevId)
|
||||
rec := env.withCookie(t, http.MethodGet, "/v1/api-key", "", 6)
|
||||
|
||||
assertAPIKeyStatus(t, rec, http.StatusOK)
|
||||
list := apiKeyDecodeList(t, rec)
|
||||
@@ -174,10 +177,10 @@ func TestListAPIKeysCreatorName(t *testing.T) {
|
||||
return repo
|
||||
}
|
||||
|
||||
t.Run("Use case: ListAPIKeys returns the creator name only to an owner", func(t *testing.T) {
|
||||
t.Run("Use case: ListAPIKeys returns the creator name only with all scope", func(t *testing.T) {
|
||||
uc := useCases.NewListAPIKeysUseCase(seed(), users(), apiKeyUnitClock)
|
||||
|
||||
ownerEntries, err := uc.ListAPIKeys(1)
|
||||
ownerEntries, err := uc.ListAPIKeys(1, apiKeyAllScope)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -189,12 +192,12 @@ func TestListAPIKeysCreatorName(t *testing.T) {
|
||||
t.Errorf("unexpected creator names %v", names)
|
||||
}
|
||||
|
||||
adminEntries, err := uc.ListAPIKeys(2)
|
||||
ownScopeEntries, err := uc.ListAPIKeys(2, apiKeyOwnScope)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(adminEntries) != 1 || adminEntries[0].CreatorName != "" {
|
||||
t.Errorf("expected one entry with an empty creator name, got %+v", adminEntries)
|
||||
if len(ownScopeEntries) != 1 || ownScopeEntries[0].CreatorName != "" {
|
||||
t.Errorf("expected one entry with an empty creator name, got %+v", ownScopeEntries)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -218,7 +221,7 @@ func TestListAPIKeysTieKeepsRepositoryOrder(t *testing.T) {
|
||||
}}
|
||||
uc := useCases.NewListAPIKeysUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock)
|
||||
|
||||
entries, err := uc.ListAPIKeys(1)
|
||||
entries, err := uc.ListAPIKeys(1, apiKeyAllScope)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
+38
-16
@@ -8,6 +8,7 @@ import (
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/domain/image"
|
||||
"RenewCMS/internal/domain/role"
|
||||
"RenewCMS/internal/domain/update"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"RenewCMS/internal/infrastructure/config"
|
||||
@@ -116,6 +117,7 @@ func newAPIKeyEnvRecording(t *testing.T, recorder audit.Recorder) *apiKeyEnv {
|
||||
GetUserUseCase: useCases.NewGetUserUseCase(env.users),
|
||||
GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()),
|
||||
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),
|
||||
AuthenticateUserUseCase: useCases.NewAuthenticateUserUseCase(env.users, hasher, recorder),
|
||||
@@ -145,15 +147,19 @@ func newAPIKeyEnvRecording(t *testing.T, recorder audit.Recorder) *apiKeyEnv {
|
||||
CheckForUpdateUseCase: useCases.NewCheckForUpdateUseCase(env.feed, env.updates, apiKeyRunningVersion, clock, recorder),
|
||||
CreateAPIKeyUseCase: useCases.NewCreateAPIKeyUseCase(env.keys, env.generator, clock, recorder),
|
||||
ListAPIKeysUseCase: useCases.NewListAPIKeysUseCase(env.keys, env.users, clock),
|
||||
RevokeAPIKeyUseCase: useCases.NewRevokeAPIKeyUseCase(env.keys, env.users, clock, recorder),
|
||||
RevokeAPIKeyUseCase: useCases.NewRevokeAPIKeyUseCase(env.keys, clock, recorder),
|
||||
AuthenticateAPIKeyUseCase: useCases.NewAuthenticateAPIKeyUseCase(env.keys, env.generator, env.users, clock),
|
||||
}
|
||||
router := chi.NewRouter()
|
||||
router.Mount("/v1", api.InitBackendRoutes(env.container, env.settings))
|
||||
env.router = router
|
||||
env.rebuildRouter()
|
||||
return env
|
||||
}
|
||||
|
||||
func (e *apiKeyEnv) rebuildRouter() {
|
||||
router := chi.NewRouter()
|
||||
router.Mount("/v1", api.InitBackendRoutes(e.container, e.settings))
|
||||
e.router = router
|
||||
}
|
||||
|
||||
func (e *apiKeyEnv) issueKey(userId uint32, name string, mutate ...func(*apikey.APIKey)) (string, uint32) {
|
||||
e.issued++
|
||||
raw, prefix, hashed := e.generator.Generate()
|
||||
@@ -361,7 +367,7 @@ func TestAPIKeysHttp(t *testing.T) {
|
||||
if len(list) != 1 {
|
||||
t.Fatalf("expected one entry, got %d", len(list))
|
||||
}
|
||||
apiKeyAssertExactFields(t, list[0], "id", "user_id", "name", "prefix", "status", "created_at", "last_used_at", "expires_at", "revoked_at")
|
||||
apiKeyAssertExactFields(t, list[0], "id", "user_id", "name", "prefix", "status", "created_at", "last_used_at", "expires_at", "revoked_at", "created_by_name")
|
||||
if strings.Contains(rec.Body.String(), raw) || strings.Contains(rec.Body.String(), env.keys.sorted()[0].HashedKey) {
|
||||
t.Errorf("neither the raw key nor its hash may appear in the body")
|
||||
}
|
||||
@@ -431,7 +437,10 @@ func TestAPIKeysHttp(t *testing.T) {
|
||||
}
|
||||
raw := apiKeyDecodeObject(t, rec)["key"].(string)
|
||||
invite := env.withKey(t, http.MethodPost, "/v1/user/invite", `{"email": "new@example.com"}`, raw)
|
||||
assertAPIKeyMessage(t, invite, http.StatusForbidden, apperr.OwnerRequiredErrorMessage)
|
||||
assertAPIKeyStatus(t, invite, http.StatusCreated)
|
||||
if len(env.invites.invites) != 1 || env.invites.invites[1].InvitedBy != apiKeyDevId {
|
||||
t.Errorf("expected an invite invited by the key's creator, got %+v", env.invites.invites)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Key management needs a verified real session", func(t *testing.T) {
|
||||
@@ -564,13 +573,14 @@ func TestAPIKeysHttp(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("An admin's key can not do owner-only things", func(t *testing.T) {
|
||||
t.Run("A writer's key can not invite", func(t *testing.T) {
|
||||
env := newAPIKeyEnv(t)
|
||||
raw, _ := env.issueKey(apiKeyDevId, "ci")
|
||||
env.addUser(5, "", writerRoleID, true)
|
||||
raw, _ := env.issueKey(5, "ci")
|
||||
|
||||
rec := env.withKey(t, http.MethodPost, "/v1/user/invite", `{"email": "new@example.com"}`, raw)
|
||||
|
||||
assertAPIKeyMessage(t, rec, http.StatusForbidden, apperr.OwnerRequiredErrorMessage)
|
||||
assertAPIKeyMessage(t, rec, http.StatusForbidden, apperr.MissingPermissionErrorMessage)
|
||||
if len(env.invites.invites) != 0 {
|
||||
t.Errorf("expected no invite")
|
||||
}
|
||||
@@ -647,7 +657,8 @@ 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)
|
||||
raw, _ := env.issueKey(apiKeyPatId, "ci")
|
||||
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})
|
||||
assertAPIKeyStatus(t, invite, http.StatusForbidden)
|
||||
@@ -854,7 +865,7 @@ func TestAPIKeysHttp(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Listing as an admin shows only that admin's keys", func(t *testing.T) {
|
||||
t.Run("Listing as an admin shows every user's keys", func(t *testing.T) {
|
||||
env := newAPIKeyEnv(t)
|
||||
env.issueKey(apiKeyDevId, "a")
|
||||
env.issueKey(apiKeyDevId, "b")
|
||||
@@ -863,8 +874,8 @@ func TestAPIKeysHttp(t *testing.T) {
|
||||
rec := env.withCookie(t, http.MethodGet, "/v1/api-key", "", apiKeyDevId)
|
||||
|
||||
assertAPIKeyStatus(t, rec, http.StatusOK)
|
||||
if got := strings.Join(apiKeyEntryNames(t, rec), ","); got != "b,a" {
|
||||
t.Errorf("expected exactly b and a, got %s", got)
|
||||
if got := strings.Join(apiKeyEntryNames(t, rec), ","); got != "c,b,a" {
|
||||
t.Errorf("expected c, b and a, got %s", got)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -957,16 +968,26 @@ func TestAPIKeysHttp(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("An admin can't revoke someone else's key, and learns nothing about it", func(t *testing.T) {
|
||||
t.Run("An own-scope caller can't revoke someone else's key, and learns nothing about it", func(t *testing.T) {
|
||||
env := newAPIKeyEnv(t)
|
||||
env.customRoleUser(6, role.Grant{Key: "apikeys.revoke", Scope: role.ScopeOwn})
|
||||
raw, id := env.issueKey(apiKeyPatId, "ci")
|
||||
|
||||
rec := env.withCookie(t, http.MethodDelete, "/v1/api-key/"+strconv.Itoa(int(id)), "", apiKeyDevId)
|
||||
rec := env.withCookie(t, http.MethodDelete, "/v1/api-key/"+strconv.Itoa(int(id)), "", 6)
|
||||
|
||||
assertAPIKeyMessage(t, rec, http.StatusNotFound, apperr.APIKeyNotFoundErrorMessage)
|
||||
assertAPIKeyStatus(t, env.withKey(t, http.MethodGet, "/v1/settings", "", raw), http.StatusOK)
|
||||
})
|
||||
|
||||
t.Run("An admin revokes someone else's key", func(t *testing.T) {
|
||||
env := newAPIKeyEnv(t)
|
||||
raw, id := env.issueKey(apiKeyPatId, "ci")
|
||||
|
||||
assertAPIKeyStatus(t, env.withCookie(t, http.MethodDelete, "/v1/api-key/"+strconv.Itoa(int(id)), "", apiKeyDevId), http.StatusNoContent)
|
||||
|
||||
assertAPIKeyStatus(t, env.withKey(t, http.MethodGet, "/v1/settings", "", raw), http.StatusUnauthorized)
|
||||
})
|
||||
|
||||
t.Run("The owner can revoke any key", func(t *testing.T) {
|
||||
env := newAPIKeyEnv(t)
|
||||
raw, id := env.issueKey(apiKeyPatId, "ci")
|
||||
@@ -1056,8 +1077,9 @@ func TestAPIKeysHttp(t *testing.T) {
|
||||
t.Errorf("expected no stored key")
|
||||
}
|
||||
|
||||
env.customRoleUser(6, role.Grant{Key: "apikeys.view", Scope: role.ScopeOwn})
|
||||
env.keys.fail.failNext("ListByUser")
|
||||
assertAPIKeyMessage(t, env.withCookie(t, http.MethodGet, "/v1/api-key", "", apiKeyDevId), http.StatusInternalServerError, "Internal server error")
|
||||
assertAPIKeyMessage(t, env.withCookie(t, http.MethodGet, "/v1/api-key", "", 6), http.StatusInternalServerError, "Internal server error")
|
||||
env.keys.fail.failNext("ListAll")
|
||||
assertAPIKeyMessage(t, env.withCookie(t, http.MethodGet, "/v1/api-key", "", apiKeyOwnerId), http.StatusInternalServerError, "Internal server error")
|
||||
|
||||
|
||||
+29
-26
@@ -2,6 +2,7 @@ package test
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/apikey"
|
||||
"RenewCMS/internal/domain/role"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"RenewCMS/internal/infrastructure/hashing"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
@@ -173,6 +174,11 @@ func seedStubKey(repo *mockAPIKeyRepo, raw string, mutate func(*apikey.APIKey))
|
||||
return stored
|
||||
}
|
||||
|
||||
var (
|
||||
apiKeyOwnScope = role.NewGrantSet([]role.Grant{{Key: role.KeyAPIKeysView, Scope: role.ScopeOwn}, {Key: role.KeyAPIKeysRevoke, Scope: role.ScopeOwn}})
|
||||
apiKeyAllScope = role.NewGrantSet([]role.Grant{{Key: role.KeyAPIKeysView, Scope: role.ScopeAll}, {Key: role.KeyAPIKeysRevoke, Scope: role.ScopeAll}})
|
||||
)
|
||||
|
||||
func apiKeyUnitUsers() *mockUserRepo {
|
||||
users := newMockUserRepo()
|
||||
users.users[1] = user.User{ID: 1, Username: "marta", IsVerified: true, Role: user.RoleOwner}
|
||||
@@ -402,10 +408,10 @@ func TestListAPIKeysUseCase(t *testing.T) {
|
||||
return result
|
||||
}
|
||||
|
||||
t.Run("An admin lists only their own keys, newest first", func(t *testing.T) {
|
||||
t.Run("A caller with own scope lists only their own keys, newest first", func(t *testing.T) {
|
||||
repo := newMockAPIKeyRepo()
|
||||
seed(repo)
|
||||
entries, err := useCases.NewListAPIKeysUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock).ListAPIKeys(2)
|
||||
entries, err := useCases.NewListAPIKeysUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock).ListAPIKeys(2, apiKeyOwnScope)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -414,10 +420,10 @@ func TestListAPIKeysUseCase(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The owner lists every key, newest first", func(t *testing.T) {
|
||||
t.Run("A caller with all scope lists every key, newest first", func(t *testing.T) {
|
||||
repo := newMockAPIKeyRepo()
|
||||
seed(repo)
|
||||
entries, err := useCases.NewListAPIKeysUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock).ListAPIKeys(1)
|
||||
entries, err := useCases.NewListAPIKeysUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock).ListAPIKeys(1, apiKeyAllScope)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -436,7 +442,7 @@ func TestListAPIKeysUseCase(t *testing.T) {
|
||||
for _, raw := range []string{"rcms_first", "rcms_second", "rcms_third"} {
|
||||
seedStubKey(repo, raw, func(k *apikey.APIKey) { k.CreatedAt = same })
|
||||
}
|
||||
entries, err := useCases.NewListAPIKeysUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock).ListAPIKeys(2)
|
||||
entries, err := useCases.NewListAPIKeysUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock).ListAPIKeys(2, apiKeyOwnScope)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -455,7 +461,7 @@ func TestListAPIKeysUseCase(t *testing.T) {
|
||||
seedStubKey(repo, "rcms_expired", func(k *apikey.APIKey) { k.ExpiresAt = &expired })
|
||||
seedStubKey(repo, "rcms_both", func(k *apikey.APIKey) { k.ExpiresAt = &expired; k.RevokedAt = &revoked })
|
||||
|
||||
entries, _ := useCases.NewListAPIKeysUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock).ListAPIKeys(2)
|
||||
entries, _ := useCases.NewListAPIKeysUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock).ListAPIKeys(2, apiKeyOwnScope)
|
||||
statuses := map[string]apikey.Status{}
|
||||
for _, entry := range entries {
|
||||
statuses[entry.Key.Prefix] = entry.Status
|
||||
@@ -468,15 +474,15 @@ func TestListAPIKeysUseCase(t *testing.T) {
|
||||
t.Run("An unknown requester and repository failures are returned", func(t *testing.T) {
|
||||
repo := newMockAPIKeyRepo()
|
||||
uc := useCases.NewListAPIKeysUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock)
|
||||
if _, err := uc.ListAPIKeys(99); !errors.Is(err, user.ErrUserNotFound) {
|
||||
if _, err := uc.ListAPIKeys(99, apiKeyOwnScope); !errors.Is(err, user.ErrUserNotFound) {
|
||||
t.Errorf("expected ErrUserNotFound, got %v", err)
|
||||
}
|
||||
repo.fail.failNext("ListByUser")
|
||||
if _, err := uc.ListAPIKeys(2); !errors.Is(err, errRepositoryFailure) {
|
||||
if _, err := uc.ListAPIKeys(2, apiKeyOwnScope); !errors.Is(err, errRepositoryFailure) {
|
||||
t.Errorf("expected the repository failure, got %v", err)
|
||||
}
|
||||
repo.fail.failNext("ListAll")
|
||||
if _, err := uc.ListAPIKeys(1); !errors.Is(err, errRepositoryFailure) {
|
||||
if _, err := uc.ListAPIKeys(1, apiKeyAllScope); !errors.Is(err, errRepositoryFailure) {
|
||||
t.Errorf("expected the repository failure, got %v", err)
|
||||
}
|
||||
})
|
||||
@@ -486,9 +492,9 @@ func TestRevokeAPIKeyUseCase(t *testing.T) {
|
||||
t.Run("A creator revokes their own key at the clock's now", func(t *testing.T) {
|
||||
repo := newMockAPIKeyRepo()
|
||||
stored := seedStubKey(repo, "rcms_a", nil)
|
||||
uc := useCases.NewRevokeAPIKeyUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock, discardRecorder)
|
||||
uc := useCases.NewRevokeAPIKeyUseCase(repo, apiKeyUnitClock, discardRecorder)
|
||||
|
||||
if err := uc.RevokeAPIKey(testCtx, 2, stored.ID); err != nil {
|
||||
if err := uc.RevokeAPIKey(testCtx, 2, stored.ID, apiKeyOwnScope); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got := repo.keys[stored.ID].RevokedAt; got == nil || !got.Equal(apiKeyUnitNow) {
|
||||
@@ -496,12 +502,12 @@ func TestRevokeAPIKeyUseCase(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Another admin gets not found and the key stays active", func(t *testing.T) {
|
||||
t.Run("Another own-scope caller gets not found and the key stays active", func(t *testing.T) {
|
||||
repo := newMockAPIKeyRepo()
|
||||
stored := seedStubKey(repo, "rcms_a", nil)
|
||||
uc := useCases.NewRevokeAPIKeyUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock, discardRecorder)
|
||||
uc := useCases.NewRevokeAPIKeyUseCase(repo, apiKeyUnitClock, discardRecorder)
|
||||
|
||||
if err := uc.RevokeAPIKey(testCtx, 3, stored.ID); !errors.Is(err, apikey.ErrAPIKeyNotFound) {
|
||||
if err := uc.RevokeAPIKey(testCtx, 3, stored.ID, apiKeyOwnScope); !errors.Is(err, apikey.ErrAPIKeyNotFound) {
|
||||
t.Errorf("expected ErrAPIKeyNotFound, got %v", err)
|
||||
}
|
||||
if repo.keys[stored.ID].RevokedAt != nil || repo.revokeCalls != 0 {
|
||||
@@ -509,12 +515,12 @@ func TestRevokeAPIKeyUseCase(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The owner revokes any key", func(t *testing.T) {
|
||||
t.Run("A caller with all scope revokes any key", func(t *testing.T) {
|
||||
repo := newMockAPIKeyRepo()
|
||||
stored := seedStubKey(repo, "rcms_a", func(k *apikey.APIKey) { k.UserID = 3 })
|
||||
uc := useCases.NewRevokeAPIKeyUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock, discardRecorder)
|
||||
uc := useCases.NewRevokeAPIKeyUseCase(repo, apiKeyUnitClock, discardRecorder)
|
||||
|
||||
if err := uc.RevokeAPIKey(testCtx, 1, stored.ID); err != nil {
|
||||
if err := uc.RevokeAPIKey(testCtx, 1, stored.ID, apiKeyAllScope); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if repo.keys[stored.ID].RevokedAt == nil {
|
||||
@@ -526,9 +532,9 @@ func TestRevokeAPIKeyUseCase(t *testing.T) {
|
||||
repo := newMockAPIKeyRepo()
|
||||
original := apiKeyUnitNow.Add(-time.Hour)
|
||||
stored := seedStubKey(repo, "rcms_a", func(k *apikey.APIKey) { k.RevokedAt = &original })
|
||||
uc := useCases.NewRevokeAPIKeyUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock, discardRecorder)
|
||||
uc := useCases.NewRevokeAPIKeyUseCase(repo, apiKeyUnitClock, discardRecorder)
|
||||
|
||||
if err := uc.RevokeAPIKey(testCtx, 2, stored.ID); err != nil {
|
||||
if err := uc.RevokeAPIKey(testCtx, 2, stored.ID, apiKeyOwnScope); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got := repo.keys[stored.ID].RevokedAt; got == nil || !got.Equal(original) {
|
||||
@@ -536,19 +542,16 @@ func TestRevokeAPIKeyUseCase(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("An unknown key, an unknown requester and repository failures are returned", func(t *testing.T) {
|
||||
t.Run("An unknown key and repository failures are returned", func(t *testing.T) {
|
||||
repo := newMockAPIKeyRepo()
|
||||
stored := seedStubKey(repo, "rcms_a", nil)
|
||||
uc := useCases.NewRevokeAPIKeyUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock, discardRecorder)
|
||||
uc := useCases.NewRevokeAPIKeyUseCase(repo, apiKeyUnitClock, discardRecorder)
|
||||
|
||||
if err := uc.RevokeAPIKey(testCtx, 2, 999); !errors.Is(err, apikey.ErrAPIKeyNotFound) {
|
||||
if err := uc.RevokeAPIKey(testCtx, 2, 999, apiKeyOwnScope); !errors.Is(err, apikey.ErrAPIKeyNotFound) {
|
||||
t.Errorf("expected ErrAPIKeyNotFound, got %v", err)
|
||||
}
|
||||
if err := uc.RevokeAPIKey(testCtx, 99, stored.ID); !errors.Is(err, user.ErrUserNotFound) {
|
||||
t.Errorf("expected ErrUserNotFound, got %v", err)
|
||||
}
|
||||
repo.fail.failNext("Revoke")
|
||||
if err := uc.RevokeAPIKey(testCtx, 2, stored.ID); !errors.Is(err, errRepositoryFailure) {
|
||||
if err := uc.RevokeAPIKey(testCtx, 2, stored.ID, apiKeyOwnScope); !errors.Is(err, errRepositoryFailure) {
|
||||
t.Errorf("expected the repository failure, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -127,7 +127,7 @@ func TestAuthorUseCases(t *testing.T) {
|
||||
uc := useCases.NewUpdateAuthorUseCase(authors, users, newMockImageRepo(), discardRecorder)
|
||||
bio := "x"
|
||||
|
||||
_, err := uc.Update(context.Background(), 2, useCases.AuthorCaller{UserID: 9, Role: "user"}, author.Patch{Bio: &bio})
|
||||
_, err := uc.Update(context.Background(), 2, useCases.AuthorCaller{UserID: 9, Grants: authorsEditOwnGrants}, author.Patch{Bio: &bio})
|
||||
|
||||
if !errors.Is(err, author.ErrAuthorRights) {
|
||||
t.Errorf("err = %v", err)
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
ownerOrAdminRefusal = "Only owners and admins can do this."
|
||||
ownerOrAdminRefusal = apperr.MissingPermissionErrorMessage
|
||||
sessionRefusal = "This action needs a real session."
|
||||
)
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ func newContainerHarness(t *testing.T, container bool, helperInstalled bool) *co
|
||||
routes := &api.UseCases{
|
||||
Token: token,
|
||||
GetUserUseCase: useCases.NewGetUserUseCase(newUpdateUserRepo()),
|
||||
CheckPermissionUseCase: useCases.NewCheckPermissionUseCase(newSeededMockRoleRepo()),
|
||||
GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()),
|
||||
GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()),
|
||||
GetUpdateStatusUseCase: useCases.NewGetUpdateStatusUseCase(rig.store, rig.jobs, helper, rig.results, rig.requests, updateRunning, container, now),
|
||||
|
||||
@@ -420,6 +420,7 @@ func TestBuiltinTypeHTTP(t *testing.T) {
|
||||
container := &api.UseCases{
|
||||
Token: token,
|
||||
GetUserUseCase: useCases.NewGetUserUseCase(users),
|
||||
CheckPermissionUseCase: useCases.NewCheckPermissionUseCase(newSeededMockRoleRepo()),
|
||||
AuthenticateAPIKeyUseCase: useCases.NewAuthenticateAPIKeyUseCase(newMockAPIKeyRepo(), generator, users, now),
|
||||
ListBuiltinTypesUseCase: useCases.NewListBuiltinTypesUseCase(),
|
||||
GetBuiltinTypeUseCase: useCases.NewGetBuiltinTypeUseCase(),
|
||||
|
||||
@@ -266,6 +266,7 @@ func newContentTypeEnv(t *testing.T) *contentTypeEnv {
|
||||
container := &api.UseCases{
|
||||
Token: env.token,
|
||||
GetUserUseCase: useCases.NewGetUserUseCase(env.users),
|
||||
CheckPermissionUseCase: useCases.NewCheckPermissionUseCase(newSeededMockRoleRepo()),
|
||||
AuthenticateAPIKeyUseCase: useCases.NewAuthenticateAPIKeyUseCase(env.keys, env.generator, env.users, now),
|
||||
CreateContentTypeUseCase: useCases.NewCreateContentTypeUseCase(env.types, now),
|
||||
ListContentTypesUseCase: useCases.NewListContentTypesUseCase(env.types),
|
||||
|
||||
@@ -2,6 +2,7 @@ package test
|
||||
|
||||
import (
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strconv"
|
||||
@@ -128,7 +129,7 @@ func TestContentTypeKeysAndAccess(t *testing.T) {
|
||||
{"no credentials", 0, 401, ""},
|
||||
{"a jwt cookie for user 4", ctUnverifiedId, 403, apperr.VerificationRequiredErrorMessage},
|
||||
{"a stale token_version cookie", ctStaleId, 401, apperr.UnauthorizedErrorMessage},
|
||||
{"a verified member without a role", ctMemberId, 403, apperr.OwnerOrAdminRequiredErrorMessage},
|
||||
{"a verified member without a role", ctMemberId, 403, apperr.MissingPermissionErrorMessage},
|
||||
}
|
||||
for _, example := range examples {
|
||||
t.Run(example.caller, func(t *testing.T) {
|
||||
@@ -160,6 +161,8 @@ func TestContentTypeKeysAndAccess(t *testing.T) {
|
||||
env.createType(t, "recipe", recipeFields())
|
||||
env.createType(t, "event", dishFields())
|
||||
|
||||
env.users.users[ctMemberId] = user.User{ID: ctMemberId, Username: "pat", IsVerified: true, RoleID: writerRoleID}
|
||||
|
||||
list := env.as(t, ctMemberId, http.MethodGet, "/v1/content-type", "")
|
||||
|
||||
expectStatusAndBody(t, list, http.StatusOK, "")
|
||||
|
||||
@@ -39,12 +39,12 @@ func TestImageVariantsGuards(t *testing.T) {
|
||||
{"no cookie", http.MethodGet, "/v1/image-variants", "none", 401, apperr.UnauthorizedErrorMessage},
|
||||
{"stale cookie", http.MethodGet, "/v1/image-variants", "stale", 401, apperr.UnauthorizedErrorMessage},
|
||||
{"unverified user", http.MethodGet, "/v1/image-variants", "unverified", 403, apperr.VerificationRequiredErrorMessage},
|
||||
{"plain user list", http.MethodGet, "/v1/image-variants", "plain", 403, apperr.OwnerOrAdminRequiredErrorMessage},
|
||||
{"plain user create", http.MethodPost, "/v1/image-variants", "plain", 403, apperr.OwnerOrAdminRequiredErrorMessage},
|
||||
{"plain user put", http.MethodPut, "/v1/image-variants/hero", "plain", 403, apperr.OwnerOrAdminRequiredErrorMessage},
|
||||
{"plain user patch", http.MethodPatch, "/v1/image-variants/hero", "plain", 403, apperr.OwnerOrAdminRequiredErrorMessage},
|
||||
{"plain user delete", http.MethodDelete, "/v1/image-variants/hero", "plain", 403, apperr.OwnerOrAdminRequiredErrorMessage},
|
||||
{"plain user purge", http.MethodPost, "/v1/image-variants/hero/purge", "plain", 403, apperr.OwnerOrAdminRequiredErrorMessage},
|
||||
{"plain user list", http.MethodGet, "/v1/image-variants", "plain", 403, apperr.MissingPermissionErrorMessage},
|
||||
{"plain user create", http.MethodPost, "/v1/image-variants", "plain", 403, apperr.MissingPermissionErrorMessage},
|
||||
{"plain user put", http.MethodPut, "/v1/image-variants/hero", "plain", 403, apperr.MissingPermissionErrorMessage},
|
||||
{"plain user patch", http.MethodPatch, "/v1/image-variants/hero", "plain", 403, apperr.MissingPermissionErrorMessage},
|
||||
{"plain user delete", http.MethodDelete, "/v1/image-variants/hero", "plain", 403, apperr.MissingPermissionErrorMessage},
|
||||
{"plain user purge", http.MethodPost, "/v1/image-variants/hero/purge", "plain", 403, apperr.MissingPermissionErrorMessage},
|
||||
{"api key", http.MethodGet, "/v1/image-variants", "apikey", 401, apperr.UnauthorizedErrorMessage},
|
||||
}
|
||||
for _, row := range rows {
|
||||
|
||||
@@ -29,8 +29,8 @@ func TestImageVariantsCacheGuards(t *testing.T) {
|
||||
{"no cookie", http.MethodGet, cacheStatsPath, "none", 401, apperr.UnauthorizedErrorMessage},
|
||||
{"a stale token_version cookie", http.MethodGet, cacheStatsPath, "stale", 401, apperr.UnauthorizedErrorMessage},
|
||||
{"the cookie of an unverified user", http.MethodGet, cacheStatsPath, "unverified", 403, apperr.VerificationRequiredErrorMessage},
|
||||
{"the cookie of a plain user", http.MethodGet, cacheStatsPath, "plain", 403, apperr.OwnerOrAdminRequiredErrorMessage},
|
||||
{"the cookie of a plain user on purge", http.MethodPost, cachePurgePath, "plain", 403, apperr.OwnerOrAdminRequiredErrorMessage},
|
||||
{"the cookie of a plain user", http.MethodGet, cacheStatsPath, "plain", 403, apperr.MissingPermissionErrorMessage},
|
||||
{"the cookie of a plain user on purge", http.MethodPost, cachePurgePath, "plain", 403, apperr.MissingPermissionErrorMessage},
|
||||
{"a valid API key", http.MethodGet, cacheStatsPath, "apikey", 401, apperr.UnauthorizedErrorMessage},
|
||||
{"a valid API key on purge", http.MethodPost, cachePurgePath, "apikey", 401, apperr.UnauthorizedErrorMessage},
|
||||
}
|
||||
|
||||
@@ -113,6 +113,7 @@ func (e *variantEnv) container(limits useCases.ServeVariantLimits) *api.UseCases
|
||||
return &api.UseCases{
|
||||
Token: e.token,
|
||||
GetUserUseCase: useCases.NewGetUserUseCase(e.users),
|
||||
CheckPermissionUseCase: useCases.NewCheckPermissionUseCase(newSeededMockRoleRepo()),
|
||||
GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()),
|
||||
GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()),
|
||||
OpenMediaUseCase: useCases.NewOpenMediaUseCase(e.storage),
|
||||
|
||||
Loaded 100 of 131 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user