feat: roles system backend
This commit is contained in:
1 parent
44cca46d5e
commit
8c6c6456da
50 files changed
+2578
-22
No files matched your search
@@ -59,7 +59,7 @@ func newAcceptInviteFixture(t *testing.T, maxAttempts int) *acceptInviteFixture
|
||||
f.token,
|
||||
true,
|
||||
)
|
||||
meHandler := auth.NewMeHandler(getUser, useCases.NewGetMyAuthorUseCase(plainAuthors()))
|
||||
meHandler := auth.NewMeHandler(getUser, useCases.NewGetMyAuthorUseCase(plainAuthors()), useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()))
|
||||
listHandler := userhandler.NewInviteHandler(
|
||||
useCases.NewCreateInviteUseCase(f.users, f.invites, f.hasher, f.mail, invitePublicURL, discardRecorder),
|
||||
nil,
|
||||
|
||||
@@ -74,6 +74,7 @@ func newActivityHTTPEnv(t *testing.T, attempts int) *activityHTTPEnv {
|
||||
Token: e.token,
|
||||
GetUserUseCase: useCases.NewGetUserUseCase(e.users),
|
||||
GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()),
|
||||
GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()),
|
||||
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),
|
||||
|
||||
@@ -64,6 +64,7 @@ var routesNotYetInTheCatalogue = []string{
|
||||
"GET /v1/image-variants/cache",
|
||||
"GET /v1/media/{id}/variants",
|
||||
"GET /v1/user/lookup",
|
||||
"GET /v1/permission",
|
||||
"PATCH /v1/image-variants/{name}",
|
||||
"POST /v1/content-type",
|
||||
"POST /v1/content-type/{key}/entry",
|
||||
|
||||
@@ -45,6 +45,7 @@ var apiKeyRawPattern = regexp.MustCompile(`^rcms_[A-Za-z0-9_-]{43}$`)
|
||||
|
||||
type apiKeyEnv struct {
|
||||
users *mockUserRepo
|
||||
roles *mockRoleRepo
|
||||
keys *mockAPIKeyRepo
|
||||
articles *mockArticleRepo
|
||||
images *mockImageRepo
|
||||
@@ -72,6 +73,7 @@ func newAPIKeyEnvRecording(t *testing.T, recorder audit.Recorder) *apiKeyEnv {
|
||||
|
||||
env := &apiKeyEnv{
|
||||
users: newMockUserRepo(),
|
||||
roles: newSeededMockRoleRepo(),
|
||||
keys: newMockAPIKeyRepo(),
|
||||
articles: newMockArticleRepo(),
|
||||
images: newMockImageRepo(),
|
||||
@@ -113,6 +115,8 @@ func newAPIKeyEnvRecording(t *testing.T, recorder audit.Recorder) *apiKeyEnv {
|
||||
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(env.articles, recorder),
|
||||
GetUserUseCase: useCases.NewGetUserUseCase(env.users),
|
||||
GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()),
|
||||
GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(env.roles),
|
||||
ListPermissionsUseCase: useCases.NewListPermissionsUseCase(),
|
||||
CreateUserUseCase: useCases.NewCreateUserUseCase(env.users, hasher, mail, "https://cms.example.com", recorder),
|
||||
AuthenticateUserUseCase: useCases.NewAuthenticateUserUseCase(env.users, hasher, recorder),
|
||||
VerifyUserUseCase: useCases.NewVerifyUserUseCase(env.users, hasher, recorder),
|
||||
|
||||
@@ -351,8 +351,8 @@ type authorEnv struct {
|
||||
func newAuthorEnv(t *testing.T) *authorEnv {
|
||||
t.Helper()
|
||||
p := newProducersEnv(t)
|
||||
p.users.users[authorEditorID] = user.User{ID: authorEditorID, Username: "edith", DisplayName: "Edith Moreau", Email: "edith@example.com", IsVerified: true, Role: authorEditorRole}
|
||||
p.users.users[authorOtherEditorID] = user.User{ID: authorOtherEditorID, Username: "frank", Email: "frank@example.com", IsVerified: true, Role: authorEditorRole}
|
||||
p.users.users[authorEditorID] = user.User{ID: authorEditorID, Username: "edith", DisplayName: "Edith Moreau", Email: "edith@example.com", IsVerified: true, Role: authorEditorRole, RoleID: writerRoleID}
|
||||
p.users.users[authorOtherEditorID] = user.User{ID: authorOtherEditorID, Username: "frank", Email: "frank@example.com", IsVerified: true, Role: authorEditorRole, RoleID: writerRoleID}
|
||||
p.users.nextID = authorOtherEditorID
|
||||
authors := newMockAuthorRepo(p.articles)
|
||||
sites := plainSites()
|
||||
@@ -437,9 +437,9 @@ func joinNames(names []string) string {
|
||||
}
|
||||
|
||||
func userWithEmail(id uint32, username string, email string) user.User {
|
||||
return user.User{ID: id, Username: username, Email: email, IsVerified: true, Role: authorEditorRole}
|
||||
return user.User{ID: id, Username: username, Email: email, IsVerified: true, Role: authorEditorRole, RoleID: writerRoleID}
|
||||
}
|
||||
|
||||
func userNamed(id uint32, username string, displayName string) user.User {
|
||||
return user.User{ID: id, Username: username, DisplayName: displayName, Email: username + "@example.com", IsVerified: true, Role: authorEditorRole}
|
||||
return user.User{ID: id, Username: username, DisplayName: displayName, Email: username + "@example.com", IsVerified: true, Role: authorEditorRole, RoleID: writerRoleID}
|
||||
}
|
||||
@@ -58,6 +58,7 @@ func newContainerHarness(t *testing.T, container bool, helperInstalled bool) *co
|
||||
Token: token,
|
||||
GetUserUseCase: useCases.NewGetUserUseCase(newUpdateUserRepo()),
|
||||
GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()),
|
||||
GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()),
|
||||
GetUpdateStatusUseCase: useCases.NewGetUpdateStatusUseCase(rig.store, rig.jobs, helper, rig.results, rig.requests, updateRunning, container, now),
|
||||
CheckForUpdateUseCase: useCases.NewCheckForUpdateUseCase(feed, rig.store, updateRunning, now, discardRecorder),
|
||||
StartUpdateDownloadUseCase: start,
|
||||
|
||||
@@ -19,7 +19,7 @@ import (
|
||||
|
||||
func newGetCurrentUserRouter(userRepo *mockUserRepo, hasher *stubHasher, token *authtoken.Auth) *chi.Mux {
|
||||
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
|
||||
meHandler := auth.NewMeHandler(getUserUseCase, useCases.NewGetMyAuthorUseCase(plainAuthors()))
|
||||
meHandler := auth.NewMeHandler(getUserUseCase, useCases.NewGetMyAuthorUseCase(plainAuthors()), useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()))
|
||||
verificationHandler := auth.NewVerificationHandler(
|
||||
useCases.NewVerifyUserUseCase(userRepo, hasher, discardRecorder),
|
||||
useCases.NewCancelRegistrationUseCase(userRepo),
|
||||
@@ -67,6 +67,7 @@ func TestGetCurrentUserHttp(t *testing.T) {
|
||||
userRepo.users[7] = user.User{
|
||||
ID: 7,
|
||||
Username: "alice",
|
||||
Role: user.RoleOwner,
|
||||
Email: "alice@example.com",
|
||||
IsVerified: true,
|
||||
TokenVersion: 0,
|
||||
@@ -97,6 +98,7 @@ func TestGetCurrentUserHttp(t *testing.T) {
|
||||
userRepo.users[8] = user.User{
|
||||
ID: 8,
|
||||
Username: "bob",
|
||||
Role: user.RoleOwner,
|
||||
Email: "bob@example.com",
|
||||
IsVerified: false,
|
||||
TokenVersion: 0,
|
||||
@@ -124,7 +126,7 @@ func TestGetCurrentUserHttp(t *testing.T) {
|
||||
t.Run("No jwt cookie at all is refused", func(t *testing.T) {
|
||||
token := authtoken.New([]byte("test-secret"))
|
||||
userRepo := newMockUserRepo()
|
||||
userRepo.users[7] = user.User{ID: 7, Username: "alice", IsVerified: true, TokenVersion: 0}
|
||||
userRepo.users[7] = user.User{ID: 7, Username: "alice", Role: user.RoleAdmin, IsVerified: true, TokenVersion: 0}
|
||||
router := newGetCurrentUserRouter(userRepo, &stubHasher{}, token)
|
||||
|
||||
rec := sendGetCurrentUserRequest(router, http.MethodGet, "/v1/auth/me", nil)
|
||||
@@ -137,7 +139,7 @@ func TestGetCurrentUserHttp(t *testing.T) {
|
||||
t.Run("A jwt whose token_version no longer matches the stored user is refused", func(t *testing.T) {
|
||||
token := authtoken.New([]byte("test-secret"))
|
||||
userRepo := newMockUserRepo()
|
||||
userRepo.users[7] = user.User{ID: 7, Username: "alice", IsVerified: true, TokenVersion: 1}
|
||||
userRepo.users[7] = user.User{ID: 7, Username: "alice", Role: user.RoleAdmin, IsVerified: true, TokenVersion: 1}
|
||||
router := newGetCurrentUserRouter(userRepo, &stubHasher{}, token)
|
||||
cookie := mintGetCurrentUserCookie(t, token, 7, 0)
|
||||
|
||||
@@ -154,7 +156,7 @@ func TestGetCurrentUserHttp(t *testing.T) {
|
||||
t.Run("A jwt minted for a since-deleted account is refused", func(t *testing.T) {
|
||||
token := authtoken.New([]byte("test-secret"))
|
||||
userRepo := newMockUserRepo()
|
||||
userRepo.users[7] = user.User{ID: 7, Username: "alice", IsVerified: false, TokenVersion: 0}
|
||||
userRepo.users[7] = user.User{ID: 7, Username: "alice", Role: user.RoleAdmin, IsVerified: false, TokenVersion: 0}
|
||||
router := newGetCurrentUserRouter(userRepo, &stubHasher{}, token)
|
||||
cookie := mintGetCurrentUserCookie(t, token, 7, 0)
|
||||
|
||||
@@ -175,7 +177,7 @@ func TestGetCurrentUserHttp(t *testing.T) {
|
||||
t.Run("The route is never rate-limited, unlike login and password reset", func(t *testing.T) {
|
||||
token := authtoken.New([]byte("test-secret"))
|
||||
userRepo := newMockUserRepo()
|
||||
userRepo.users[7] = user.User{ID: 7, Username: "alice", IsVerified: true, TokenVersion: 0}
|
||||
userRepo.users[7] = user.User{ID: 7, Username: "alice", Role: user.RoleAdmin, IsVerified: true, TokenVersion: 0}
|
||||
router := newGetCurrentUserRouter(userRepo, &stubHasher{}, token)
|
||||
cookie := mintGetCurrentUserCookie(t, token, 7, 0)
|
||||
|
||||
|
||||
@@ -114,6 +114,7 @@ func (e *variantEnv) container(limits useCases.ServeVariantLimits) *api.UseCases
|
||||
Token: e.token,
|
||||
GetUserUseCase: useCases.NewGetUserUseCase(e.users),
|
||||
GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()),
|
||||
GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()),
|
||||
OpenMediaUseCase: useCases.NewOpenMediaUseCase(e.storage),
|
||||
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(e.images, e.storage, e.files, recorder, plainSites(), plainAuthors(), e.articles),
|
||||
MergeMediaUseCase: newTestMergeMedia(e.images, e.articles, e.storage, e.files, recorder, plainSites(), plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo()),
|
||||
|
||||
@@ -74,6 +74,7 @@ func newMediaEnv(t *testing.T) *mediaEnv {
|
||||
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(env.articles, discardRecorder),
|
||||
GetUserUseCase: useCases.NewGetUserUseCase(env.users),
|
||||
GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()),
|
||||
GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()),
|
||||
CreateImageUseCase: useCases.NewCreateImageUseCase(env.images, env.storage, discardRecorder, imaging.NewMetadataStripper()),
|
||||
GenerateVariantsOnUploadUseCase: newInertGenerateOnUploadUseCase(),
|
||||
ListMediaUseCase: useCases.NewListMediaUseCase(env.images, env.articles, env.users, env.variants, plainSites(), plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo()),
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRoleDomainImportsOnlyTheStandardLibrary(t *testing.T) {
|
||||
t.Run("The role domain imports only the standard library", func(t *testing.T) {
|
||||
files, err := filepath.Glob("../internal/domain/role/*.go")
|
||||
if err != nil || len(files) == 0 {
|
||||
t.Fatalf("no role domain files: %v", err)
|
||||
}
|
||||
for _, file := range files {
|
||||
relative := strings.TrimPrefix(filepath.ToSlash(file), "../")
|
||||
for _, imported := range importsOf(t, relative) {
|
||||
first := strings.Split(imported, "/")[0]
|
||||
if strings.Contains(first, ".") || first == "RenewCMS" {
|
||||
t.Errorf("%s imports %s", relative, imported)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/role"
|
||||
"RenewCMS/internal/infrastructure/persistence/database"
|
||||
"RenewCMS/internal/infrastructure/persistence/models"
|
||||
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func deleteGrantRow(t *testing.T, db *gorm.DB, roleID uint32, key string) {
|
||||
t.Helper()
|
||||
if err := db.Where("role_id = ? AND permission_key = ?", roleID, key).Delete(&models.RolePermission{}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuiltInReconciliation(t *testing.T) {
|
||||
reseed := func(t *testing.T, db *gorm.DB) {
|
||||
t.Helper()
|
||||
if err := database.SeedBuiltInRoles(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("Owner regains a missing key at scope all without a version bump", func(t *testing.T) {
|
||||
db := openMigratedRolesDB(t)
|
||||
owner := storedRole(t, db, "Owner")
|
||||
deleteGrantRow(t, db, owner.ID, "system.apply_updates")
|
||||
deleteGrantRow(t, db, owner.ID, "content.read")
|
||||
reseed(t, db)
|
||||
after := storedRole(t, db, "Owner")
|
||||
scopes := grantMap(after.Grants)
|
||||
if scopes["system.apply_updates"] != role.ScopeAll || scopes["content.read"] != role.ScopeAll {
|
||||
t.Errorf("owner did not regain keys")
|
||||
}
|
||||
if after.Version != owner.Version || len(after.Grants) != len(owner.Grants) {
|
||||
t.Errorf("version or grant count changed: %+v", after.Version)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Owner keeps an existing grant untouched", func(t *testing.T) {
|
||||
db := openMigratedRolesDB(t)
|
||||
owner := storedRole(t, db, "Owner")
|
||||
if err := db.Model(&models.RolePermission{}).Where("role_id = ? AND permission_key = ?", owner.ID, "content.read").Update("scope", "own").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reseed(t, db)
|
||||
if grantMap(storedRole(t, db, "Owner").Grants)["content.read"] != role.ScopeOwn {
|
||||
t.Errorf("existing grant downgraded or overwritten")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("An unedited Admin regains a deleted grant but never an owner-only one", func(t *testing.T) {
|
||||
db := openMigratedRolesDB(t)
|
||||
admin := storedRole(t, db, "Admin")
|
||||
deleteGrantRow(t, db, admin.ID, "users.invite")
|
||||
reseed(t, db)
|
||||
scopes := grantMap(storedRole(t, db, "Admin").Grants)
|
||||
if scopes["users.invite"] != role.ScopeAll {
|
||||
t.Errorf("admin did not regain users.invite")
|
||||
}
|
||||
if _, owned := scopes["system.apply_updates"]; owned {
|
||||
t.Errorf("admin gained an owner-only key")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("An edited Writer does not regain a deleted grant", func(t *testing.T) {
|
||||
db := openMigratedRolesDB(t)
|
||||
writer := storedRole(t, db, "Writer")
|
||||
if err := db.Model(&models.Role{}).Where("id = ?", writer.ID).Update("version", 2).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
deleteGrantRow(t, db, writer.ID, "media.upload")
|
||||
reseed(t, db)
|
||||
after := storedRole(t, db, "Writer")
|
||||
if _, back := grantMap(after.Grants)["media.upload"]; back || after.Version != 2 {
|
||||
t.Errorf("edited writer reconciled: %+v", after)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("An unedited Writer regains a deleted grant", func(t *testing.T) {
|
||||
db := openMigratedRolesDB(t)
|
||||
writer := storedRole(t, db, "Writer")
|
||||
deleteGrantRow(t, db, writer.ID, "media.upload")
|
||||
reseed(t, db)
|
||||
if grantMap(storedRole(t, db, "Writer").Grants)["media.upload"] != role.ScopeAll {
|
||||
t.Errorf("writer did not regain media.upload")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRoleRepositorySaveVersionCheck(t *testing.T) {
|
||||
t.Run("A stale version is a conflict and leaves grants untouched", func(t *testing.T) {
|
||||
db := openMigratedRolesDB(t)
|
||||
repo := persistence.NewRoleRepository(db)
|
||||
writer := storedRole(t, db, "Writer")
|
||||
before := len(writer.Grants)
|
||||
writer.Version = 7
|
||||
writer.Name = "Renamed"
|
||||
writer.Grants = []role.Grant{{Key: "media.delete", Scope: role.ScopeOwn}}
|
||||
if _, err := repo.Save(writer); !errors.Is(err, role.ErrRoleConflict) {
|
||||
t.Fatalf("want ErrRoleConflict, got %v", err)
|
||||
}
|
||||
after := storedRole(t, db, "Writer")
|
||||
if len(after.Grants) != before || after.Version != 1 {
|
||||
t.Errorf("stale save changed the role: %+v", after)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A successful save bumps the version and replaces grants", func(t *testing.T) {
|
||||
db := openMigratedRolesDB(t)
|
||||
repo := persistence.NewRoleRepository(db)
|
||||
writer := storedRole(t, db, "Writer")
|
||||
writer.Description = "edited"
|
||||
writer.Grants = []role.Grant{{Key: "media.delete", Scope: role.ScopeOwn}}
|
||||
saved, err := repo.Save(writer)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if saved.Version != writer.Version+1 || saved.Description != "edited" || len(saved.Grants) != 1 {
|
||||
t.Errorf("saved: %+v", saved)
|
||||
}
|
||||
again := writer
|
||||
if _, err := repo.Save(again); !errors.Is(err, role.ErrRoleConflict) {
|
||||
t.Errorf("reusing the old version must conflict, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Save cannot flip BuiltIn or Locked", func(t *testing.T) {
|
||||
db := openMigratedRolesDB(t)
|
||||
repo := persistence.NewRoleRepository(db)
|
||||
writer := storedRole(t, db, "Writer")
|
||||
writer.BuiltIn = false
|
||||
writer.Locked = true
|
||||
saved, err := repo.Save(writer)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !saved.BuiltIn || saved.Locked {
|
||||
t.Errorf("flags changed: %+v", saved)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestMockRoleRepoSaveMirrorsVersionCheck(t *testing.T) {
|
||||
repo := newSeededMockRoleRepo()
|
||||
writer := repo.byName("Writer")
|
||||
stale := writer
|
||||
stale.Version = 9
|
||||
if _, err := repo.Save(stale); !errors.Is(err, role.ErrRoleConflict) {
|
||||
t.Errorf("want conflict, got %v", err)
|
||||
}
|
||||
writer.BuiltIn = false
|
||||
saved, err := repo.Save(writer)
|
||||
if err != nil || saved.Version != 2 || !saved.BuiltIn {
|
||||
t.Errorf("saved %+v err %v", saved, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGrants(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
locked bool
|
||||
grants []role.Grant
|
||||
want error
|
||||
}{
|
||||
{"valid scoped and toggle", false, []role.Grant{{Key: "content.read", Scope: role.ScopeOwn}, {Key: "schema.view", Scope: role.ScopeAll}}, nil},
|
||||
{"unknown key", false, []role.Grant{{Key: "content.explode", Scope: role.ScopeAll}}, role.ErrUnknownPermission},
|
||||
{"scope none stored", false, []role.Grant{{Key: "content.read", Scope: role.ScopeNone}}, role.ErrInvalidScope},
|
||||
{"unknown scope", false, []role.Grant{{Key: "content.read", Scope: "some"}}, role.ErrInvalidScope},
|
||||
{"toggle with own", false, []role.Grant{{Key: "schema.view", Scope: role.ScopeOwn}}, role.ErrInvalidScope},
|
||||
{"owner-only on regular role", false, []role.Grant{{Key: "system.apply_updates", Scope: role.ScopeAll}}, role.ErrOwnerOnlyGrant},
|
||||
{"owner-only on locked owner", true, []role.Grant{{Key: "system.apply_updates", Scope: role.ScopeAll}}, nil},
|
||||
{"per-type key", false, []role.Grant{{Key: "content.read:blog", Scope: role.ScopeOwn}}, nil},
|
||||
{"malformed per-type key", false, []role.Grant{{Key: "content.read:Bad Type", Scope: role.ScopeAll}}, role.ErrUnknownPermission},
|
||||
{"per-type toggle with own", false, []role.Grant{{Key: "content.create:blog", Scope: role.ScopeOwn}}, role.ErrInvalidScope},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if err := role.ValidateGrants(c.locked, c.grants); !errors.Is(err, c.want) {
|
||||
t.Errorf("want %v, got %v", c.want, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("Built-in definitions are valid", func(t *testing.T) {
|
||||
for _, builtIn := range role.BuiltInRoles() {
|
||||
if err := role.ValidateGrants(builtIn.Locked, builtIn.Grants); err != nil {
|
||||
t.Errorf("%s: %v", builtIn.Name, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRoleAllowsKindAndOwnerOnly(t *testing.T) {
|
||||
t.Run("A toggle grant at scope own does not count", func(t *testing.T) {
|
||||
if roleWithGrant("schema.view", role.ScopeOwn).Allows("schema.view", true) {
|
||||
t.Errorf("expected false")
|
||||
}
|
||||
})
|
||||
t.Run("Owner-only grants are ignored on a role that is not locked", func(t *testing.T) {
|
||||
if roleWithGrant("system.apply_updates", role.ScopeAll).Allows("system.apply_updates", true) {
|
||||
t.Errorf("expected false")
|
||||
}
|
||||
})
|
||||
t.Run("Owner-only grants count on the locked Owner", func(t *testing.T) {
|
||||
if !role.BuiltInRoles()[0].Allows("system.apply_updates", false) {
|
||||
t.Errorf("expected true")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestParseTypedKey(t *testing.T) {
|
||||
t.Run("A valid composite key is split", func(t *testing.T) {
|
||||
base, typeKey, err := role.ParseTypedKey("content.update:blog_post-2")
|
||||
if err != nil || base != "content.update" || typeKey != "blog_post-2" {
|
||||
t.Errorf("got %q %q %v", base, typeKey, err)
|
||||
}
|
||||
})
|
||||
rejected := map[string]string{
|
||||
"no separator": "content.read",
|
||||
"empty type key": "content.read:",
|
||||
"non per-type key": "schema.view:blog",
|
||||
"unknown base": "content.explode:blog",
|
||||
"uppercase": "content.read:Blog",
|
||||
"space": "content.read:a b",
|
||||
"second colon": "content.read:a:b",
|
||||
"too long": "content.read:" + strings.Repeat("a", 100),
|
||||
}
|
||||
for name, composite := range rejected {
|
||||
t.Run("Rejects "+name, func(t *testing.T) {
|
||||
if _, _, err := role.ParseTypedKey(composite); !errors.Is(err, role.ErrUnknownPermission) {
|
||||
t.Errorf("want ErrUnknownPermission, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
t.Run("Accepts a composite key of exactly 100 characters", func(t *testing.T) {
|
||||
composite := "content.read:" + strings.Repeat("a", 87)
|
||||
if _, _, err := role.ParseTypedKey(composite); err != nil {
|
||||
t.Errorf("unexpected %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRoleAllowsFor(t *testing.T) {
|
||||
t.Run("A per-type override denies despite the wildcard", func(t *testing.T) {
|
||||
r := role.Role{Grants: []role.Grant{{Key: "content.update", Scope: role.ScopeAll}, {Key: "content.update:blog", Scope: role.ScopeOwn}}}
|
||||
if r.AllowsFor("content.update", "blog", false) {
|
||||
t.Errorf("override should deny a non-owned entry")
|
||||
}
|
||||
if !r.AllowsFor("content.update", "blog", true) {
|
||||
t.Errorf("override should allow an owned entry")
|
||||
}
|
||||
if !r.AllowsFor("content.update", "news", false) {
|
||||
t.Errorf("other types fall back to the wildcard")
|
||||
}
|
||||
})
|
||||
t.Run("A per-type override grants without a wildcard", func(t *testing.T) {
|
||||
r := role.Role{Grants: []role.Grant{{Key: "content.read:blog", Scope: role.ScopeAll}}}
|
||||
if !r.AllowsFor("content.read", "blog", false) || r.AllowsFor("content.read", "news", false) {
|
||||
t.Errorf("override should apply to blog only")
|
||||
}
|
||||
})
|
||||
t.Run("An empty type key behaves like Allows", func(t *testing.T) {
|
||||
r := roleWithGrant("content.read", role.ScopeAll)
|
||||
if !r.AllowsFor("content.read", "", false) {
|
||||
t.Errorf("expected true")
|
||||
}
|
||||
})
|
||||
t.Run("A malformed type key or non per-type key is denied", func(t *testing.T) {
|
||||
r := role.BuiltInRoles()[0]
|
||||
if r.AllowsFor("content.read", "Bad Key", true) || r.AllowsFor("schema.view", "blog", true) {
|
||||
t.Errorf("expected false")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBackfillCreatedByWithSoftDeletedArticle(t *testing.T) {
|
||||
db := openMigratedRolesDB(t)
|
||||
createUserRow(t, db, 3, "owner", nil)
|
||||
createArticleRow(t, db, 10, 0)
|
||||
if err := db.Delete(&models.Article{}, 10).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.BackfillCreatedBy(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if createdByOf(t, db, "articles", 10) != 3 {
|
||||
t.Errorf("soft-deleted article not attributed")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/role"
|
||||
"RenewCMS/internal/infrastructure/persistence/database"
|
||||
"RenewCMS/internal/infrastructure/persistence/models"
|
||||
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRoleRepositoryFailures(t *testing.T) {
|
||||
broken := func(t *testing.T, table string) *persistence.RoleRepository {
|
||||
t.Helper()
|
||||
db := openMigratedRolesDB(t)
|
||||
if err := db.Migrator().DropTable(table); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return persistence.NewRoleRepository(db)
|
||||
}
|
||||
|
||||
t.Run("List fails when the roles table is missing", func(t *testing.T) {
|
||||
if _, err := broken(t, "roles").List(); err == nil {
|
||||
t.Error("expected error")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Get distinguishes a missing role from a storage failure", func(t *testing.T) {
|
||||
repo := persistence.NewRoleRepository(openMigratedRolesDB(t))
|
||||
if _, err := repo.Get(9999); !errors.Is(err, role.ErrRoleNotFound) {
|
||||
t.Errorf("want ErrRoleNotFound, got %v", err)
|
||||
}
|
||||
if _, err := broken(t, "roles").Get(1); err == nil || errors.Is(err, role.ErrRoleNotFound) {
|
||||
t.Errorf("want storage error, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GetByName reports a missing role", func(t *testing.T) {
|
||||
repo := persistence.NewRoleRepository(openMigratedRolesDB(t))
|
||||
if _, err := repo.GetByName("Nobody"); !errors.Is(err, role.ErrRoleNotFound) {
|
||||
t.Errorf("want ErrRoleNotFound, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ListGrants and role loading fail when grants cannot be read", func(t *testing.T) {
|
||||
repo := broken(t, "role_permissions")
|
||||
if _, err := repo.ListGrants(1); err == nil {
|
||||
t.Error("ListGrants: expected error")
|
||||
}
|
||||
if _, err := repo.Get(1); err == nil {
|
||||
t.Error("Get: expected error")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Create persists grants and returns them", func(t *testing.T) {
|
||||
db := openMigratedRolesDB(t)
|
||||
repo := persistence.NewRoleRepository(db)
|
||||
created, err := repo.Create(role.Role{Name: "Contributor", Version: 1, Grants: []role.Grant{{Key: "content.read", Scope: role.ScopeAll}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(created.Grants) != 1 || created.Grants[0].Key != "content.read" {
|
||||
t.Errorf("returned grants: %+v", created.Grants)
|
||||
}
|
||||
if countRows(t, db, &models.RolePermission{}) != 73 {
|
||||
t.Errorf("grant rows not stored")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Create rolls back when the grants cannot be stored", func(t *testing.T) {
|
||||
db := openMigratedRolesDB(t)
|
||||
if err := db.Migrator().DropTable("role_permissions"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repo := persistence.NewRoleRepository(db)
|
||||
before := countRows(t, db, &models.Role{})
|
||||
_, err := repo.Create(role.Role{Name: "Contributor", Version: 1, Grants: []role.Grant{{Key: "content.read", Scope: role.ScopeAll}}})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if countRows(t, db, &models.Role{}) != before {
|
||||
t.Errorf("role row not rolled back")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Create fails when the roles table is missing", func(t *testing.T) {
|
||||
if _, err := broken(t, "roles").Create(role.Role{Name: "X", Version: 1}); err == nil {
|
||||
t.Error("expected error")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Save returns the stored role with its new grants", func(t *testing.T) {
|
||||
db := openMigratedRolesDB(t)
|
||||
repo := persistence.NewRoleRepository(db)
|
||||
writer := storedRole(t, db, "Writer")
|
||||
writer.Grants = []role.Grant{{Key: "media.delete", Scope: role.ScopeOwn}}
|
||||
saved, err := repo.Save(writer)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if saved.ID != writer.ID || len(saved.Grants) != 1 || saved.Grants[0].Key != "media.delete" {
|
||||
t.Errorf("saved: %+v", saved)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Save reports a missing role and storage failures", func(t *testing.T) {
|
||||
repo := persistence.NewRoleRepository(openMigratedRolesDB(t))
|
||||
if _, err := repo.Save(role.Role{ID: 9999, Name: "Ghost", Version: 1}); !errors.Is(err, role.ErrRoleNotFound) {
|
||||
t.Errorf("want ErrRoleNotFound, got %v", err)
|
||||
}
|
||||
db := openMigratedRolesDB(t)
|
||||
writer := storedRole(t, db, "Writer")
|
||||
if err := db.Migrator().DropTable("role_permissions"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := persistence.NewRoleRepository(db).Save(writer); err == nil {
|
||||
t.Error("expected storage error")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBackfillUserRoleIDsWithoutBuiltInRoles(t *testing.T) {
|
||||
db := openUnmigratedSQLite(t)
|
||||
if err := db.AutoMigrate(&models.Role{}, &models.RolePermission{}, &models.User{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
createUserRow(t, db, 1, "owner", nil)
|
||||
if err := database.BackfillUserRoleIDs(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if userRow(t, db, 1).RoleID != nil {
|
||||
t.Errorf("role_id must stay unset when no built-in role exists")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/role"
|
||||
"slices"
|
||||
)
|
||||
|
||||
const (
|
||||
ownerRoleID uint32 = 1
|
||||
adminRoleID uint32 = 2
|
||||
writerRoleID uint32 = 3
|
||||
)
|
||||
|
||||
type mockRoleRepo struct {
|
||||
roles map[uint32]role.Role
|
||||
nextID uint32
|
||||
fail failSwitch
|
||||
}
|
||||
|
||||
func newMockRoleRepo() *mockRoleRepo {
|
||||
return &mockRoleRepo{roles: map[uint32]role.Role{}}
|
||||
}
|
||||
|
||||
func newSeededMockRoleRepo() *mockRoleRepo {
|
||||
repo := newMockRoleRepo()
|
||||
for _, builtIn := range role.BuiltInRoles() {
|
||||
_, _ = repo.Create(builtIn)
|
||||
}
|
||||
return repo
|
||||
}
|
||||
|
||||
func (m *mockRoleRepo) byName(name string) role.Role {
|
||||
found, _ := m.GetByName(name)
|
||||
return found
|
||||
}
|
||||
|
||||
func (m *mockRoleRepo) sortedIDs() []uint32 {
|
||||
ids := make([]uint32, 0, len(m.roles))
|
||||
for id := range m.roles {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
slices.Sort(ids)
|
||||
return ids
|
||||
}
|
||||
|
||||
func (m *mockRoleRepo) List() ([]role.Role, error) {
|
||||
if err := m.fail.trigger("List"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
listed := []role.Role{}
|
||||
for _, id := range m.sortedIDs() {
|
||||
listed = append(listed, m.roles[id])
|
||||
}
|
||||
return listed, nil
|
||||
}
|
||||
|
||||
func (m *mockRoleRepo) Get(id uint32) (role.Role, error) {
|
||||
if err := m.fail.trigger("Get"); err != nil {
|
||||
return role.Role{}, err
|
||||
}
|
||||
found, ok := m.roles[id]
|
||||
if !ok {
|
||||
return role.Role{}, role.ErrRoleNotFound
|
||||
}
|
||||
return found, nil
|
||||
}
|
||||
|
||||
func (m *mockRoleRepo) GetByName(name string) (role.Role, error) {
|
||||
if err := m.fail.trigger("GetByName"); err != nil {
|
||||
return role.Role{}, err
|
||||
}
|
||||
var match *role.Role
|
||||
for _, id := range m.sortedIDs() {
|
||||
candidate := m.roles[id]
|
||||
if candidate.Name == name && (match == nil || candidate.BuiltIn && !match.BuiltIn) {
|
||||
match = &candidate
|
||||
}
|
||||
}
|
||||
if match == nil {
|
||||
return role.Role{}, role.ErrRoleNotFound
|
||||
}
|
||||
return *match, nil
|
||||
}
|
||||
|
||||
func (m *mockRoleRepo) ListGrants(roleID uint32) ([]role.Grant, error) {
|
||||
if err := m.fail.trigger("ListGrants"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
found, ok := m.roles[roleID]
|
||||
if !ok {
|
||||
return nil, role.ErrRoleNotFound
|
||||
}
|
||||
return slices.Clone(found.Grants), nil
|
||||
}
|
||||
|
||||
func (m *mockRoleRepo) Create(created role.Role) (role.Role, error) {
|
||||
if err := m.fail.trigger("Create"); err != nil {
|
||||
return role.Role{}, err
|
||||
}
|
||||
m.nextID++
|
||||
created.ID = m.nextID
|
||||
m.roles[created.ID] = created
|
||||
return created, nil
|
||||
}
|
||||
|
||||
func (m *mockRoleRepo) Save(saved role.Role) (role.Role, error) {
|
||||
if err := m.fail.trigger("Save"); err != nil {
|
||||
return role.Role{}, err
|
||||
}
|
||||
stored, ok := m.roles[saved.ID]
|
||||
if !ok {
|
||||
return role.Role{}, role.ErrRoleNotFound
|
||||
}
|
||||
if stored.Version != saved.Version {
|
||||
return role.Role{}, role.ErrRoleConflict
|
||||
}
|
||||
stored.Name = saved.Name
|
||||
stored.Description = saved.Description
|
||||
stored.Version++
|
||||
stored.Grants = slices.Clone(saved.Grants)
|
||||
m.roles[saved.ID] = stored
|
||||
return stored, nil
|
||||
}
|
||||
|
||||
var _ role.Repository = &mockRoleRepo{}
|
||||
@@ -0,0 +1,329 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
"RenewCMS/internal/domain/role"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type accessBody struct {
|
||||
Role struct {
|
||||
ID uint32 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
BuiltIn bool `json:"built_in"`
|
||||
Locked bool `json:"locked"`
|
||||
} `json:"role"`
|
||||
Permissions []role.Grant `json:"permissions"`
|
||||
}
|
||||
|
||||
type meBody struct {
|
||||
ID uint32 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
IsVerified bool `json:"is_verified"`
|
||||
Role string `json:"role"`
|
||||
Author any `json:"author"`
|
||||
Access accessBody `json:"access"`
|
||||
}
|
||||
|
||||
func (e *apiKeyEnv) addUser(id uint32, legacy user.Role, roleID uint32, verified bool) {
|
||||
e.users.users[id] = user.User{ID: id, Username: "user" + string(rune('a'+id)), Email: "user" + string(rune('a'+id)) + "@example.com", IsVerified: verified, Role: legacy, RoleID: roleID}
|
||||
}
|
||||
|
||||
func (e *apiKeyEnv) me(t *testing.T, id uint32) (*httptest.ResponseRecorder, meBody) {
|
||||
t.Helper()
|
||||
rec := e.withCookie(t, http.MethodGet, "/v1/auth/me", "", id)
|
||||
var body meBody
|
||||
if rec.Code == http.StatusOK {
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode %q: %v", rec.Body.String(), err)
|
||||
}
|
||||
}
|
||||
return rec, body
|
||||
}
|
||||
|
||||
func permissionKeys(grants []role.Grant) []string {
|
||||
keys := []string{}
|
||||
for _, grant := range grants {
|
||||
keys = append(keys, grant.Key)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
func TestRolesPermissionsFoundationHTTP(t *testing.T) {
|
||||
t.Run("The owner's /me carries the Owner role and all 34 permissions", func(t *testing.T) {
|
||||
e := newAPIKeyEnv(t)
|
||||
e.addUser(7, user.RoleOwner, ownerRoleID, true)
|
||||
rec, body := e.me(t, 7)
|
||||
if rec.Code != http.StatusOK || rec.Header().Get("Cache-Control") != "no-store" {
|
||||
t.Fatalf("status %d cache %q", rec.Code, rec.Header().Get("Cache-Control"))
|
||||
}
|
||||
if body.ID != 7 || body.Role != "owner" || !body.IsVerified {
|
||||
t.Errorf("existing fields changed: %+v", body)
|
||||
}
|
||||
if body.Access.Role.ID != ownerRoleID || body.Access.Role.Name != "Owner" || !body.Access.Role.BuiltIn || !body.Access.Role.Locked {
|
||||
t.Errorf("role %+v", body.Access.Role)
|
||||
}
|
||||
want := []string{}
|
||||
for _, permission := range role.Catalogue() {
|
||||
want = append(want, permission.Key)
|
||||
}
|
||||
if !slices.Equal(permissionKeys(body.Access.Permissions), want) {
|
||||
t.Errorf("permissions %v", permissionKeys(body.Access.Permissions))
|
||||
}
|
||||
for _, grant := range body.Access.Permissions {
|
||||
if grant.Scope != role.ScopeAll {
|
||||
t.Errorf("%s scope %s", grant.Key, grant.Scope)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("An admin's /me omits the owner-only keys", func(t *testing.T) {
|
||||
e := newAPIKeyEnv(t)
|
||||
e.addUser(8, user.RoleAdmin, adminRoleID, true)
|
||||
_, body := e.me(t, 8)
|
||||
if body.Access.Role.Name != "Admin" || !body.Access.Role.BuiltIn || body.Access.Role.Locked {
|
||||
t.Errorf("role %+v", body.Access.Role)
|
||||
}
|
||||
if len(body.Access.Permissions) != 29 {
|
||||
t.Fatalf("expected 29 permissions, got %d", len(body.Access.Permissions))
|
||||
}
|
||||
scopes := grantMap(body.Access.Permissions)
|
||||
for _, permission := range role.Catalogue() {
|
||||
if _, present := scopes[permission.Key]; present == permission.OwnerOnly {
|
||||
t.Errorf("%s presence %v", permission.Key, present)
|
||||
}
|
||||
}
|
||||
if scopes["roles.view"] != role.ScopeAll || scopes["roles.manage"] != role.ScopeAll {
|
||||
t.Errorf("roles keys missing")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A writer's /me lists only granted keys with their scope", func(t *testing.T) {
|
||||
e := newAPIKeyEnv(t)
|
||||
e.addUser(9, user.RoleAdmin, writerRoleID, true)
|
||||
_, body := e.me(t, 9)
|
||||
want := []role.Grant{
|
||||
{Key: "content.read", Scope: role.ScopeAll}, {Key: "content.create", Scope: role.ScopeAll},
|
||||
{Key: "content.update", Scope: role.ScopeAll}, {Key: "schema.view", Scope: role.ScopeAll},
|
||||
{Key: "media.view", Scope: role.ScopeAll}, {Key: "media.upload", Scope: role.ScopeAll},
|
||||
{Key: "authors.view", Scope: role.ScopeAll}, {Key: "authors.edit", Scope: role.ScopeOwn},
|
||||
{Key: "activity.view", Scope: role.ScopeOwn},
|
||||
}
|
||||
if !slices.Equal(body.Access.Permissions, want) {
|
||||
t.Errorf("permissions %v", body.Access.Permissions)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A user on a custom role sees that role", func(t *testing.T) {
|
||||
e := newAPIKeyEnv(t)
|
||||
custom, _ := e.roles.Create(role.Role{Name: "Contributor", Version: 1, Grants: []role.Grant{
|
||||
{Key: "content.update", Scope: role.ScopeOwn}, {Key: "content.read", Scope: role.ScopeAll},
|
||||
}})
|
||||
e.addUser(10, user.RoleAdmin, custom.ID, true)
|
||||
_, body := e.me(t, 10)
|
||||
want := []role.Grant{{Key: "content.read", Scope: role.ScopeAll}, {Key: "content.update", Scope: role.ScopeOwn}}
|
||||
if body.Access.Role.Name != "Contributor" || body.Access.Role.BuiltIn || !slices.Equal(body.Access.Permissions, want) {
|
||||
t.Errorf("body %+v", body.Access)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A user with no role_id falls back to the built-in role named by the legacy role", func(t *testing.T) {
|
||||
for legacy, name := range map[user.Role]string{user.RoleOwner: "Owner", user.RoleAdmin: "Admin"} {
|
||||
e := newAPIKeyEnv(t)
|
||||
e.addUser(11, legacy, 0, true)
|
||||
_, body := e.me(t, 11)
|
||||
if body.Access.Role.Name != name {
|
||||
t.Errorf("%s: got %q", legacy, body.Access.Role.Name)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The permissions follow the role live", func(t *testing.T) {
|
||||
e := newAPIKeyEnv(t)
|
||||
e.addUser(9, user.RoleAdmin, writerRoleID, true)
|
||||
writer := e.roles.byName("Writer")
|
||||
writer.Grants = append(writer.Grants, role.Grant{Key: "media.delete", Scope: role.ScopeOwn})
|
||||
_, _ = e.roles.Save(writer)
|
||||
_, body := e.me(t, 9)
|
||||
if grantMap(body.Access.Permissions)["media.delete"] != role.ScopeOwn {
|
||||
t.Errorf("permissions %v", body.Access.Permissions)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("An unverified user still gets /me, with access", func(t *testing.T) {
|
||||
e := newAPIKeyEnv(t)
|
||||
e.addUser(12, user.RoleAdmin, writerRoleID, false)
|
||||
rec, body := e.me(t, 12)
|
||||
if rec.Code != http.StatusOK || body.IsVerified || body.Access.Role.Name != "Writer" {
|
||||
t.Errorf("status %d body %+v", rec.Code, body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A user whose role cannot be resolved gets a server error, not an empty access block", func(t *testing.T) {
|
||||
e := newAPIKeyEnv(t)
|
||||
e.addUser(13, "", 99, true)
|
||||
rec, _ := e.me(t, 13)
|
||||
if rec.Code != http.StatusInternalServerError || rec.Body.String() != "Internal server error\n" {
|
||||
t.Errorf("status %d body %q", rec.Code, rec.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The existing /me failures are unchanged", func(t *testing.T) {
|
||||
e := newAPIKeyEnv(t)
|
||||
if rec := e.anonymous(t, http.MethodGet, "/v1/auth/me", ""); rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("no cookie: %d", rec.Code)
|
||||
}
|
||||
e.addUser(7, user.RoleOwner, ownerRoleID, true)
|
||||
stale := e.users.users[7]
|
||||
stale.TokenVersion = 1
|
||||
e.users.users[7] = stale
|
||||
rec := e.withCookie(t, http.MethodGet, "/v1/auth/me", "", 7)
|
||||
if rec.Code != http.StatusUnauthorized || rec.Body.String() != apperr.UnauthorizedErrorMessage+"\n" {
|
||||
t.Errorf("stale token: %d %q", rec.Code, rec.Body.String())
|
||||
}
|
||||
delete(e.users.users, 7)
|
||||
rec = e.withCookie(t, http.MethodGet, "/v1/auth/me", "", 7)
|
||||
if rec.Code != http.StatusUnauthorized || rec.Body.String() != apperr.UnauthorizedErrorMessage+"\n" {
|
||||
t.Errorf("deleted account: %d %q", rec.Code, rec.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPermissionCatalogueHTTP(t *testing.T) {
|
||||
type item struct {
|
||||
Key string `json:"key"`
|
||||
ScopeKind string `json:"scope_kind"`
|
||||
OwnerOnly bool `json:"owner_only"`
|
||||
PerType bool `json:"per_type"`
|
||||
}
|
||||
type catalogueBody struct {
|
||||
Groups []struct {
|
||||
Key string `json:"key"`
|
||||
Items []item `json:"items"`
|
||||
} `json:"groups"`
|
||||
}
|
||||
|
||||
t.Run("A signed-in verified user reads the catalogue", func(t *testing.T) {
|
||||
e := newAPIKeyEnv(t)
|
||||
e.addUser(9, user.RoleAdmin, writerRoleID, true)
|
||||
rec := e.withCookie(t, http.MethodGet, "/v1/permission", "", 9)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var body catalogueBody
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
groups := []string{}
|
||||
total := 0
|
||||
for _, group := range body.Groups {
|
||||
groups = append(groups, group.Key)
|
||||
for _, found := range group.Items {
|
||||
total++
|
||||
permission, err := role.Lookup(found.Key)
|
||||
if err != nil {
|
||||
t.Fatalf("unknown key %s", found.Key)
|
||||
}
|
||||
wantKind := "scoped"
|
||||
if permission.Kind == role.KindToggle {
|
||||
wantKind = "toggle"
|
||||
}
|
||||
if found.ScopeKind != wantKind || found.OwnerOnly != permission.OwnerOnly {
|
||||
t.Errorf("item %+v", found)
|
||||
}
|
||||
if found.PerType != (group.Key == "content") {
|
||||
t.Errorf("per_type of %s: %v", found.Key, found.PerType)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !slices.Equal(groups, []string{"content", "schema", "media", "authors", "activity", "users", "settings", "roles", "system"}) || total != 34 {
|
||||
t.Errorf("groups %v total %d", groups, total)
|
||||
}
|
||||
if body.Groups[0].Items[0] != (item{Key: "content.read", ScopeKind: "scoped", PerType: true}) {
|
||||
t.Errorf("first item %+v", body.Groups[0].Items[0])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The catalogue is the same for every role", func(t *testing.T) {
|
||||
e := newAPIKeyEnv(t)
|
||||
e.addUser(9, user.RoleAdmin, writerRoleID, true)
|
||||
e.addUser(7, user.RoleOwner, ownerRoleID, true)
|
||||
writer := e.withCookie(t, http.MethodGet, "/v1/permission", "", 9)
|
||||
owner := e.withCookie(t, http.MethodGet, "/v1/permission", "", 7)
|
||||
if writer.Body.String() != owner.Body.String() || writer.Body.Len() == 0 {
|
||||
t.Errorf("bodies differ")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("An API key may read the catalogue", func(t *testing.T) {
|
||||
e := newAPIKeyEnv(t)
|
||||
raw, _ := e.issueKey(apiKeyDevId, "reader")
|
||||
if rec := e.withKey(t, http.MethodGet, "/v1/permission", "", raw); rec.Code != http.StatusOK {
|
||||
t.Errorf("status %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("No cookie is refused", func(t *testing.T) {
|
||||
e := newAPIKeyEnv(t)
|
||||
if rec := e.anonymous(t, http.MethodGet, "/v1/permission", ""); rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("status %d", rec.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A stale token is refused", func(t *testing.T) {
|
||||
e := newAPIKeyEnv(t)
|
||||
e.addUser(7, user.RoleOwner, ownerRoleID, true)
|
||||
stored := e.users.users[7]
|
||||
stored.TokenVersion = 1
|
||||
e.users.users[7] = stored
|
||||
rec := e.withCookie(t, http.MethodGet, "/v1/permission", "", 7)
|
||||
if rec.Code != http.StatusUnauthorized || rec.Body.String() != apperr.UnauthorizedErrorMessage+"\n" {
|
||||
t.Errorf("status %d body %q", rec.Code, rec.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("An unverified user is refused", func(t *testing.T) {
|
||||
e := newAPIKeyEnv(t)
|
||||
e.addUser(12, user.RoleAdmin, writerRoleID, false)
|
||||
rec := e.withCookie(t, http.MethodGet, "/v1/permission", "", 12)
|
||||
if rec.Code != http.StatusForbidden || rec.Body.String() != apperr.VerificationRequiredErrorMessage+"\n" {
|
||||
t.Errorf("status %d body %q", rec.Code, rec.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestExistingRoleChecksAreUntouched(t *testing.T) {
|
||||
t.Run("Existing role checks are untouched in this slice", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
user uint32
|
||||
method string
|
||||
path string
|
||||
body string
|
||||
denied bool
|
||||
}{
|
||||
{"owner invites", apiKeyOwnerId, http.MethodPost, "/v1/user/invite", `{"email":"new@example.com"}`, false},
|
||||
{"admin invites", apiKeyDevId, http.MethodPost, "/v1/user/invite", `{"email":"new@example.com"}`, true},
|
||||
{"admin creates a content type", apiKeyDevId, http.MethodPost, "/v1/content-type", `{}`, false},
|
||||
{"admin patches settings", apiKeyDevId, http.MethodPatch, "/v1/settings", `{}`, true},
|
||||
{"admin checks for updates", apiKeyDevId, http.MethodPost, "/v1/system/update/check", "", true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
e := newAPIKeyEnv(t)
|
||||
rec := e.withCookie(t, c.method, c.path, c.body, c.user)
|
||||
if c.denied && (rec.Code != http.StatusForbidden || rec.Body.String() != apperr.OwnerRequiredErrorMessage+"\n") {
|
||||
t.Errorf("expected owner-required 403, got %d %q", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !c.denied && rec.Code == http.StatusForbidden {
|
||||
t.Errorf("unexpected 403 %q", rec.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/role"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"RenewCMS/internal/infrastructure/persistence/database"
|
||||
"RenewCMS/internal/infrastructure/persistence/models"
|
||||
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"errors"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func roleWithGrant(key string, scope role.Scope) role.Role {
|
||||
return role.Role{Name: "Probe", Grants: []role.Grant{{Key: key, Scope: scope}}}
|
||||
}
|
||||
|
||||
func TestRoleAllows(t *testing.T) {
|
||||
cases := []struct {
|
||||
key string
|
||||
scope role.Scope
|
||||
owned bool
|
||||
allowed bool
|
||||
}{
|
||||
{"content.update", role.ScopeAll, false, true},
|
||||
{"content.update", role.ScopeAll, true, true},
|
||||
{"content.update", role.ScopeOwn, true, true},
|
||||
{"content.update", role.ScopeOwn, false, false},
|
||||
{"content.update", role.ScopeNone, true, false},
|
||||
{"content.create", role.ScopeAll, false, true},
|
||||
{"schema.view", role.ScopeNone, false, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(string(c.scope)+" "+c.key, func(t *testing.T) {
|
||||
if got := roleWithGrant(c.key, c.scope).Allows(c.key, c.owned); got != c.allowed {
|
||||
t.Errorf("owned=%v: expected %v, got %v", c.owned, c.allowed, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("A key absent from the role is not allowed", func(t *testing.T) {
|
||||
if (role.Role{}).Allows("content.read", true) {
|
||||
t.Errorf("expected false")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("An unknown key is rejected, never silently allowed", func(t *testing.T) {
|
||||
everything := role.BuiltInRoles()[0]
|
||||
everything.Grants = append(everything.Grants, role.Grant{Key: "content.explode", Scope: role.ScopeAll})
|
||||
if everything.Allows("content.explode", true) {
|
||||
t.Errorf("expected false")
|
||||
}
|
||||
if _, err := role.Lookup("content.explode"); !errors.Is(err, role.ErrUnknownPermission) {
|
||||
t.Errorf("expected ErrUnknownPermission, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRoleCatalogue(t *testing.T) {
|
||||
keys := []string{
|
||||
"content.read", "content.create", "content.update", "content.delete", "content.publish",
|
||||
"schema.view", "schema.edit", "schema.delete",
|
||||
"media.view", "media.upload", "media.delete",
|
||||
"authors.view", "authors.create", "authors.edit", "authors.delete",
|
||||
"activity.view", "activity.export",
|
||||
"users.view", "users.invite", "users.change_role", "users.remove",
|
||||
"settings.view", "settings.edit_general", "settings.manage_sites", "settings.delete_site",
|
||||
"apikeys.view", "apikeys.create", "apikeys.revoke",
|
||||
"roles.view", "roles.manage", "roles.edit_owner_only",
|
||||
"system.apply_updates", "system.edit_retention", "system.transfer_ownership",
|
||||
}
|
||||
|
||||
t.Run("The catalogue is stable and complete", func(t *testing.T) {
|
||||
catalogue := role.Catalogue()
|
||||
got := []string{}
|
||||
ownerOnly := []string{}
|
||||
scoped := []string{}
|
||||
for _, permission := range catalogue {
|
||||
got = append(got, permission.Key)
|
||||
if permission.OwnerOnly {
|
||||
ownerOnly = append(ownerOnly, permission.Key)
|
||||
}
|
||||
if permission.Kind == role.KindScoped {
|
||||
scoped = append(scoped, permission.Key)
|
||||
}
|
||||
if permission.Group == "content" && permission.Kind == role.KindToggle && permission.Key != "content.create" {
|
||||
t.Errorf("unexpected toggle %s", permission.Key)
|
||||
}
|
||||
}
|
||||
if !slices.Equal(got, keys) {
|
||||
t.Errorf("catalogue order differs: %v", got)
|
||||
}
|
||||
wantOwnerOnly := []string{"settings.delete_site", "roles.edit_owner_only", "system.apply_updates", "system.edit_retention", "system.transfer_ownership"}
|
||||
if !slices.Equal(ownerOnly, wantOwnerOnly) {
|
||||
t.Errorf("owner-only keys: %v", ownerOnly)
|
||||
}
|
||||
wantScoped := []string{"content.read", "content.update", "content.delete", "content.publish", "media.delete", "authors.edit", "activity.view", "apikeys.view", "apikeys.revoke"}
|
||||
if !slices.Equal(scoped, wantScoped) {
|
||||
t.Errorf("scoped keys: %v", scoped)
|
||||
}
|
||||
wantGroups := []string{"content", "schema", "media", "authors", "activity", "users", "settings", "roles", "system"}
|
||||
if !slices.Equal(role.Groups(), wantGroups) {
|
||||
t.Errorf("groups: %v", role.Groups())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func grantMap(grants []role.Grant) map[string]role.Scope {
|
||||
scopes := map[string]role.Scope{}
|
||||
for _, grant := range grants {
|
||||
scopes[grant.Key] = grant.Scope
|
||||
}
|
||||
return scopes
|
||||
}
|
||||
|
||||
func openMigratedRolesDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
db := openUnmigratedSQLite(t)
|
||||
if err := database.Migrate(db, migrationPublicURL); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func storedRole(t *testing.T, db *gorm.DB, name string) role.Role {
|
||||
t.Helper()
|
||||
found, err := persistence.NewRoleRepository(db).GetByName(name)
|
||||
if err != nil {
|
||||
t.Fatalf("role %s: %v", name, err)
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
func countRows(t *testing.T, db *gorm.DB, model any) int64 {
|
||||
t.Helper()
|
||||
var total int64
|
||||
if err := db.Model(model).Count(&total).Error; err != nil {
|
||||
t.Fatalf("count: %v", err)
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
func TestSeedBuiltInRoles(t *testing.T) {
|
||||
t.Run("Seeding an empty database creates the three built-in roles", func(t *testing.T) {
|
||||
db := openMigratedRolesDB(t)
|
||||
owner, admin, writer := storedRole(t, db, "Owner"), storedRole(t, db, "Admin"), storedRole(t, db, "Writer")
|
||||
for _, found := range []role.Role{owner, admin, writer} {
|
||||
if !found.BuiltIn || found.Version != 1 {
|
||||
t.Errorf("%s: built_in %v version %d", found.Name, found.BuiltIn, found.Version)
|
||||
}
|
||||
}
|
||||
if !owner.Locked || admin.Locked || writer.Locked {
|
||||
t.Errorf("only Owner is locked")
|
||||
}
|
||||
if len(owner.Grants) != 34 || len(admin.Grants) != 29 || len(writer.Grants) != 9 {
|
||||
t.Fatalf("grant counts %d %d %d", len(owner.Grants), len(admin.Grants), len(writer.Grants))
|
||||
}
|
||||
for _, grant := range append(slices.Clone(owner.Grants), admin.Grants...) {
|
||||
if grant.Scope != role.ScopeAll {
|
||||
t.Errorf("%s not at all", grant.Key)
|
||||
}
|
||||
}
|
||||
for _, grant := range admin.Grants {
|
||||
if permission, _ := role.Lookup(grant.Key); permission.OwnerOnly {
|
||||
t.Errorf("admin holds owner-only %s", grant.Key)
|
||||
}
|
||||
}
|
||||
scopes := grantMap(writer.Grants)
|
||||
if scopes["authors.edit"] != role.ScopeOwn || scopes["activity.view"] != role.ScopeOwn || scopes["content.create"] != role.ScopeAll {
|
||||
t.Errorf("writer grants %v", scopes)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Seeding twice changes nothing", func(t *testing.T) {
|
||||
db := openMigratedRolesDB(t)
|
||||
ownerID := storedRole(t, db, "Owner").ID
|
||||
if err := database.SeedBuiltInRoles(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if countRows(t, db, &models.Role{}) != 3 || countRows(t, db, &models.RolePermission{}) != 72 || storedRole(t, db, "Owner").ID != ownerID {
|
||||
t.Errorf("seeding was not idempotent")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Seeding never overwrites an edited built-in role", func(t *testing.T) {
|
||||
db := openMigratedRolesDB(t)
|
||||
writer := storedRole(t, db, "Writer")
|
||||
if err := db.Model(&models.Role{}).Where("id = ?", writer.ID).Update("version", 4).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&models.RolePermission{RoleID: writer.ID, Key: "media.delete", Scope: "own"}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.SeedBuiltInRoles(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after := storedRole(t, db, "Writer")
|
||||
if after.Version != 4 || grantMap(after.Grants)["media.delete"] != role.ScopeOwn {
|
||||
t.Errorf("edited writer overwritten: %+v", after)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Seeding never touches a custom role that reuses a built-in name", func(t *testing.T) {
|
||||
db := openUnmigratedSQLite(t)
|
||||
if err := db.AutoMigrate(&models.Role{}, &models.RolePermission{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
custom := models.Role{Name: "Writer", Version: 7}
|
||||
if err := db.Create(&custom).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.SeedBuiltInRoles(db); err != nil {
|
||||
t.Fatalf("startup aborted: %v", err)
|
||||
}
|
||||
var kept models.Role
|
||||
if err := db.First(&kept, custom.ID).Error; err != nil || kept.BuiltIn || kept.Version != 7 {
|
||||
t.Errorf("custom row changed: %+v %v", kept, err)
|
||||
}
|
||||
if found := storedRole(t, db, "Writer"); !found.BuiltIn || found.ID == custom.ID {
|
||||
t.Errorf("built-in writer missing: %+v", found)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func createUserRow(t *testing.T, db *gorm.DB, id uint32, legacy string, roleID *uint32) {
|
||||
t.Helper()
|
||||
row := models.User{ID: id, Username: "u" + legacy + string(rune('a'+id)), Email: legacy + string(rune('a'+id)) + "@x.example", Password: "x", Role: legacy, RoleID: roleID}
|
||||
if err := db.Create(&row).Error; err != nil {
|
||||
t.Fatalf("user %d: %v", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
func userRow(t *testing.T, db *gorm.DB, id uint32) models.User {
|
||||
t.Helper()
|
||||
var row models.User
|
||||
if err := db.First(&row, id).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
func roleIDOf(row models.User) uint32 {
|
||||
if row.RoleID == nil {
|
||||
return 0
|
||||
}
|
||||
return *row.RoleID
|
||||
}
|
||||
|
||||
func TestBackfillUserRoleIDs(t *testing.T) {
|
||||
t.Run("Existing owner and admin users get the matching built-in role", func(t *testing.T) {
|
||||
db := openMigratedRolesDB(t)
|
||||
createUserRow(t, db, 3, "owner", nil)
|
||||
createUserRow(t, db, 5, "admin", nil)
|
||||
createUserRow(t, db, 9, "admin", nil)
|
||||
if err := database.BackfillUserRoleIDs(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ownerID, adminID := storedRole(t, db, "Owner").ID, storedRole(t, db, "Admin").ID
|
||||
if roleIDOf(userRow(t, db, 3)) != ownerID || roleIDOf(userRow(t, db, 5)) != adminID || roleIDOf(userRow(t, db, 9)) != adminID {
|
||||
t.Errorf("role ids not backfilled")
|
||||
}
|
||||
if userRow(t, db, 3).Role != "owner" || userRow(t, db, 5).Role != "admin" {
|
||||
t.Errorf("legacy role changed")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The backfill runs after BackfillUserRoles on a database that predates both", func(t *testing.T) {
|
||||
db := openMigratedRolesDB(t)
|
||||
createUserRow(t, db, 2, "", nil)
|
||||
createUserRow(t, db, 4, "", nil)
|
||||
if err := database.Migrate(db, migrationPublicURL); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
two, four := userRow(t, db, 2), userRow(t, db, 4)
|
||||
if two.Role != "owner" || roleIDOf(two) != storedRole(t, db, "Owner").ID {
|
||||
t.Errorf("user 2: %+v", two)
|
||||
}
|
||||
if four.Role != "admin" || roleIDOf(four) != storedRole(t, db, "Admin").ID {
|
||||
t.Errorf("user 4: %+v", four)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The role_id backfill is idempotent and respects an existing role_id", func(t *testing.T) {
|
||||
db := openMigratedRolesDB(t)
|
||||
custom, err := persistence.NewRoleRepository(db).Create(role.Role{Name: "Contributor", Version: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
createUserRow(t, db, 5, "admin", &custom.ID)
|
||||
for range 2 {
|
||||
if err := database.BackfillUserRoleIDs(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if roleIDOf(userRow(t, db, 5)) != custom.ID {
|
||||
t.Errorf("custom role replaced")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A database with no users is left alone", func(t *testing.T) {
|
||||
if err := database.BackfillUserRoleIDs(openMigratedRolesDB(t)); err != nil {
|
||||
t.Errorf("unexpected error %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func createdByOf(t *testing.T, db *gorm.DB, table string, id uint32) uint32 {
|
||||
t.Helper()
|
||||
var value uint32
|
||||
if err := db.Table(table).Unscoped().Select("created_by").Where("id = ?", id).Scan(&value).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func createArticleRow(t *testing.T, db *gorm.DB, id uint32, createdBy uint32) {
|
||||
t.Helper()
|
||||
if err := db.Create(&models.Article{ID: id, Title: "Article", Slug: "a-" + string(rune('a'+id)), CreatedBy: createdBy, SiteID: 1}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func createEntryRow(t *testing.T, db *gorm.DB, id uint32, createdBy uint32) {
|
||||
t.Helper()
|
||||
if err := db.Create(&models.Entry{ID: id, ContentTypeID: 1, Data: "{}", CreatedBy: createdBy}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackfillCreatedBy(t *testing.T) {
|
||||
t.Run("Existing articles are attributed to the first owner", func(t *testing.T) {
|
||||
db := openMigratedRolesDB(t)
|
||||
createUserRow(t, db, 3, "owner", nil)
|
||||
createUserRow(t, db, 4, "owner", nil)
|
||||
createArticleRow(t, db, 10, 0)
|
||||
createArticleRow(t, db, 11, 0)
|
||||
if err := database.BackfillCreatedBy(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if createdByOf(t, db, "articles", 10) != 3 || createdByOf(t, db, "articles", 11) != 3 {
|
||||
t.Errorf("articles not attributed")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Entries without an author are attributed to the first owner", func(t *testing.T) {
|
||||
db := openMigratedRolesDB(t)
|
||||
createUserRow(t, db, 3, "owner", nil)
|
||||
createEntryRow(t, db, 20, 0)
|
||||
createEntryRow(t, db, 21, 7)
|
||||
if err := database.BackfillCreatedBy(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if createdByOf(t, db, "entries", 20) != 3 || createdByOf(t, db, "entries", 21) != 7 {
|
||||
t.Errorf("entries wrong")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The created_by backfill never rewrites an existing value, and is idempotent", func(t *testing.T) {
|
||||
db := openMigratedRolesDB(t)
|
||||
createUserRow(t, db, 3, "owner", nil)
|
||||
createArticleRow(t, db, 10, 7)
|
||||
for range 2 {
|
||||
if err := database.BackfillCreatedBy(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if createdByOf(t, db, "articles", 10) != 7 {
|
||||
t.Errorf("existing value rewritten")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("With no owner yet the backfill does nothing", func(t *testing.T) {
|
||||
db := openMigratedRolesDB(t)
|
||||
createArticleRow(t, db, 10, 0)
|
||||
if err := database.BackfillCreatedBy(db); err != nil {
|
||||
t.Fatalf("unexpected error %v", err)
|
||||
}
|
||||
if createdByOf(t, db, "articles", 10) != 0 {
|
||||
t.Errorf("created_by changed")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestNewArticlesRecordTheirCreator(t *testing.T) {
|
||||
t.Run("New articles record their creator", func(t *testing.T) {
|
||||
repo := newMockArticleRepo()
|
||||
uc := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors())
|
||||
created, err := uc.CreateArticle(testCtx, useCases.CreateArticleCommand{Title: "Valid Title", Body: "x", CreatedBy: 7})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if repo.articles[created.ID].CreatedBy != 7 {
|
||||
t.Errorf("stored created_by %d", repo.articles[created.ID].CreatedBy)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetMyAccessUseCase(t *testing.T) {
|
||||
t.Run("A user with no role_id falls back to the built-in role named by the legacy role", func(t *testing.T) {
|
||||
uc := useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo())
|
||||
for legacy, name := range map[user.Role]string{user.RoleOwner: "Owner", user.RoleAdmin: "Admin"} {
|
||||
access, err := uc.Get(user.User{ID: 11, Role: legacy})
|
||||
if err != nil || access.Role.Name != name {
|
||||
t.Errorf("%s: %v %v", legacy, access.Role.Name, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A user whose role cannot be resolved gets an error", func(t *testing.T) {
|
||||
uc := useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo())
|
||||
if _, err := uc.Get(user.User{ID: 13, RoleID: 99}); !errors.Is(err, role.ErrRoleNotFound) {
|
||||
t.Errorf("expected ErrRoleNotFound, got %v", err)
|
||||
}
|
||||
if _, err := uc.Get(user.User{ID: 13}); !errors.Is(err, role.ErrRoleNotFound) {
|
||||
t.Errorf("expected ErrRoleNotFound, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The fallback never picks a custom role that reuses a built-in name", func(t *testing.T) {
|
||||
repo := newMockRoleRepo()
|
||||
_, _ = repo.Create(role.Role{Name: "Owner"})
|
||||
if _, err := useCases.NewGetMyAccessUseCase(repo).Get(user.User{Role: user.RoleOwner}); !errors.Is(err, role.ErrRoleNotFound) {
|
||||
t.Errorf("expected ErrRoleNotFound, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -137,6 +137,7 @@ func newRuntimeSettingsContainer(t *testing.T) *api.UseCases {
|
||||
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
|
||||
GetUserUseCase: useCases.NewGetUserUseCase(userRepo),
|
||||
GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()),
|
||||
GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()),
|
||||
CreateUserUseCase: useCases.NewCreateUserUseCase(userRepo, hasher, mailRepo, "https://cms.example.com", discardRecorder),
|
||||
AuthenticateUserUseCase: useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder),
|
||||
CancelRegistrationUseCase: useCases.NewCancelRegistrationUseCase(userRepo),
|
||||
|
||||
@@ -176,6 +176,7 @@ func newSessionRevocationEnv(t *testing.T) *sessionRevocationEnv {
|
||||
Token: authtoken.New([]byte(testJWTSecret), authtoken.WithRevocationStore(useCases.NewSessionRevocationUseCase(sessions, time.Now))),
|
||||
GetUserUseCase: useCases.NewGetUserUseCase(userRepo),
|
||||
GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()),
|
||||
GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()),
|
||||
CreateUserUseCase: useCases.NewCreateUserUseCase(userRepo, hasher, newMockMailRepo(), "https://cms.example.com", discardRecorder),
|
||||
AuthenticateUserUseCase: useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder),
|
||||
}
|
||||
|
||||
@@ -176,7 +176,7 @@ func TestSQLiteSchemaIsUntouchedByTheModelChanges(t *testing.T) {
|
||||
if _, indexed := after.schema["idx_articles_site_slug"]; !indexed {
|
||||
t.Errorf("the unique (site_id, slug) index is missing: %v", after.schema)
|
||||
}
|
||||
for _, name := range []string{"articles", "idx_articles_site_id", "idx_articles_site_slug", "idx_articles_cover_image_id", "idx_articles_social_image_id"} {
|
||||
for _, name := range []string{"articles", "idx_articles_site_id", "idx_articles_site_slug", "idx_articles_cover_image_id", "idx_articles_social_image_id", "users", "idx_users_role_id"} {
|
||||
delete(before.schema, name)
|
||||
delete(after.schema, name)
|
||||
}
|
||||
@@ -190,13 +190,16 @@ func TestSQLiteSchemaIsUntouchedByTheModelChanges(t *testing.T) {
|
||||
for _, column := range addedArticleColumns {
|
||||
delete(after.rows["articles"][0], column)
|
||||
}
|
||||
for _, row := range after.rows["users"] {
|
||||
delete(row, "role_id")
|
||||
}
|
||||
if !reflect.DeepEqual(before.rows, after.rows) {
|
||||
t.Errorf("rows changed:\nbefore %v\nafter %v", before.rows, after.rows)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
var addedArticleColumns = []string{"site_id", "slug", "excerpt", "seo_title", "seo_description", "cover_image_id", "cover_image_alt", "social_title", "social_description", "social_image_id", "canonical_url", "noindex", "seo_dismissed"}
|
||||
var addedArticleColumns = []string{"site_id", "created_by", "slug", "excerpt", "seo_title", "seo_description", "cover_image_id", "cover_image_alt", "social_title", "social_description", "social_image_id", "canonical_url", "noindex", "seo_dismissed"}
|
||||
|
||||
func assertSiteColumnAdded(t *testing.T, db *gorm.DB) {
|
||||
t.Helper()
|
||||
@@ -216,7 +219,7 @@ func assertSiteColumnAdded(t *testing.T, db *gorm.DB) {
|
||||
}
|
||||
|
||||
func isNewTableObject(name string) bool {
|
||||
for _, prefix := range []string{"audit_", "idx_audit_", "image_variant", "idx_image_variant", "idx_images_path", "idx_sites_key", "sites", "authors", "idx_authors_", "article_authors", "idx_article_authors_", "content_types", "idx_content_types_", "entries", "idx_entries_", "revoked_sessions", "idx_revoked_sessions_"} {
|
||||
for _, prefix := range []string{"audit_", "idx_audit_", "image_variant", "idx_image_variant", "idx_images_path", "idx_sites_key", "sites", "authors", "idx_authors_", "article_authors", "idx_article_authors_", "content_types", "idx_content_types_", "entries", "idx_entries_", "revoked_sessions", "idx_revoked_sessions_", "role", "idx_role"} {
|
||||
if strings.HasPrefix(name, prefix) {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ func newDownloadHTTP(t *testing.T, rig *downloadRig) *updateHarness {
|
||||
Token: token,
|
||||
GetUserUseCase: useCases.NewGetUserUseCase(users),
|
||||
GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()),
|
||||
GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()),
|
||||
GetUpdateStatusUseCase: rig.status,
|
||||
CheckForUpdateUseCase: useCases.NewCheckForUpdateUseCase(feed, rig.store, updateRunning, now, discardRecorder),
|
||||
StartUpdateDownloadUseCase: rig.start,
|
||||
|
||||
@@ -64,6 +64,7 @@ func newUpdateHarness(t *testing.T, running string) *updateHarness {
|
||||
Token: token,
|
||||
GetUserUseCase: useCases.NewGetUserUseCase(users),
|
||||
GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()),
|
||||
GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()),
|
||||
GetUpdateStatusUseCase: newUpdateStatusUseCase(store, &fakeJobStore{}, &fakeHelperDetector{}, running),
|
||||
CheckForUpdateUseCase: useCases.NewCheckForUpdateUseCase(feed, store, running, now, discardRecorder),
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ func newRestartHTTP(t *testing.T) *restartHTTP {
|
||||
Token: h.token,
|
||||
GetUserUseCase: useCases.NewGetUserUseCase(newUpdateUserRepo()),
|
||||
GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()),
|
||||
GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()),
|
||||
GetUpdateStatusUseCase: useCases.NewGetUpdateStatusUseCase(
|
||||
h.store, h.jobs, h.helper, h.resultFiles, h.markers, updateRunning, false, h.clock.Now),
|
||||
RequestRestartUseCase: useCases.NewRequestRestartUseCase(
|
||||
|
||||
@@ -27,7 +27,7 @@ func newDisplayNameRouter(userRepo *mockUserRepo, token *authtoken.Auth) *chi.Mu
|
||||
getUser := useCases.NewGetUserUseCase(userRepo)
|
||||
update := useCases.NewUpdateUserProfileUseCase(userRepo, discardRecorder)
|
||||
handler := userhandler.NewHandler(update)
|
||||
meHandler := auth.NewMeHandler(getUser, useCases.NewGetMyAuthorUseCase(plainAuthors()))
|
||||
meHandler := auth.NewMeHandler(getUser, useCases.NewGetMyAuthorUseCase(plainAuthors()), useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()))
|
||||
loginHandler := auth.NewLoginHandler(
|
||||
token,
|
||||
useCases.NewCreateUserUseCase(userRepo, hasher, newMockMailRepo(), "https://cms.example.com", discardRecorder),
|
||||
@@ -91,6 +91,7 @@ func seededDisplayNameRepo(displayName string) *mockUserRepo {
|
||||
Email: "marta@renew.example",
|
||||
Password: "hashed:correct-horse",
|
||||
IsVerified: true,
|
||||
Role: user.RoleOwner,
|
||||
DisplayName: displayName,
|
||||
}
|
||||
return repo
|
||||
@@ -123,7 +124,7 @@ func assertSevenKeys(t *testing.T, body []byte) map[string]json.RawMessage {
|
||||
func assertMeKeys(t *testing.T, body []byte) map[string]json.RawMessage {
|
||||
t.Helper()
|
||||
decoded := decodeKeys(t, body)
|
||||
expected := append(append([]string{}, displayNameResponseKeys...), "author")
|
||||
expected := append(append([]string{}, displayNameResponseKeys...), "author", "access")
|
||||
if len(decoded) != len(expected) {
|
||||
t.Errorf("expected exactly %d keys, got %d: %s", len(expected), len(decoded), string(body))
|
||||
}
|
||||
@@ -392,7 +393,7 @@ func TestUserDisplayNameHttp(t *testing.T) {
|
||||
|
||||
t.Run("GET /v1/auth/me returns an empty display_name, not a missing key, when none is set", func(t *testing.T) {
|
||||
repo := newMockUserRepo()
|
||||
repo.users[8] = user.User{ID: 8, Username: "bob", IsVerified: false}
|
||||
repo.users[8] = user.User{ID: 8, Username: "bob", IsVerified: false, Role: user.RoleAdmin}
|
||||
router := newDisplayNameRouter(repo, token)
|
||||
rec := sendDisplayNameRequest(router, http.MethodGet, "/v1/auth/me", "", displayNameCookie(t, token, 8, 0))
|
||||
if rec.Code != http.StatusOK {
|
||||
@@ -420,7 +421,7 @@ func TestUserDisplayNameHttp(t *testing.T) {
|
||||
|
||||
t.Run("An unverified user is refused", func(t *testing.T) {
|
||||
repo := newMockUserRepo()
|
||||
repo.users[8] = user.User{ID: 8, Username: "bob", IsVerified: false}
|
||||
repo.users[8] = user.User{ID: 8, Username: "bob", IsVerified: false, Role: user.RoleAdmin}
|
||||
router := newDisplayNameRouter(repo, token)
|
||||
rec := sendDisplayNameRequest(router, http.MethodPatch, "/v1/user/me", `{"display_name": "Bob"}`, displayNameCookie(t, token, 8, 0))
|
||||
if rec.Code != http.StatusForbidden || rec.Body.String() != apperr.VerificationRequiredErrorMessage+"\n" {
|
||||
|
||||
@@ -60,7 +60,7 @@ func newUserRolesFixture(t *testing.T, maxAttempts int) *userRolesFixture {
|
||||
f.token,
|
||||
true,
|
||||
)
|
||||
meHandler := auth.NewMeHandler(getUser, useCases.NewGetMyAuthorUseCase(plainAuthors()))
|
||||
meHandler := auth.NewMeHandler(getUser, useCases.NewGetMyAuthorUseCase(plainAuthors()), useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()))
|
||||
inviteHandler := userhandler.NewInviteHandler(
|
||||
useCases.NewCreateInviteUseCase(f.users, f.invites, hasher, f.mail, invitePublicURL, discardRecorder),
|
||||
nil,
|
||||
|
||||
Reference in new issue
Block a user