375 lines
11 KiB
Go
375 lines
11 KiB
Go
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
|
|
}
|