refac: debt on role backend
This commit is contained in:
1 parent
de142f6c5f
commit
a043f7f41a
130 files changed
+3591
-512
No files matched your search
@@ -0,0 +1,374 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/domain/contenttype"
|
||||
"RenewCMS/internal/domain/entry"
|
||||
"RenewCMS/internal/domain/role"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
csOwner uint32 = 3
|
||||
csAdmin uint32 = 5
|
||||
csWriter uint32 = 9
|
||||
csUnverified uint32 = 12
|
||||
csOrphan uint32 = 13
|
||||
csHandPlaced uint32 = 20
|
||||
csContributor uint32 = 21
|
||||
csReaderOwn uint32 = 22
|
||||
csEditor uint32 = 23
|
||||
csPublisherOwn uint32 = 24
|
||||
csTypedA uint32 = 25
|
||||
csTypedB uint32 = 26
|
||||
csNoContent uint32 = 27
|
||||
csAnonymous uint32 = 0
|
||||
csEventsTypeID uint32 = 1
|
||||
csVenuesTypeID uint32 = 2
|
||||
csEntryBody = `{"data": {"title": "Updated"}}`
|
||||
csArticlePatch = `{"body": "x"}`
|
||||
)
|
||||
|
||||
type contentScopeRecorder struct {
|
||||
events []audit.Event
|
||||
}
|
||||
|
||||
var _ audit.Recorder = &contentScopeRecorder{}
|
||||
|
||||
func (r *contentScopeRecorder) Record(_ context.Context, event audit.Event) {
|
||||
r.events = append(r.events, event)
|
||||
}
|
||||
|
||||
func (r *contentScopeRecorder) actions() []string {
|
||||
actions := []string{}
|
||||
for _, event := range r.events {
|
||||
actions = append(actions, event.Action)
|
||||
}
|
||||
return actions
|
||||
}
|
||||
|
||||
type contentScopeRoleRepo struct {
|
||||
role.Repository
|
||||
gets int
|
||||
getByNames int
|
||||
listGrantsN int
|
||||
}
|
||||
|
||||
func (c *contentScopeRoleRepo) Get(id uint32) (role.Role, error) {
|
||||
c.gets++
|
||||
return c.Repository.Get(id)
|
||||
}
|
||||
|
||||
func (c *contentScopeRoleRepo) GetByName(name string) (role.Role, error) {
|
||||
c.getByNames++
|
||||
return c.Repository.GetByName(name)
|
||||
}
|
||||
|
||||
func (c *contentScopeRoleRepo) ListGrants(roleID uint32) ([]role.Grant, error) {
|
||||
c.listGrantsN++
|
||||
return c.Repository.ListGrants(roleID)
|
||||
}
|
||||
|
||||
func (c *contentScopeRoleRepo) touched() int {
|
||||
return c.gets + c.getByNames + c.listGrantsN
|
||||
}
|
||||
|
||||
type contentScopeUserRepo struct {
|
||||
user.Repository
|
||||
gets int
|
||||
failGet error
|
||||
}
|
||||
|
||||
func (c *contentScopeUserRepo) Get(id uint32) (user.User, error) {
|
||||
c.gets++
|
||||
if c.failGet != nil {
|
||||
return user.User{}, c.failGet
|
||||
}
|
||||
return c.Repository.Get(id)
|
||||
}
|
||||
|
||||
type contentScopeTypeRepo struct {
|
||||
contenttype.Repository
|
||||
lookups int
|
||||
}
|
||||
|
||||
func (c *contentScopeTypeRepo) GetByKey(key string) (contenttype.ContentType, error) {
|
||||
c.lookups++
|
||||
return c.Repository.GetByKey(key)
|
||||
}
|
||||
|
||||
type contentScopeWorld struct {
|
||||
*apiKeyEnv
|
||||
recorder *contentScopeRecorder
|
||||
roleRepo *contentScopeRoleRepo
|
||||
userRepo *contentScopeUserRepo
|
||||
typeRepo *contentScopeTypeRepo
|
||||
typeStore *mockContentTypeRepo
|
||||
entries *mockEntryRepo
|
||||
roleIDs map[uint32]uint32
|
||||
}
|
||||
|
||||
type customRoleSpec struct {
|
||||
user uint32
|
||||
name string
|
||||
grants []string
|
||||
}
|
||||
|
||||
var contentScopeCustomRoles = []customRoleSpec{
|
||||
{csContributor, "Contributor", []string{"content.read=all", "content.create=all", "content.update=own"}},
|
||||
{csReaderOwn, "Reader own", []string{"content.read=own", "content.create=all"}},
|
||||
{csEditor, "Editor", []string{"content.read=all", "content.update=all", "content.publish=all", "content.delete=own"}},
|
||||
{csPublisherOwn, "Publisher own", []string{"content.read=all", "content.publish=own"}},
|
||||
{csTypedA, "Typed A", []string{"content.read=all", "content.create=all", "content.update=all", "content.update:events=own", "content.update:article=none"}},
|
||||
{csTypedB, "Typed B", []string{"content.read:events=all", "content.create:events=all", "content.update:events=own"}},
|
||||
{csNoContent, "No content", []string{"schema.view=all"}},
|
||||
{csHandPlaced, "Hand placed", []string{"system.apply_updates=all"}},
|
||||
}
|
||||
|
||||
type articleSeed struct {
|
||||
id uint32
|
||||
online bool
|
||||
createdBy uint32
|
||||
}
|
||||
|
||||
var contentScopeArticles = []articleSeed{
|
||||
{10, false, 21}, {11, false, 9}, {12, true, 3}, {13, false, 3}, {14, false, 0}, {15, true, 21}, {16, false, 22}, {17, false, 24},
|
||||
}
|
||||
|
||||
type entrySeed struct {
|
||||
id uint32
|
||||
typeID uint32
|
||||
createdBy uint32
|
||||
}
|
||||
|
||||
var contentScopeEntries = []entrySeed{
|
||||
{1, csEventsTypeID, 21}, {2, csEventsTypeID, 9}, {3, csEventsTypeID, 0}, {4, csEventsTypeID, 25},
|
||||
{5, csVenuesTypeID, 3}, {6, csEventsTypeID, 22}, {7, csEventsTypeID, 26}, {8, csEventsTypeID, 23},
|
||||
}
|
||||
|
||||
func newContentScopeWorld(t *testing.T) *contentScopeWorld {
|
||||
t.Helper()
|
||||
recorder := &contentScopeRecorder{}
|
||||
env := newAPIKeyEnvRecording(t, recorder)
|
||||
world := &contentScopeWorld{
|
||||
apiKeyEnv: env,
|
||||
recorder: recorder,
|
||||
roleRepo: &contentScopeRoleRepo{Repository: env.roles},
|
||||
userRepo: &contentScopeUserRepo{Repository: env.users},
|
||||
typeStore: newMockContentTypeRepo(),
|
||||
entries: newMockEntryRepo(),
|
||||
roleIDs: map[uint32]uint32{},
|
||||
}
|
||||
world.typeRepo = &contentScopeTypeRepo{Repository: world.typeStore}
|
||||
world.seedUsers()
|
||||
world.seedContent()
|
||||
world.wire()
|
||||
return world
|
||||
}
|
||||
|
||||
func (w *contentScopeWorld) seedUsers() {
|
||||
w.addUser(csOwner, user.RoleOwner, ownerRoleID, true)
|
||||
w.addUser(csAdmin, user.RoleAdmin, adminRoleID, true)
|
||||
w.addUser(csWriter, "", writerRoleID, true)
|
||||
w.addUser(csUnverified, user.RoleAdmin, adminRoleID, false)
|
||||
w.addUser(csOrphan, "", 99, true)
|
||||
for _, spec := range contentScopeCustomRoles {
|
||||
created, _ := w.roles.Create(role.Role{Name: spec.name, Version: 1, Grants: grantRows(spec.grants...)})
|
||||
w.roleIDs[spec.user] = created.ID
|
||||
w.addUser(spec.user, "", created.ID, true)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *contentScopeWorld) seedContent() {
|
||||
now := time.Date(2026, time.October, 5, 9, 0, 0, 0, time.UTC)
|
||||
for _, seed := range contentScopeArticles {
|
||||
w.seedScopedArticle(seed.id, seed.online, seed.createdBy)
|
||||
}
|
||||
title := contenttype.Field{Key: "title", Label: "Title", Type: contenttype.TypeText, Required: true, Options: []string{}}
|
||||
w.typeStore.types[csEventsTypeID] = contenttype.ContentType{ID: csEventsTypeID, Key: "events", Name: "Events", Fields: []contenttype.Field{title}}
|
||||
w.typeStore.types[csVenuesTypeID] = contenttype.ContentType{ID: csVenuesTypeID, Key: "venues", Name: "Venues", Fields: []contenttype.Field{title}}
|
||||
w.typeStore.nextID = 10
|
||||
for _, seed := range contentScopeEntries {
|
||||
w.entries.entries[seed.id] = entry.Entry{ID: seed.id, ContentTypeID: seed.typeID, Data: map[string]any{"title": "Entry " + strconv.Itoa(int(seed.id))}, CreatedBy: seed.createdBy, UpdatedBy: seed.createdBy, CreatedAt: now, UpdatedAt: now}
|
||||
}
|
||||
w.entries.nextID = 100
|
||||
}
|
||||
|
||||
func (w *contentScopeWorld) seedScopedArticle(id uint32, online bool, createdBy uint32) {
|
||||
w.articles.articles[id] = article.Article{ID: id, Title: "Article " + strconv.Itoa(int(id)), Body: "x", Slug: "article-" + strconv.Itoa(int(id)), IsOnline: online, SiteID: 1, CreatedBy: createdBy, UpdatedAt: time.Date(2026, time.October, 1, 0, 0, 0, 0, time.UTC)}
|
||||
}
|
||||
|
||||
func (w *contentScopeWorld) wire() {
|
||||
clock := func() time.Time { return w.now }
|
||||
c := w.container
|
||||
c.GetUserUseCase = useCases.NewGetUserUseCase(w.userRepo)
|
||||
c.CheckPermissionUseCase = useCases.NewCheckPermissionUseCase(w.roleRepo)
|
||||
c.GetMyAccessUseCase = useCases.NewGetMyAccessUseCase(w.roleRepo)
|
||||
c.AuthenticateAPIKeyUseCase = useCases.NewAuthenticateAPIKeyUseCase(w.keys, w.generator, w.userRepo, clock)
|
||||
c.ResolveArticleSEOUseCase = useCases.NewResolveArticleSEOUseCase(w.articles, plainSites(), w.images)
|
||||
c.CheckArticleSEOUseCase = useCases.NewCheckArticleSEOUseCase(w.articles, plainSites(), w.images)
|
||||
c.CreateContentTypeUseCase = useCases.NewCreateContentTypeUseCase(w.typeRepo, clock)
|
||||
c.CreateEntryUseCase = useCases.NewCreateEntryUseCase(w.typeRepo, w.entries, w.images, clock)
|
||||
c.GetEntryUseCase = useCases.NewGetEntryUseCase(w.typeRepo, w.entries, w.images)
|
||||
c.ListEntriesUseCase = useCases.NewListEntriesUseCase(w.typeRepo, w.entries, w.images)
|
||||
c.UpdateEntryUseCase = useCases.NewUpdateEntryUseCase(w.typeRepo, w.entries, w.images, clock)
|
||||
c.DeleteEntryUseCase = useCases.NewDeleteEntryUseCase(w.typeRepo, w.entries)
|
||||
w.rebuildRouter()
|
||||
}
|
||||
|
||||
func (w *contentScopeWorld) do(t *testing.T, caller uint32, method string, path string, body string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
return w.send(t, apiKeyRequest{method: method, path: path, body: body, cookieUser: caller})
|
||||
}
|
||||
|
||||
func (w *contentScopeWorld) resetCounters() {
|
||||
w.roleRepo.gets, w.roleRepo.getByNames, w.roleRepo.listGrantsN = 0, 0, 0
|
||||
w.userRepo.gets = 0
|
||||
w.typeRepo.lookups = 0
|
||||
}
|
||||
|
||||
func (w *contentScopeWorld) setGrants(userID uint32, rows ...string) {
|
||||
stored := w.roles.roles[w.roleIDs[userID]]
|
||||
stored.Grants = grantRows(rows...)
|
||||
w.roles.roles[stored.ID] = stored
|
||||
}
|
||||
|
||||
type scopeWant struct {
|
||||
status int
|
||||
key string
|
||||
message string
|
||||
}
|
||||
|
||||
func okWant(status int) scopeWant {
|
||||
return scopeWant{status: status}
|
||||
}
|
||||
|
||||
func refusedWant(key string) scopeWant {
|
||||
return scopeWant{status: http.StatusForbidden, key: key}
|
||||
}
|
||||
|
||||
func notFoundWant(message string) scopeWant {
|
||||
return scopeWant{status: http.StatusNotFound, message: message}
|
||||
}
|
||||
|
||||
func (w scopeWant) label() string {
|
||||
if w.key != "" {
|
||||
return "403 " + w.key
|
||||
}
|
||||
text := strconv.Itoa(w.status)
|
||||
if w.message != "" {
|
||||
text += " " + w.message
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
func assertScopeWant(t *testing.T, rec *httptest.ResponseRecorder, want scopeWant) {
|
||||
t.Helper()
|
||||
if want.key != "" {
|
||||
if rec.Code != http.StatusForbidden || rec.Body.String() != apperr.MissingPermissionErrorMessage+"\n" {
|
||||
t.Fatalf("expected 403 missing_permission, got %d %q", rec.Code, rec.Body.String())
|
||||
}
|
||||
if header := rec.Header().Get(apperr.MissingPermissionHeader); header != want.key {
|
||||
t.Fatalf("expected %s %q, got %q", apperr.MissingPermissionHeader, want.key, header)
|
||||
}
|
||||
return
|
||||
}
|
||||
if rec.Code != want.status {
|
||||
t.Fatalf("expected %d, got %d %q", want.status, rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec.Header().Get(apperr.MissingPermissionHeader) != "" {
|
||||
t.Fatalf("unexpected %s header on a non-refusal", apperr.MissingPermissionHeader)
|
||||
}
|
||||
if want.message != "" && strings.TrimSpace(rec.Body.String()) != want.message {
|
||||
t.Fatalf("expected body %q, got %q", want.message, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
type scopeRow struct {
|
||||
caller uint32
|
||||
method string
|
||||
path string
|
||||
body string
|
||||
want scopeWant
|
||||
}
|
||||
|
||||
func callerLabel(caller uint32) string {
|
||||
if caller == csAnonymous {
|
||||
return "no cookie"
|
||||
}
|
||||
return "user " + strconv.Itoa(int(caller))
|
||||
}
|
||||
|
||||
func runScopeRows(t *testing.T, rows []scopeRow) {
|
||||
t.Helper()
|
||||
for _, row := range rows {
|
||||
t.Run(callerLabel(row.caller)+" "+row.method+" "+row.path+" "+row.body+" -> "+row.want.label(), func(t *testing.T) {
|
||||
world := newContentScopeWorld(t)
|
||||
assertScopeWant(t, world.do(t, row.caller, row.method, row.path, row.body), row.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type scopeListBody struct {
|
||||
Items []struct {
|
||||
ID uint32 `json:"id"`
|
||||
} `json:"items"`
|
||||
Total int `json:"total"`
|
||||
Page int `json:"page"`
|
||||
Size int `json:"size"`
|
||||
Counts struct {
|
||||
All int `json:"all"`
|
||||
Online int `json:"online"`
|
||||
Draft int `json:"draft"`
|
||||
Suggestions int `json:"suggestions"`
|
||||
} `json:"counts"`
|
||||
}
|
||||
|
||||
func decodeScopeList(t *testing.T, rec *httptest.ResponseRecorder) scopeListBody {
|
||||
t.Helper()
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d %q", rec.Code, rec.Body.String())
|
||||
}
|
||||
var body scopeListBody
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode %q: %v", rec.Body.String(), err)
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func (b scopeListBody) ids() []uint32 {
|
||||
ids := []uint32{}
|
||||
for _, item := range b.Items {
|
||||
ids = append(ids, item.ID)
|
||||
}
|
||||
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
|
||||
return ids
|
||||
}
|
||||
|
||||
func scopeIDs(values ...uint32) []uint32 {
|
||||
return values
|
||||
}
|
||||
|
||||
func sameScopeIDs(left []uint32, right []uint32) bool {
|
||||
if len(left) != len(right) {
|
||||
return false
|
||||
}
|
||||
for index := range left {
|
||||
if left[index] != right[index] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
Reference in new issue
Block a user