feat: more role backend and admin/writer roles additions and adjustments

This commit is contained in:
Florian Sylvain committed 2026-10-26 18:15:26 +01:00
1 parent 8c6c6456da
commit de142f6c5f
131 files changed
+2663 -435

No files matched your search

@@ -0,0 +1,373 @@
package test
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"context"
"encoding/csv"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"slices"
"strconv"
"strings"
"testing"
"time"
)
const scopedUserID uint32 = 6
func (e *activityHTTPEnv) customUser(id uint32, grants ...role.Grant) {
custom, _ := e.roles.Create(role.Role{Name: "Scoped " + strconv.Itoa(int(id)), Version: 1, Grants: grants})
e.users.users[id] = user.User{ID: id, Username: "scoped" + strconv.Itoa(int(id)), Email: "scoped@example.com", IsVerified: true, RoleID: custom.ID}
}
func (e *activityHTTPEnv) seedActorEvents(t *testing.T, ids ...uint32) {
t.Helper()
for _, id := range ids {
e.seed(t, activityUserEvent(id, "actor"+strconv.Itoa(int(id)), e.now.Add(-time.Hour)))
}
}
func activityActorsOf(t *testing.T, rec *httptest.ResponseRecorder) []uint32 {
t.Helper()
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d %q", rec.Code, rec.Body.String())
}
var body struct {
Items []struct {
Actor struct {
ID *uint32 `json:"id"`
} `json:"actor"`
} `json:"items"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode %q: %v", rec.Body.String(), err)
}
actors := []uint32{}
for _, item := range body.Items {
actors = append(actors, *item.Actor.ID)
}
slices.Sort(actors)
return actors
}
func csvActorsOf(t *testing.T, rec *httptest.ResponseRecorder) []string {
t.Helper()
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d %q", rec.Code, rec.Body.String())
}
rows, err := csv.NewReader(strings.NewReader(rec.Body.String())).ReadAll()
if err != nil {
t.Fatalf("csv: %v", err)
}
actors := []string{}
for _, row := range rows[1:] {
actors = append(actors, row[1])
}
slices.Sort(actors)
return actors
}
func scopedGrant(key string, scope role.Scope) role.Grant {
return role.Grant{Key: key, Scope: scope}
}
func assertMissing(t *testing.T, rec *httptest.ResponseRecorder, key string) {
t.Helper()
assertPlain(t, rec, http.StatusForbidden, apperr.MissingPermissionErrorMessage)
if got := rec.Header().Get(apperr.MissingPermissionHeader); got != key {
t.Errorf("expected %s %q, got %q", apperr.MissingPermissionHeader, key, got)
}
}
func TestRolesPermissionsEnforcementActivityScope(t *testing.T) {
t.Run("activity.view scope decides what the list returns", func(t *testing.T) {
rows := []struct {
name string
scope role.Scope
query string
actors []uint32
missing bool
}{
{"all", role.ScopeAll, "", []uint32{1, 2, scopedUserID}, false},
{"all with an actor", role.ScopeAll, "?actor=user:2", []uint32{2}, false},
{"own", role.ScopeOwn, "", []uint32{scopedUserID}, false},
{"own asking for self", role.ScopeOwn, "?actor=user:6", []uint32{scopedUserID}, false},
{"own asking for another actor", role.ScopeOwn, "?actor=user:1", nil, true},
{"none", role.ScopeNone, "", nil, true},
}
for _, row := range rows {
t.Run(row.name, func(t *testing.T) {
e := newActivityHTTPEnv(t, 1000)
e.customUser(scopedUserID, scopedGrant(role.KeyActivityView, row.scope))
e.seedActorEvents(t, 1, 2, scopedUserID)
rec := e.get("/v1/activity"+row.query, asUser(e, scopedUserID))
if row.missing {
assertMissing(t, rec, role.KeyActivityView)
return
}
if got := activityActorsOf(t, rec); !slices.Equal(got, row.actors) {
t.Errorf("expected actors %v, got %v", row.actors, got)
}
})
}
})
t.Run("Activity summary follows the scope", func(t *testing.T) {
e := newActivityHTTPEnv(t, 1000)
e.customUser(scopedUserID, scopedGrant(role.KeyActivityView, role.ScopeAll))
if rec := e.get("/v1/activity/summary", asUser(e, scopedUserID)); rec.Code != http.StatusOK {
t.Errorf("all: expected 200, got %d", rec.Code)
}
e.customUser(7, scopedGrant(role.KeyActivityView, role.ScopeOwn))
assertMissing(t, e.get("/v1/activity/summary", asUser(e, 7)), role.KeyActivityView)
})
t.Run("Activity export follows the scope", func(t *testing.T) {
rows := []struct {
name string
grants []role.Grant
actors []string
key string
}{
{"view all and export all", []role.Grant{scopedGrant(role.KeyActivityView, role.ScopeAll), scopedGrant(role.KeyActivityExport, role.ScopeAll)}, []string{"actor1", "actor2", "actor6"}, ""},
{"view own and export all", []role.Grant{scopedGrant(role.KeyActivityView, role.ScopeOwn), scopedGrant(role.KeyActivityExport, role.ScopeAll)}, []string{"actor6"}, ""},
{"view none and export all", []role.Grant{scopedGrant(role.KeyActivityView, role.ScopeNone), scopedGrant(role.KeyActivityExport, role.ScopeAll)}, nil, role.KeyActivityView},
{"view all without export", []role.Grant{scopedGrant(role.KeyActivityView, role.ScopeAll)}, nil, role.KeyActivityExport},
}
for _, row := range rows {
t.Run(row.name, func(t *testing.T) {
e := newActivityHTTPEnv(t, 1000)
e.customUser(scopedUserID, row.grants...)
e.seedActorEvents(t, 1, 2, scopedUserID)
rec := e.get("/v1/activity/export", asUser(e, scopedUserID))
if row.key != "" {
assertMissing(t, rec, row.key)
return
}
if got := csvActorsOf(t, rec); !slices.Equal(got, row.actors) {
t.Errorf("expected %v, got %v", row.actors, got)
}
})
}
})
t.Run("Writer is effective on activity", func(t *testing.T) {
e := newActivityHTTPEnv(t, 1000)
e.seedActorEvents(t, 1, 2, activityEditorID)
got := activityActorsOf(t, e.get("/v1/activity", asUser(e, activityEditorID)))
if !slices.Equal(got, []uint32{activityEditorID}) {
t.Errorf("expected only the writer's events, got %v", got)
}
})
}
func TestRolesPermissionsEnforcementAPIKeyScope(t *testing.T) {
seedKeys := func(e *apiKeyEnv) {
e.issueKey(enforcementOwnerID, "owner key")
e.issueKey(enforcementAdminID, "admin key")
e.issueKey(enforcementCustomID, "caller key")
}
t.Run("apikeys.view scope decides which keys are listed", func(t *testing.T) {
rows := []struct {
name string
scope role.Scope
names []string
named bool
key string
}{
{"all", role.ScopeAll, []string{"caller key", "admin key", "owner key"}, true, ""},
{"own", role.ScopeOwn, []string{"caller key"}, false, ""},
{"none", role.ScopeNone, nil, false, "apikeys.view"},
}
for _, row := range rows {
t.Run(row.name, func(t *testing.T) {
e := newEnforcementEnv(t)
e.customRoleUser(enforcementCustomID, scopedGrant("apikeys.view", row.scope))
seedKeys(e)
rec := e.withCookie(t, http.MethodGet, "/v1/api-key", "", enforcementCustomID)
if row.key != "" {
assertGate(t, gateOutcome{rec: rec}, row.key)
return
}
assertAPIKeyStatus(t, rec, http.StatusOK)
if got := apiKeyEntryNames(t, rec); !slices.Equal(got, row.names) {
t.Errorf("expected %v, got %v", row.names, got)
}
for _, entry := range apiKeyDecodeList(t, rec) {
if _, present := entry["created_by_name"]; present != row.named {
t.Errorf("created_by_name present=%v, expected %v", present, row.named)
}
}
})
}
})
t.Run("apikeys.revoke scope decides which keys can be revoked", func(t *testing.T) {
rows := []struct {
name string
scope role.Scope
status int
message string
revoked bool
}{
{"all", role.ScopeAll, http.StatusNoContent, "", true},
{"own", role.ScopeOwn, http.StatusNotFound, apperr.APIKeyNotFoundErrorMessage, false},
{"none", role.ScopeNone, http.StatusForbidden, apperr.MissingPermissionErrorMessage, false},
}
for _, row := range rows {
t.Run(row.name, func(t *testing.T) {
e := newEnforcementEnv(t)
e.customRoleUser(enforcementCustomID, scopedGrant("apikeys.revoke", row.scope))
_, id := e.issueKey(enforcementAdminID, "admin key")
rec := e.withCookie(t, http.MethodDelete, "/v1/api-key/"+strconv.Itoa(int(id)), "", enforcementCustomID)
assertAPIKeyMessage(t, rec, row.status, row.message)
if got := e.keys.keys[id].RevokedAt != nil; got != row.revoked {
t.Errorf("revoked=%v, expected %v", got, row.revoked)
}
})
}
})
t.Run("revoke none answers before any lookup", func(t *testing.T) {
repo := newMockAPIKeyRepo()
stored := seedStubKey(repo, "rcms_a", nil)
repo.fail.failNext("Get")
uc := useCases.NewRevokeAPIKeyUseCase(repo, apiKeyUnitClock, discardRecorder)
err := uc.RevokeAPIKey(testCtx, 2, stored.ID, role.NewGrantSet(nil))
if !errors.Is(err, role.ErrMissingPermission) {
t.Errorf("expected a missing permission, got %v", err)
}
})
t.Run("The Admin lists and revokes every key", func(t *testing.T) {
e := newEnforcementEnv(t)
_, ownerKey := e.issueKey(enforcementOwnerID, "owner key")
list := e.withCookie(t, http.MethodGet, "/v1/api-key", "", enforcementAdminID)
assertAPIKeyStatus(t, list, http.StatusOK)
entries := apiKeyListedByName(t, list)
if entries["owner key"]["created_by_name"] == nil {
t.Errorf("the owner's key lacks created_by_name: %v", entries)
}
revoke := e.withCookie(t, http.MethodDelete, "/v1/api-key/"+strconv.Itoa(int(ownerKey)), "", enforcementAdminID)
assertAPIKeyStatus(t, revoke, http.StatusNoContent)
})
t.Run("A list with no apikeys.view grant is refused by the use case as well", func(t *testing.T) {
uc := useCases.NewListAPIKeysUseCase(newMockAPIKeyRepo(), apiKeyUnitUsers(), apiKeyUnitClock)
if _, err := uc.ListAPIKeys(2, role.NewGrantSet(nil)); !errors.Is(err, role.ErrMissingPermission) {
t.Errorf("expected a missing permission, got %v", err)
}
})
}
func TestRolesPermissionsEnforcementAuthorScope(t *testing.T) {
const writerUser, ownerUser uint32 = 9, 3
newUseCase := func() (*useCases.UpdateAuthorUseCase, *mockAuthorRepo) {
authors := plainAuthors()
authors.seedLinked(4, "Writer", "writer", writerUser)
authors.seedLinked(5, "Owner", "owner", ownerUser)
users := newMockUserRepo()
users.users[writerUser] = user.User{ID: writerUser, Username: "writer", Email: "w@example.com"}
users.users[ownerUser] = user.User{ID: ownerUser, Username: "owner", Email: "o@example.com"}
return useCases.NewUpdateAuthorUseCase(authors, users, newMockImageRepo(), discardRecorder), authors
}
bio := "x"
t.Run("authors.edit scope decides who can edit an author", func(t *testing.T) {
rows := []struct {
name string
grants role.GrantSet
id uint32
patch author.Patch
want error
any bool
}{
{"all edits another author", authorsEditAllGrants, 5, author.Patch{Bio: &bio}, nil, false},
{"all may relink", authorsEditAllGrants, 5, author.Patch{UserID: author.Nullable[uint32]{Set: true, Value: idPointer(writerUser)}}, nil, true},
{"own edits its linked author", authorsEditOwnGrants, 4, author.Patch{Bio: &bio}, nil, false},
{"own cannot edit another author", authorsEditOwnGrants, 5, author.Patch{Bio: &bio}, author.ErrAuthorRights, false},
{"own cannot unlink", authorsEditOwnGrants, 4, author.Patch{UserID: author.Nullable[uint32]{Set: true}}, author.ErrAuthorRights, false},
{"none is refused", role.NewGrantSet(nil), 4, author.Patch{Bio: &bio}, role.ErrMissingPermission, false},
{"none is refused before any lookup", role.NewGrantSet(nil), 999, author.Patch{Bio: &bio}, role.ErrMissingPermission, false},
}
for _, row := range rows {
t.Run(row.name, func(t *testing.T) {
uc, _ := newUseCase()
_, err := uc.Update(context.Background(), row.id, useCases.AuthorCaller{UserID: writerUser, Grants: row.grants}, row.patch)
if row.any {
if errors.Is(err, author.ErrAuthorRights) || errors.Is(err, role.ErrMissingPermission) {
t.Errorf("unexpected refusal %v", err)
}
return
}
if !errors.Is(err, row.want) && (row.want != nil || err != nil) {
t.Errorf("expected %v, got %v", row.want, err)
}
})
}
})
t.Run("user_email on authors needs users.view", func(t *testing.T) {
for name, grants := range map[string]role.GrantSet{
"with users.view": role.NewGrantSet([]role.Grant{scopedGrant(role.KeyUsersView, role.ScopeAll)}),
"without users.view": role.NewGrantSet(nil),
} {
t.Run(name, func(t *testing.T) {
authors := plainAuthors()
authors.seedLinked(4, "Writer", "writer", writerUser)
users := newMockUserRepo()
users.users[writerUser] = user.User{ID: writerUser, Email: "w@example.com"}
page, err := useCases.NewListAuthorsUseCase(authors, users).List(useCases.ListAuthorsCommand{Page: 1, Size: 25, Grants: grants})
if err != nil || len(page.Items) != 1 {
t.Fatalf("unexpected %v %+v", err, page)
}
if shown := page.Items[0].UserEmail != nil; shown != grants.Holds(role.KeyUsersView) {
t.Errorf("user_email shown=%v for %s", shown, name)
}
})
}
})
t.Run("Writer sees author items without user_email and the Admin with it", func(t *testing.T) {
e := newAuthorEnv(t)
e.authors.seedLinked(2, "Edith", "edith", authorEditorID)
writerList := e.as(t, authorEditorID, http.MethodGet, "/v1/author/", "")
adminList := e.as(t, apiKeyDevId, http.MethodGet, "/v1/author/", "")
if writerList.Code != http.StatusOK || strings.Contains(writerList.Body.String(), "user_email") {
t.Errorf("writer: %d %s", writerList.Code, writerList.Body.String())
}
if adminList.Code != http.StatusOK || !strings.Contains(adminList.Body.String(), "edith@example.com") {
t.Errorf("admin: %d %s", adminList.Code, adminList.Body.String())
}
})
t.Run("A role without authors.edit is refused before any lookup", func(t *testing.T) {
e := newEnforcementEnv(t)
e.customRoleUser(enforcementCustomID, scopedGrant("authors.view", role.ScopeAll))
assertGate(t, e.gateAs(t, enforcementCustomID, http.MethodPatch, "/v1/author/999", `{"bio": "x"}`), "authors.edit")
})
}