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

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

No files matched your search

@@ -72,6 +72,6 @@ func TestActivityRangeAll(t *testing.T) {
t.Run("A non-owner export with range=all is still 403", func(t *testing.T) {
e := newActivityHTTPEnv(t, 1000)
assertPlain(t, e.get("/v1/activity/export?range=all", asUser(e, activityEditorID)), http.StatusForbidden, apperr.OwnerOrAdminRequiredErrorMessage)
assertPlain(t, e.get("/v1/activity/export?range=all", asUser(e, activityEditorID)), http.StatusForbidden, apperr.MissingPermissionErrorMessage)
})
}
+10 -7
View File
@@ -41,6 +41,7 @@ const (
type activityHTTPEnv struct {
*activityHarness
users *mockUserRepo
roles *mockRoleRepo
keys *mockAPIKeyRepo
articles *mockArticleRepo
mail *mockMailRepo
@@ -56,6 +57,7 @@ func newActivityHTTPEnv(t *testing.T, attempts int) *activityHTTPEnv {
e := &activityHTTPEnv{
activityHarness: h,
users: newMockUserRepo(),
roles: newSeededMockRoleRepo(),
keys: newMockAPIKeyRepo(),
articles: newMockArticleRepo(),
mail: newMockMailRepo(),
@@ -64,7 +66,7 @@ func newActivityHTTPEnv(t *testing.T, attempts int) *activityHTTPEnv {
}
e.users.users[activityOwnerID] = user.User{ID: activityOwnerID, Username: "marta", DisplayName: "Marta Alves", Email: "marta@example.com", Password: "hashed:" + activityPassword, IsVerified: true, Role: user.RoleOwner}
e.users.users[activityAdminID] = user.User{ID: activityAdminID, Username: "dev", Email: "dev@example.com", Password: "hashed:" + activityPassword, IsVerified: true, Role: user.RoleAdmin}
e.users.users[activityEditorID] = user.User{ID: activityEditorID, Username: "ed", Email: "ed@example.com", Password: "hashed:" + activityPassword, IsVerified: true, Role: "editor"}
e.users.users[activityEditorID] = user.User{ID: activityEditorID, Username: "ed", Email: "ed@example.com", Password: "hashed:" + activityPassword, IsVerified: true, Role: "editor", RoleID: writerRoleID}
e.users.users[activityUnverifiedID] = user.User{ID: activityUnverifiedID, Username: "quinn", Email: "quinn@example.com", IsVerified: false, Role: user.RoleOwner}
e.users.nextID = 4
@@ -73,14 +75,15 @@ func newActivityHTTPEnv(t *testing.T, attempts int) *activityHTTPEnv {
container := &api.UseCases{
Token: e.token,
GetUserUseCase: useCases.NewGetUserUseCase(e.users),
CheckPermissionUseCase: useCases.NewCheckPermissionUseCase(e.roles),
GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()),
GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()),
GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(e.roles),
AuthenticateUserUseCase: useCases.NewAuthenticateUserUseCase(e.users, hasher, h.recorder),
RequestPasswordResetUseCase: useCases.NewRequestPasswordResetUseCase(e.users, hasher, e.mail, "https://cms.example.com", h.recorder),
ResetPasswordUseCase: useCases.NewResetPasswordUseCase(e.users, hasher, e.keys, resetClock, h.recorder),
CreateAPIKeyUseCase: useCases.NewCreateAPIKeyUseCase(e.keys, e.generator, h.clock, h.recorder),
ListAPIKeysUseCase: useCases.NewListAPIKeysUseCase(e.keys, e.users, h.clock),
RevokeAPIKeyUseCase: useCases.NewRevokeAPIKeyUseCase(e.keys, e.users, h.clock, h.recorder),
RevokeAPIKeyUseCase: useCases.NewRevokeAPIKeyUseCase(e.keys, h.clock, h.recorder),
AuthenticateAPIKeyUseCase: useCases.NewAuthenticateAPIKeyUseCase(e.keys, e.generator, e.users, h.clock),
GetSettingsUseCase: useCases.NewGetSettingsUseCase("v-test"),
GetActivityRetentionUseCase: useCases.NewGetActivityRetentionUseCase(h.settings, h.probe, h.clock),
@@ -277,7 +280,7 @@ func TestActivityLogBackendAudienceGates(t *testing.T) {
t.Run("A scoped caller cannot read the summary or the export", func(t *testing.T) {
for _, path := range []string{"/v1/activity/summary", "/v1/activity/export"} {
e := newActivityHTTPEnv(t, 1000)
assertPlain(t, e.get(path, asUser(e, activityEditorID)), http.StatusForbidden, apperr.OwnerOrAdminRequiredErrorMessage)
assertPlain(t, e.get(path, asUser(e, activityEditorID)), http.StatusForbidden, apperr.MissingPermissionErrorMessage)
if e.probe.reads != 0 {
t.Error("the audit repository was read")
}
@@ -670,7 +673,7 @@ func TestActivityLogBackendRetentionSetting(t *testing.T) {
t.Run("The default is 90 days", func(t *testing.T) {
e := newActivityHTTPEnv(t, 1000)
rec := e.get("/v1/settings", asUser(e, activityEditorID))
rec := e.get("/v1/settings", asUser(e, activityAdminID))
assertJSONEqual(t, `{"version":"v-test","activity_retention_days":90,"activity_retention_max_gb":0,"activity_log_size_bytes":0,"activity_last_cleanup_at":null,"activity_last_cleanup_removed":0,"activity_next_cleanup_at":"2026-10-07T03:00:00Z"}`, rec.Body.String())
})
@@ -680,7 +683,7 @@ func TestActivityLogBackendRetentionSetting(t *testing.T) {
e.settings.settings.LastCleanupAt = activityPtr(time.Date(2026, time.October, 6, 3, 0, 0, 0, time.UTC))
e.settings.settings.LastCleanupRemoved = 142
body := settingsBodyOf(e.get("/v1/settings", asUser(e, activityEditorID)))
body := settingsBodyOf(e.get("/v1/settings", asUser(e, activityAdminID)))
if body["activity_last_cleanup_at"] != "2026-10-06T03:00:00Z" || body["activity_last_cleanup_removed"] != float64(142) {
t.Errorf("unexpected body %v", body)
@@ -732,7 +735,7 @@ func TestActivityLogBackendRetentionSetting(t *testing.T) {
rec := e.send(http.MethodPatch, "/v1/settings", `{"activity_retention_days": 30}`, asUser(e, activityAdminID))
assertPlain(t, rec, http.StatusForbidden, apperr.OwnerRequiredErrorMessage)
assertPlain(t, rec, http.StatusForbidden, apperr.MissingPermissionErrorMessage)
if e.settings.settings.RetentionDays != 90 {
t.Error("the retention changed")
}
+5 -4
View File
@@ -106,7 +106,7 @@ func TestSettingsNextCleanupHttp(t *testing.T) {
e.settings.settings.LastCleanupAt = activityPtr(time.Date(2026, time.October, 6, 1, 0, 0, 0, time.UTC))
e.settings.settings.LastCleanupRemoved = 142
rec := e.get("/v1/settings", asUser(e, activityEditorID))
rec := e.get("/v1/settings", asUser(e, activityAdminID))
body := keys(t, rec.Body.String())
if rec.Code != http.StatusOK || len(body) != 7 || body["activity_next_cleanup_at"] != "2026-10-07T03:00:00Z" || body["activity_last_cleanup_removed"] != float64(142) {
@@ -179,13 +179,14 @@ func TestSettingsNextCleanupQAFlow(t *testing.T) {
}
})
t.Run("GET is open to owner, admin and editor and refused without a cookie", func(t *testing.T) {
t.Run("GET is open to owner and admin and refused to an editor and without a cookie", func(t *testing.T) {
e := newActivityHTTPEnv(t, 1000)
for _, id := range []uint32{activityOwnerID, activityAdminID, activityEditorID} {
for _, id := range []uint32{activityOwnerID, activityAdminID} {
if rec := e.get("/v1/settings", asUser(e, id)); rec.Code != http.StatusOK {
t.Errorf("user %d: expected 200, got %d", id, rec.Code)
}
}
assertPlain(t, e.get("/v1/settings", asUser(e, activityEditorID)), http.StatusForbidden, apperr.MissingPermissionErrorMessage)
if rec := e.get("/v1/settings"); rec.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", rec.Code)
}
@@ -196,7 +197,7 @@ func TestSettingsNextCleanupQAFlow(t *testing.T) {
rec := e.send(http.MethodPatch, "/v1/settings", `{"activity_retention_days": 30}`, asUser(e, activityEditorID))
assertPlain(t, rec, http.StatusForbidden, apperr.OwnerRequiredErrorMessage)
assertPlain(t, rec, http.StatusForbidden, apperr.MissingPermissionErrorMessage)
if e.settings.retentionOK != 0 || len(e.all(t)) != 0 {
t.Error("the editor changed something")
}
+4 -4
View File
@@ -41,7 +41,7 @@ func TestSettingsRetentionSizeHttp(t *testing.T) {
e.settings.settings.RetentionMaxGB = c.gb
e.probe.sizeBytes = &c.bytes
rec := e.get("/v1/settings", asUser(e, activityEditorID))
rec := e.get("/v1/settings", asUser(e, activityAdminID))
body := sizeSettingsBody(t, rec.Body.Bytes())
var got []string
@@ -61,7 +61,7 @@ func TestSettingsRetentionSizeHttp(t *testing.T) {
t.Run("Settings API, a fresh install defaults to no size limit", func(t *testing.T) {
e := newActivityHTTPEnv(t, 1000)
body := sizeSettingsBody(t, e.get("/v1/settings", asUser(e, activityEditorID)).Body.Bytes())
body := sizeSettingsBody(t, e.get("/v1/settings", asUser(e, activityAdminID)).Body.Bytes())
if body["activity_retention_days"] != float64(90) || body["activity_retention_max_gb"] != float64(0) || body["activity_log_size_bytes"] != float64(0) {
t.Errorf("unexpected body %v", body)
@@ -153,7 +153,7 @@ func TestSettingsRetentionSizeHttp(t *testing.T) {
rec := e.send(http.MethodPatch, "/v1/settings", `{"activity_retention_max_gb": 5}`, asUser(e, activityAdminID))
assertPlain(t, rec, http.StatusForbidden, "Only the owner can do this.")
assertPlain(t, rec, http.StatusForbidden, apperr.MissingPermissionErrorMessage)
if e.settings.retentionOK != 0 {
t.Error("the admin changed something")
}
@@ -164,7 +164,7 @@ func TestSettingsRetentionSizeHttp(t *testing.T) {
e := newActivityHTTPEnv(t, 1000)
e.probe.sizeErr = errRepositoryFailure
assertPlain(t, e.get("/v1/settings", asUser(e, activityEditorID)), http.StatusInternalServerError, "Internal server error")
assertPlain(t, e.get("/v1/settings", asUser(e, activityAdminID)), http.StatusInternalServerError, "Internal server error")
})
t.Run("Settings API, the change is audited", func(t *testing.T) {
+4
View File
@@ -26,6 +26,7 @@ type activityProbeRepo struct {
deleteErr error
deleteCalls int
readErr error
maxReads int
sizeErr error
sizeBytes *int64
oldestErr error
@@ -47,6 +48,9 @@ func (p *activityProbeRepo) Append(event audit.Event) (audit.Event, error) {
func (p *activityProbeRepo) List(filter audit.Filter) (audit.Page, error) {
p.reads++
p.listCalls = append(p.listCalls, filter)
if p.maxReads > 0 && p.reads > p.maxReads {
return audit.Page{}, errors.New("read budget exceeded")
}
if p.readErr != nil {
return audit.Page{}, p.readErr
}
+18 -15
View File
@@ -4,6 +4,7 @@ import (
"RenewCMS/internal/domain/apikey"
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"bytes"
@@ -36,6 +37,7 @@ func newActivityWorld(t *testing.T) *activityWorld {
images: newMockImageRepo(),
keys: newMockAPIKeyRepo(),
}
w.probe.maxReads = 50
w.users.users[1] = user.User{ID: 1, Username: "owner", IsVerified: true, Role: user.RoleOwner}
w.users.users[2] = user.User{ID: 2, Username: "admin", IsVerified: true, Role: user.RoleAdmin}
w.users.users[3] = user.User{ID: 3, Username: "marta", DisplayName: "Marta Alves", IsVerified: true, Role: "editor"}
@@ -62,7 +64,7 @@ func TestActivityListUseCase(t *testing.T) {
recent := w.seedAt(t, 2*day, nil)
w.seedAt(t, 10*day, nil)
page, err := w.list.ListActivity(useCases.ListActivityCommand{ViewerID: 1})
page, err := w.list.ListActivity(useCases.ListActivityCommand{ViewerID: 1, Grants: activityViewAllGrants})
if err != nil || len(page.Items) != 1 || page.Items[0].Event.ID != recent.ID {
t.Fatalf("expected only the recent event, got %+v, %v", page, err)
@@ -94,7 +96,7 @@ func TestActivityListUseCase(t *testing.T) {
w := newActivityWorld(t)
w.seedAt(t, c.ago, nil)
page, err := w.list.ListActivity(useCases.ListActivityCommand{ViewerID: 1, Query: c.query})
page, err := w.list.ListActivity(useCases.ListActivityCommand{ViewerID: 1, Grants: activityViewAllGrants, Query: c.query})
if err != nil || (page.Total == 1) != c.found {
t.Errorf("expected found=%v, got total %d, %v", c.found, page.Total, err)
@@ -115,7 +117,7 @@ func TestActivityListUseCase(t *testing.T) {
for i, query := range queries {
w := newActivityWorld(t)
_, err := w.list.ListActivity(useCases.ListActivityCommand{ViewerID: 1, Query: query})
_, err := w.list.ListActivity(useCases.ListActivityCommand{ViewerID: 1, Grants: activityViewAllGrants, Query: query})
if !errors.Is(err, audit.ErrInvalidFilter) || w.probe.reads != 0 {
t.Errorf("query %d (%+v): expected ErrInvalidFilter without reading, got %v after %d reads", i, query, err, w.probe.reads)
@@ -128,14 +130,14 @@ func TestActivityListUseCase(t *testing.T) {
w.seedAt(t, day, nil)
w.seedAt(t, day, func(e *audit.Event) { e.Outcome = audit.OutcomeFailed })
page, _ := w.list.ListActivity(useCases.ListActivityCommand{ViewerID: 1, Query: useCases.ActivityQuery{Outcome: "all"}})
page, _ := w.list.ListActivity(useCases.ListActivityCommand{ViewerID: 1, Grants: activityViewAllGrants, Query: useCases.ActivityQuery{Outcome: "all"}})
if page.Total != 2 {
t.Errorf("expected both outcomes, got %d", page.Total)
}
})
t.Run("A caller who is not owner or admin is scoped to its own events", func(t *testing.T) {
t.Run("A caller with activity.view at own is scoped to its own events", func(t *testing.T) {
w := newActivityWorld(t)
w.seedAt(t, day, nil)
w.seedAt(t, day, func(e *audit.Event) {
@@ -143,7 +145,7 @@ func TestActivityListUseCase(t *testing.T) {
})
w.seedAt(t, day, func(e *audit.Event) { e.Actor = audit.SystemActor() })
page, err := w.list.ListActivity(useCases.ListActivityCommand{ViewerID: 3})
page, err := w.list.ListActivity(useCases.ListActivityCommand{ViewerID: 3, Grants: activityViewOwnGrants})
if err != nil || page.Total != 1 || *page.Items[0].Event.Actor.ID != 3 {
t.Fatalf("expected only user 3's events, got %+v, %v", page, err)
@@ -155,7 +157,7 @@ func TestActivityListUseCase(t *testing.T) {
for actor, allowed := range cases {
w := newActivityWorld(t)
_, err := w.list.ListActivity(useCases.ListActivityCommand{ViewerID: 3, Query: useCases.ActivityQuery{Actor: actor}})
_, err := w.list.ListActivity(useCases.ListActivityCommand{ViewerID: 3, Grants: activityViewOwnGrants, Query: useCases.ActivityQuery{Actor: actor}})
if allowed && err != nil {
t.Errorf("%s: expected success, got %v", actor, err)
@@ -166,13 +168,13 @@ func TestActivityListUseCase(t *testing.T) {
}
})
t.Run("An unknown viewer is reported", func(t *testing.T) {
t.Run("A caller without activity.view is refused before the repository is read", func(t *testing.T) {
w := newActivityWorld(t)
_, err := w.list.ListActivity(useCases.ListActivityCommand{ViewerID: 99})
_, err := w.list.ListActivity(useCases.ListActivityCommand{ViewerID: 3})
if !errors.Is(err, user.ErrUserNotFound) {
t.Errorf("expected ErrUserNotFound, got %v", err)
if !errors.Is(err, role.ErrMissingPermission) || w.probe.reads != 0 {
t.Errorf("expected a missing permission without reading, got %v after %d reads", err, w.probe.reads)
}
})
@@ -195,7 +197,7 @@ func TestActivityListUseCase(t *testing.T) {
c.seed(w)
w.seedAt(t, day, func(e *audit.Event) { e.Target = c.target })
page, err := w.list.ListActivity(useCases.ListActivityCommand{ViewerID: 1})
page, err := w.list.ListActivity(useCases.ListActivityCommand{ViewerID: 1, Grants: activityViewAllGrants})
if err != nil || len(page.Items) != 1 {
t.Fatalf("unexpected result %+v, %v", page, err)
@@ -209,9 +211,10 @@ func TestActivityListUseCase(t *testing.T) {
t.Run("A repository failure is returned", func(t *testing.T) {
w := newActivityWorld(t)
w.seedAt(t, 24*time.Hour, func(e *audit.Event) { e.Target = audit.Target{Type: "user", ID: "3", Name: "Marta Alves"} })
w.users.fail.failNext("Get")
_, err := w.list.ListActivity(useCases.ListActivityCommand{ViewerID: 1})
_, err := w.list.ListActivity(useCases.ListActivityCommand{ViewerID: 1, Grants: activityViewAllGrants})
if !errors.Is(err, errRepositoryFailure) {
t.Errorf("expected the repository failure, got %v", err)
@@ -307,7 +310,7 @@ func TestActivitySummaryUseCase(t *testing.T) {
func exportToString(t *testing.T, w *activityWorld, query useCases.ActivityQuery) string {
t.Helper()
prepared, err := w.export.Prepare(query)
prepared, err := w.export.Prepare(useCases.ExportActivityCommand{Grants: activityViewAllGrants, Query: query})
if err != nil {
t.Fatalf("prepare failed: %v", err)
}
@@ -445,7 +448,7 @@ func TestActivityExportUseCase(t *testing.T) {
t.Run("An invalid filter is refused before anything is prepared", func(t *testing.T) {
w := newActivityWorld(t)
_, err := w.export.Prepare(useCases.ActivityQuery{Outcome: "maybe"})
_, err := w.export.Prepare(useCases.ExportActivityCommand{Grants: activityViewAllGrants, Query: useCases.ActivityQuery{Outcome: "maybe"}})
if !errors.Is(err, audit.ErrInvalidFilter) || w.probe.reads != 0 {
t.Errorf("expected ErrInvalidFilter without reading, got %v", err)
+13 -10
View File
@@ -3,6 +3,7 @@ package test
import (
"RenewCMS/api"
"RenewCMS/internal/domain/apikey"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"net/http"
@@ -86,13 +87,15 @@ func TestAPIKeysCreatorNameHttp(t *testing.T) {
}
})
t.Run("API: an admin's list is unchanged", func(t *testing.T) {
t.Run("API: an own-scope caller's list is unchanged", func(t *testing.T) {
env := newAPIKeyEnv(t)
env.nameUsers()
env.issueKey(apiKeyDevId, "a")
env.issueKey(apiKeyDevId, "b")
env.customRoleUser(6, role.Grant{Key: "apikeys.view", Scope: role.ScopeOwn})
env.issueKey(6, "a")
env.issueKey(6, "b")
env.issueKey(apiKeyDevId, "other")
rec := env.withCookie(t, http.MethodGet, "/v1/api-key", "", apiKeyDevId)
rec := env.withCookie(t, http.MethodGet, "/v1/api-key", "", 6)
assertAPIKeyStatus(t, rec, http.StatusOK)
list := apiKeyDecodeList(t, rec)
@@ -174,10 +177,10 @@ func TestListAPIKeysCreatorName(t *testing.T) {
return repo
}
t.Run("Use case: ListAPIKeys returns the creator name only to an owner", func(t *testing.T) {
t.Run("Use case: ListAPIKeys returns the creator name only with all scope", func(t *testing.T) {
uc := useCases.NewListAPIKeysUseCase(seed(), users(), apiKeyUnitClock)
ownerEntries, err := uc.ListAPIKeys(1)
ownerEntries, err := uc.ListAPIKeys(1, apiKeyAllScope)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -189,12 +192,12 @@ func TestListAPIKeysCreatorName(t *testing.T) {
t.Errorf("unexpected creator names %v", names)
}
adminEntries, err := uc.ListAPIKeys(2)
ownScopeEntries, err := uc.ListAPIKeys(2, apiKeyOwnScope)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(adminEntries) != 1 || adminEntries[0].CreatorName != "" {
t.Errorf("expected one entry with an empty creator name, got %+v", adminEntries)
if len(ownScopeEntries) != 1 || ownScopeEntries[0].CreatorName != "" {
t.Errorf("expected one entry with an empty creator name, got %+v", ownScopeEntries)
}
})
}
@@ -218,7 +221,7 @@ func TestListAPIKeysTieKeepsRepositoryOrder(t *testing.T) {
}}
uc := useCases.NewListAPIKeysUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock)
entries, err := uc.ListAPIKeys(1)
entries, err := uc.ListAPIKeys(1, apiKeyAllScope)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
+38 -16
View File
@@ -8,6 +8,7 @@ import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/update"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/config"
@@ -116,6 +117,7 @@ func newAPIKeyEnvRecording(t *testing.T, recorder audit.Recorder) *apiKeyEnv {
GetUserUseCase: useCases.NewGetUserUseCase(env.users),
GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()),
GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(env.roles),
CheckPermissionUseCase: useCases.NewCheckPermissionUseCase(env.roles),
ListPermissionsUseCase: useCases.NewListPermissionsUseCase(),
CreateUserUseCase: useCases.NewCreateUserUseCase(env.users, hasher, mail, "https://cms.example.com", recorder),
AuthenticateUserUseCase: useCases.NewAuthenticateUserUseCase(env.users, hasher, recorder),
@@ -145,15 +147,19 @@ func newAPIKeyEnvRecording(t *testing.T, recorder audit.Recorder) *apiKeyEnv {
CheckForUpdateUseCase: useCases.NewCheckForUpdateUseCase(env.feed, env.updates, apiKeyRunningVersion, clock, recorder),
CreateAPIKeyUseCase: useCases.NewCreateAPIKeyUseCase(env.keys, env.generator, clock, recorder),
ListAPIKeysUseCase: useCases.NewListAPIKeysUseCase(env.keys, env.users, clock),
RevokeAPIKeyUseCase: useCases.NewRevokeAPIKeyUseCase(env.keys, env.users, clock, recorder),
RevokeAPIKeyUseCase: useCases.NewRevokeAPIKeyUseCase(env.keys, clock, recorder),
AuthenticateAPIKeyUseCase: useCases.NewAuthenticateAPIKeyUseCase(env.keys, env.generator, env.users, clock),
}
router := chi.NewRouter()
router.Mount("/v1", api.InitBackendRoutes(env.container, env.settings))
env.router = router
env.rebuildRouter()
return env
}
func (e *apiKeyEnv) rebuildRouter() {
router := chi.NewRouter()
router.Mount("/v1", api.InitBackendRoutes(e.container, e.settings))
e.router = router
}
func (e *apiKeyEnv) issueKey(userId uint32, name string, mutate ...func(*apikey.APIKey)) (string, uint32) {
e.issued++
raw, prefix, hashed := e.generator.Generate()
@@ -361,7 +367,7 @@ func TestAPIKeysHttp(t *testing.T) {
if len(list) != 1 {
t.Fatalf("expected one entry, got %d", len(list))
}
apiKeyAssertExactFields(t, list[0], "id", "user_id", "name", "prefix", "status", "created_at", "last_used_at", "expires_at", "revoked_at")
apiKeyAssertExactFields(t, list[0], "id", "user_id", "name", "prefix", "status", "created_at", "last_used_at", "expires_at", "revoked_at", "created_by_name")
if strings.Contains(rec.Body.String(), raw) || strings.Contains(rec.Body.String(), env.keys.sorted()[0].HashedKey) {
t.Errorf("neither the raw key nor its hash may appear in the body")
}
@@ -431,7 +437,10 @@ func TestAPIKeysHttp(t *testing.T) {
}
raw := apiKeyDecodeObject(t, rec)["key"].(string)
invite := env.withKey(t, http.MethodPost, "/v1/user/invite", `{"email": "new@example.com"}`, raw)
assertAPIKeyMessage(t, invite, http.StatusForbidden, apperr.OwnerRequiredErrorMessage)
assertAPIKeyStatus(t, invite, http.StatusCreated)
if len(env.invites.invites) != 1 || env.invites.invites[1].InvitedBy != apiKeyDevId {
t.Errorf("expected an invite invited by the key's creator, got %+v", env.invites.invites)
}
})
t.Run("Key management needs a verified real session", func(t *testing.T) {
@@ -564,13 +573,14 @@ func TestAPIKeysHttp(t *testing.T) {
}
})
t.Run("An admin's key can not do owner-only things", func(t *testing.T) {
t.Run("A writer's key can not invite", func(t *testing.T) {
env := newAPIKeyEnv(t)
raw, _ := env.issueKey(apiKeyDevId, "ci")
env.addUser(5, "", writerRoleID, true)
raw, _ := env.issueKey(5, "ci")
rec := env.withKey(t, http.MethodPost, "/v1/user/invite", `{"email": "new@example.com"}`, raw)
assertAPIKeyMessage(t, rec, http.StatusForbidden, apperr.OwnerRequiredErrorMessage)
assertAPIKeyMessage(t, rec, http.StatusForbidden, apperr.MissingPermissionErrorMessage)
if len(env.invites.invites) != 0 {
t.Errorf("expected no invite")
}
@@ -647,7 +657,8 @@ func TestAPIKeysHttp(t *testing.T) {
t.Run("A Bearer key wins over the cookie, and a bad key is not rescued by a good cookie", func(t *testing.T) {
env := newAPIKeyEnv(t)
raw, _ := env.issueKey(apiKeyPatId, "ci")
env.addUser(5, "", writerRoleID, true)
raw, _ := env.issueKey(5, "ci")
invite := env.send(t, apiKeyRequest{method: http.MethodPost, path: "/v1/user/invite", body: `{"email": "new@example.com"}`, bearer: raw, cookieUser: apiKeyOwnerId})
assertAPIKeyStatus(t, invite, http.StatusForbidden)
@@ -854,7 +865,7 @@ func TestAPIKeysHttp(t *testing.T) {
}
})
t.Run("Listing as an admin shows only that admin's keys", func(t *testing.T) {
t.Run("Listing as an admin shows every user's keys", func(t *testing.T) {
env := newAPIKeyEnv(t)
env.issueKey(apiKeyDevId, "a")
env.issueKey(apiKeyDevId, "b")
@@ -863,8 +874,8 @@ func TestAPIKeysHttp(t *testing.T) {
rec := env.withCookie(t, http.MethodGet, "/v1/api-key", "", apiKeyDevId)
assertAPIKeyStatus(t, rec, http.StatusOK)
if got := strings.Join(apiKeyEntryNames(t, rec), ","); got != "b,a" {
t.Errorf("expected exactly b and a, got %s", got)
if got := strings.Join(apiKeyEntryNames(t, rec), ","); got != "c,b,a" {
t.Errorf("expected c, b and a, got %s", got)
}
})
@@ -957,16 +968,26 @@ func TestAPIKeysHttp(t *testing.T) {
}
})
t.Run("An admin can't revoke someone else's key, and learns nothing about it", func(t *testing.T) {
t.Run("An own-scope caller can't revoke someone else's key, and learns nothing about it", func(t *testing.T) {
env := newAPIKeyEnv(t)
env.customRoleUser(6, role.Grant{Key: "apikeys.revoke", Scope: role.ScopeOwn})
raw, id := env.issueKey(apiKeyPatId, "ci")
rec := env.withCookie(t, http.MethodDelete, "/v1/api-key/"+strconv.Itoa(int(id)), "", apiKeyDevId)
rec := env.withCookie(t, http.MethodDelete, "/v1/api-key/"+strconv.Itoa(int(id)), "", 6)
assertAPIKeyMessage(t, rec, http.StatusNotFound, apperr.APIKeyNotFoundErrorMessage)
assertAPIKeyStatus(t, env.withKey(t, http.MethodGet, "/v1/settings", "", raw), http.StatusOK)
})
t.Run("An admin revokes someone else's key", func(t *testing.T) {
env := newAPIKeyEnv(t)
raw, id := env.issueKey(apiKeyPatId, "ci")
assertAPIKeyStatus(t, env.withCookie(t, http.MethodDelete, "/v1/api-key/"+strconv.Itoa(int(id)), "", apiKeyDevId), http.StatusNoContent)
assertAPIKeyStatus(t, env.withKey(t, http.MethodGet, "/v1/settings", "", raw), http.StatusUnauthorized)
})
t.Run("The owner can revoke any key", func(t *testing.T) {
env := newAPIKeyEnv(t)
raw, id := env.issueKey(apiKeyPatId, "ci")
@@ -1056,8 +1077,9 @@ func TestAPIKeysHttp(t *testing.T) {
t.Errorf("expected no stored key")
}
env.customRoleUser(6, role.Grant{Key: "apikeys.view", Scope: role.ScopeOwn})
env.keys.fail.failNext("ListByUser")
assertAPIKeyMessage(t, env.withCookie(t, http.MethodGet, "/v1/api-key", "", apiKeyDevId), http.StatusInternalServerError, "Internal server error")
assertAPIKeyMessage(t, env.withCookie(t, http.MethodGet, "/v1/api-key", "", 6), http.StatusInternalServerError, "Internal server error")
env.keys.fail.failNext("ListAll")
assertAPIKeyMessage(t, env.withCookie(t, http.MethodGet, "/v1/api-key", "", apiKeyOwnerId), http.StatusInternalServerError, "Internal server error")
+29 -26
View File
@@ -2,6 +2,7 @@ package test
import (
"RenewCMS/internal/domain/apikey"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/hashing"
"RenewCMS/internal/infrastructure/useCases"
@@ -173,6 +174,11 @@ func seedStubKey(repo *mockAPIKeyRepo, raw string, mutate func(*apikey.APIKey))
return stored
}
var (
apiKeyOwnScope = role.NewGrantSet([]role.Grant{{Key: role.KeyAPIKeysView, Scope: role.ScopeOwn}, {Key: role.KeyAPIKeysRevoke, Scope: role.ScopeOwn}})
apiKeyAllScope = role.NewGrantSet([]role.Grant{{Key: role.KeyAPIKeysView, Scope: role.ScopeAll}, {Key: role.KeyAPIKeysRevoke, Scope: role.ScopeAll}})
)
func apiKeyUnitUsers() *mockUserRepo {
users := newMockUserRepo()
users.users[1] = user.User{ID: 1, Username: "marta", IsVerified: true, Role: user.RoleOwner}
@@ -402,10 +408,10 @@ func TestListAPIKeysUseCase(t *testing.T) {
return result
}
t.Run("An admin lists only their own keys, newest first", func(t *testing.T) {
t.Run("A caller with own scope lists only their own keys, newest first", func(t *testing.T) {
repo := newMockAPIKeyRepo()
seed(repo)
entries, err := useCases.NewListAPIKeysUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock).ListAPIKeys(2)
entries, err := useCases.NewListAPIKeysUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock).ListAPIKeys(2, apiKeyOwnScope)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -414,10 +420,10 @@ func TestListAPIKeysUseCase(t *testing.T) {
}
})
t.Run("The owner lists every key, newest first", func(t *testing.T) {
t.Run("A caller with all scope lists every key, newest first", func(t *testing.T) {
repo := newMockAPIKeyRepo()
seed(repo)
entries, err := useCases.NewListAPIKeysUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock).ListAPIKeys(1)
entries, err := useCases.NewListAPIKeysUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock).ListAPIKeys(1, apiKeyAllScope)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -436,7 +442,7 @@ func TestListAPIKeysUseCase(t *testing.T) {
for _, raw := range []string{"rcms_first", "rcms_second", "rcms_third"} {
seedStubKey(repo, raw, func(k *apikey.APIKey) { k.CreatedAt = same })
}
entries, err := useCases.NewListAPIKeysUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock).ListAPIKeys(2)
entries, err := useCases.NewListAPIKeysUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock).ListAPIKeys(2, apiKeyOwnScope)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -455,7 +461,7 @@ func TestListAPIKeysUseCase(t *testing.T) {
seedStubKey(repo, "rcms_expired", func(k *apikey.APIKey) { k.ExpiresAt = &expired })
seedStubKey(repo, "rcms_both", func(k *apikey.APIKey) { k.ExpiresAt = &expired; k.RevokedAt = &revoked })
entries, _ := useCases.NewListAPIKeysUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock).ListAPIKeys(2)
entries, _ := useCases.NewListAPIKeysUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock).ListAPIKeys(2, apiKeyOwnScope)
statuses := map[string]apikey.Status{}
for _, entry := range entries {
statuses[entry.Key.Prefix] = entry.Status
@@ -468,15 +474,15 @@ func TestListAPIKeysUseCase(t *testing.T) {
t.Run("An unknown requester and repository failures are returned", func(t *testing.T) {
repo := newMockAPIKeyRepo()
uc := useCases.NewListAPIKeysUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock)
if _, err := uc.ListAPIKeys(99); !errors.Is(err, user.ErrUserNotFound) {
if _, err := uc.ListAPIKeys(99, apiKeyOwnScope); !errors.Is(err, user.ErrUserNotFound) {
t.Errorf("expected ErrUserNotFound, got %v", err)
}
repo.fail.failNext("ListByUser")
if _, err := uc.ListAPIKeys(2); !errors.Is(err, errRepositoryFailure) {
if _, err := uc.ListAPIKeys(2, apiKeyOwnScope); !errors.Is(err, errRepositoryFailure) {
t.Errorf("expected the repository failure, got %v", err)
}
repo.fail.failNext("ListAll")
if _, err := uc.ListAPIKeys(1); !errors.Is(err, errRepositoryFailure) {
if _, err := uc.ListAPIKeys(1, apiKeyAllScope); !errors.Is(err, errRepositoryFailure) {
t.Errorf("expected the repository failure, got %v", err)
}
})
@@ -486,9 +492,9 @@ func TestRevokeAPIKeyUseCase(t *testing.T) {
t.Run("A creator revokes their own key at the clock's now", func(t *testing.T) {
repo := newMockAPIKeyRepo()
stored := seedStubKey(repo, "rcms_a", nil)
uc := useCases.NewRevokeAPIKeyUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock, discardRecorder)
uc := useCases.NewRevokeAPIKeyUseCase(repo, apiKeyUnitClock, discardRecorder)
if err := uc.RevokeAPIKey(testCtx, 2, stored.ID); err != nil {
if err := uc.RevokeAPIKey(testCtx, 2, stored.ID, apiKeyOwnScope); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got := repo.keys[stored.ID].RevokedAt; got == nil || !got.Equal(apiKeyUnitNow) {
@@ -496,12 +502,12 @@ func TestRevokeAPIKeyUseCase(t *testing.T) {
}
})
t.Run("Another admin gets not found and the key stays active", func(t *testing.T) {
t.Run("Another own-scope caller gets not found and the key stays active", func(t *testing.T) {
repo := newMockAPIKeyRepo()
stored := seedStubKey(repo, "rcms_a", nil)
uc := useCases.NewRevokeAPIKeyUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock, discardRecorder)
uc := useCases.NewRevokeAPIKeyUseCase(repo, apiKeyUnitClock, discardRecorder)
if err := uc.RevokeAPIKey(testCtx, 3, stored.ID); !errors.Is(err, apikey.ErrAPIKeyNotFound) {
if err := uc.RevokeAPIKey(testCtx, 3, stored.ID, apiKeyOwnScope); !errors.Is(err, apikey.ErrAPIKeyNotFound) {
t.Errorf("expected ErrAPIKeyNotFound, got %v", err)
}
if repo.keys[stored.ID].RevokedAt != nil || repo.revokeCalls != 0 {
@@ -509,12 +515,12 @@ func TestRevokeAPIKeyUseCase(t *testing.T) {
}
})
t.Run("The owner revokes any key", func(t *testing.T) {
t.Run("A caller with all scope revokes any key", func(t *testing.T) {
repo := newMockAPIKeyRepo()
stored := seedStubKey(repo, "rcms_a", func(k *apikey.APIKey) { k.UserID = 3 })
uc := useCases.NewRevokeAPIKeyUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock, discardRecorder)
uc := useCases.NewRevokeAPIKeyUseCase(repo, apiKeyUnitClock, discardRecorder)
if err := uc.RevokeAPIKey(testCtx, 1, stored.ID); err != nil {
if err := uc.RevokeAPIKey(testCtx, 1, stored.ID, apiKeyAllScope); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if repo.keys[stored.ID].RevokedAt == nil {
@@ -526,9 +532,9 @@ func TestRevokeAPIKeyUseCase(t *testing.T) {
repo := newMockAPIKeyRepo()
original := apiKeyUnitNow.Add(-time.Hour)
stored := seedStubKey(repo, "rcms_a", func(k *apikey.APIKey) { k.RevokedAt = &original })
uc := useCases.NewRevokeAPIKeyUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock, discardRecorder)
uc := useCases.NewRevokeAPIKeyUseCase(repo, apiKeyUnitClock, discardRecorder)
if err := uc.RevokeAPIKey(testCtx, 2, stored.ID); err != nil {
if err := uc.RevokeAPIKey(testCtx, 2, stored.ID, apiKeyOwnScope); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got := repo.keys[stored.ID].RevokedAt; got == nil || !got.Equal(original) {
@@ -536,19 +542,16 @@ func TestRevokeAPIKeyUseCase(t *testing.T) {
}
})
t.Run("An unknown key, an unknown requester and repository failures are returned", func(t *testing.T) {
t.Run("An unknown key and repository failures are returned", func(t *testing.T) {
repo := newMockAPIKeyRepo()
stored := seedStubKey(repo, "rcms_a", nil)
uc := useCases.NewRevokeAPIKeyUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock, discardRecorder)
uc := useCases.NewRevokeAPIKeyUseCase(repo, apiKeyUnitClock, discardRecorder)
if err := uc.RevokeAPIKey(testCtx, 2, 999); !errors.Is(err, apikey.ErrAPIKeyNotFound) {
if err := uc.RevokeAPIKey(testCtx, 2, 999, apiKeyOwnScope); !errors.Is(err, apikey.ErrAPIKeyNotFound) {
t.Errorf("expected ErrAPIKeyNotFound, got %v", err)
}
if err := uc.RevokeAPIKey(testCtx, 99, stored.ID); !errors.Is(err, user.ErrUserNotFound) {
t.Errorf("expected ErrUserNotFound, got %v", err)
}
repo.fail.failNext("Revoke")
if err := uc.RevokeAPIKey(testCtx, 2, stored.ID); !errors.Is(err, errRepositoryFailure) {
if err := uc.RevokeAPIKey(testCtx, 2, stored.ID, apiKeyOwnScope); !errors.Is(err, errRepositoryFailure) {
t.Errorf("expected the repository failure, got %v", err)
}
})
+1 -1
View File
@@ -127,7 +127,7 @@ func TestAuthorUseCases(t *testing.T) {
uc := useCases.NewUpdateAuthorUseCase(authors, users, newMockImageRepo(), discardRecorder)
bio := "x"
_, err := uc.Update(context.Background(), 2, useCases.AuthorCaller{UserID: 9, Role: "user"}, author.Patch{Bio: &bio})
_, err := uc.Update(context.Background(), 2, useCases.AuthorCaller{UserID: 9, Grants: authorsEditOwnGrants}, author.Patch{Bio: &bio})
if !errors.Is(err, author.ErrAuthorRights) {
t.Errorf("err = %v", err)
+1 -1
View File
@@ -14,7 +14,7 @@ import (
)
const (
ownerOrAdminRefusal = "Only owners and admins can do this."
ownerOrAdminRefusal = apperr.MissingPermissionErrorMessage
sessionRefusal = "This action needs a real session."
)
@@ -57,6 +57,7 @@ func newContainerHarness(t *testing.T, container bool, helperInstalled bool) *co
routes := &api.UseCases{
Token: token,
GetUserUseCase: useCases.NewGetUserUseCase(newUpdateUserRepo()),
CheckPermissionUseCase: useCases.NewCheckPermissionUseCase(newSeededMockRoleRepo()),
GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()),
GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()),
GetUpdateStatusUseCase: useCases.NewGetUpdateStatusUseCase(rig.store, rig.jobs, helper, rig.results, rig.requests, updateRunning, container, now),
@@ -420,6 +420,7 @@ func TestBuiltinTypeHTTP(t *testing.T) {
container := &api.UseCases{
Token: token,
GetUserUseCase: useCases.NewGetUserUseCase(users),
CheckPermissionUseCase: useCases.NewCheckPermissionUseCase(newSeededMockRoleRepo()),
AuthenticateAPIKeyUseCase: useCases.NewAuthenticateAPIKeyUseCase(newMockAPIKeyRepo(), generator, users, now),
ListBuiltinTypesUseCase: useCases.NewListBuiltinTypesUseCase(),
GetBuiltinTypeUseCase: useCases.NewGetBuiltinTypeUseCase(),
@@ -266,6 +266,7 @@ func newContentTypeEnv(t *testing.T) *contentTypeEnv {
container := &api.UseCases{
Token: env.token,
GetUserUseCase: useCases.NewGetUserUseCase(env.users),
CheckPermissionUseCase: useCases.NewCheckPermissionUseCase(newSeededMockRoleRepo()),
AuthenticateAPIKeyUseCase: useCases.NewAuthenticateAPIKeyUseCase(env.keys, env.generator, env.users, now),
CreateContentTypeUseCase: useCases.NewCreateContentTypeUseCase(env.types, now),
ListContentTypesUseCase: useCases.NewListContentTypesUseCase(env.types),
+4 -1
View File
@@ -2,6 +2,7 @@ package test
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/internal/domain/user"
"net/http"
"reflect"
"strconv"
@@ -128,7 +129,7 @@ func TestContentTypeKeysAndAccess(t *testing.T) {
{"no credentials", 0, 401, ""},
{"a jwt cookie for user 4", ctUnverifiedId, 403, apperr.VerificationRequiredErrorMessage},
{"a stale token_version cookie", ctStaleId, 401, apperr.UnauthorizedErrorMessage},
{"a verified member without a role", ctMemberId, 403, apperr.OwnerOrAdminRequiredErrorMessage},
{"a verified member without a role", ctMemberId, 403, apperr.MissingPermissionErrorMessage},
}
for _, example := range examples {
t.Run(example.caller, func(t *testing.T) {
@@ -160,6 +161,8 @@ func TestContentTypeKeysAndAccess(t *testing.T) {
env.createType(t, "recipe", recipeFields())
env.createType(t, "event", dishFields())
env.users.users[ctMemberId] = user.User{ID: ctMemberId, Username: "pat", IsVerified: true, RoleID: writerRoleID}
list := env.as(t, ctMemberId, http.MethodGet, "/v1/content-type", "")
expectStatusAndBody(t, list, http.StatusOK, "")
+6 -6
View File
@@ -39,12 +39,12 @@ func TestImageVariantsGuards(t *testing.T) {
{"no cookie", http.MethodGet, "/v1/image-variants", "none", 401, apperr.UnauthorizedErrorMessage},
{"stale cookie", http.MethodGet, "/v1/image-variants", "stale", 401, apperr.UnauthorizedErrorMessage},
{"unverified user", http.MethodGet, "/v1/image-variants", "unverified", 403, apperr.VerificationRequiredErrorMessage},
{"plain user list", http.MethodGet, "/v1/image-variants", "plain", 403, apperr.OwnerOrAdminRequiredErrorMessage},
{"plain user create", http.MethodPost, "/v1/image-variants", "plain", 403, apperr.OwnerOrAdminRequiredErrorMessage},
{"plain user put", http.MethodPut, "/v1/image-variants/hero", "plain", 403, apperr.OwnerOrAdminRequiredErrorMessage},
{"plain user patch", http.MethodPatch, "/v1/image-variants/hero", "plain", 403, apperr.OwnerOrAdminRequiredErrorMessage},
{"plain user delete", http.MethodDelete, "/v1/image-variants/hero", "plain", 403, apperr.OwnerOrAdminRequiredErrorMessage},
{"plain user purge", http.MethodPost, "/v1/image-variants/hero/purge", "plain", 403, apperr.OwnerOrAdminRequiredErrorMessage},
{"plain user list", http.MethodGet, "/v1/image-variants", "plain", 403, apperr.MissingPermissionErrorMessage},
{"plain user create", http.MethodPost, "/v1/image-variants", "plain", 403, apperr.MissingPermissionErrorMessage},
{"plain user put", http.MethodPut, "/v1/image-variants/hero", "plain", 403, apperr.MissingPermissionErrorMessage},
{"plain user patch", http.MethodPatch, "/v1/image-variants/hero", "plain", 403, apperr.MissingPermissionErrorMessage},
{"plain user delete", http.MethodDelete, "/v1/image-variants/hero", "plain", 403, apperr.MissingPermissionErrorMessage},
{"plain user purge", http.MethodPost, "/v1/image-variants/hero/purge", "plain", 403, apperr.MissingPermissionErrorMessage},
{"api key", http.MethodGet, "/v1/image-variants", "apikey", 401, apperr.UnauthorizedErrorMessage},
}
for _, row := range rows {
+2 -2
View File
@@ -29,8 +29,8 @@ func TestImageVariantsCacheGuards(t *testing.T) {
{"no cookie", http.MethodGet, cacheStatsPath, "none", 401, apperr.UnauthorizedErrorMessage},
{"a stale token_version cookie", http.MethodGet, cacheStatsPath, "stale", 401, apperr.UnauthorizedErrorMessage},
{"the cookie of an unverified user", http.MethodGet, cacheStatsPath, "unverified", 403, apperr.VerificationRequiredErrorMessage},
{"the cookie of a plain user", http.MethodGet, cacheStatsPath, "plain", 403, apperr.OwnerOrAdminRequiredErrorMessage},
{"the cookie of a plain user on purge", http.MethodPost, cachePurgePath, "plain", 403, apperr.OwnerOrAdminRequiredErrorMessage},
{"the cookie of a plain user", http.MethodGet, cacheStatsPath, "plain", 403, apperr.MissingPermissionErrorMessage},
{"the cookie of a plain user on purge", http.MethodPost, cachePurgePath, "plain", 403, apperr.MissingPermissionErrorMessage},
{"a valid API key", http.MethodGet, cacheStatsPath, "apikey", 401, apperr.UnauthorizedErrorMessage},
{"a valid API key on purge", http.MethodPost, cachePurgePath, "apikey", 401, apperr.UnauthorizedErrorMessage},
}
+1
View File
@@ -113,6 +113,7 @@ func (e *variantEnv) container(limits useCases.ServeVariantLimits) *api.UseCases
return &api.UseCases{
Token: e.token,
GetUserUseCase: useCases.NewGetUserUseCase(e.users),
CheckPermissionUseCase: useCases.NewCheckPermissionUseCase(newSeededMockRoleRepo()),
GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()),
GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()),
OpenMediaUseCase: useCases.NewOpenMediaUseCase(e.storage),
+4 -4
View File
@@ -149,10 +149,10 @@ func TestPerImageVariantRoutesAreGuarded(t *testing.T) {
{http.MethodGet, "/v1/media/1/variants", "none", 401, apperr.UnauthorizedErrorMessage},
{http.MethodGet, "/v1/media/1/variants", "stale", 401, apperr.UnauthorizedErrorMessage},
{http.MethodGet, "/v1/media/1/variants", "unverified", 403, apperr.VerificationRequiredErrorMessage},
{http.MethodGet, "/v1/media/1/variants", "plain", 403, apperr.OwnerOrAdminRequiredErrorMessage},
{http.MethodPost, "/v1/media/1/variants", "plain", 403, apperr.OwnerOrAdminRequiredErrorMessage},
{http.MethodPost, "/v1/media/1/variants/hero", "plain", 403, apperr.OwnerOrAdminRequiredErrorMessage},
{http.MethodDelete, "/v1/media/1/variants/hero", "plain", 403, apperr.OwnerOrAdminRequiredErrorMessage},
{http.MethodGet, "/v1/media/1/variants", "plain", 403, apperr.MissingPermissionErrorMessage},
{http.MethodPost, "/v1/media/1/variants", "plain", 403, apperr.MissingPermissionErrorMessage},
{http.MethodPost, "/v1/media/1/variants/hero", "plain", 403, apperr.MissingPermissionErrorMessage},
{http.MethodDelete, "/v1/media/1/variants/hero", "plain", 403, apperr.MissingPermissionErrorMessage},
{http.MethodGet, "/v1/media/1/variants", "apikey", 401, apperr.UnauthorizedErrorMessage},
{http.MethodPost, "/v1/media/1/variants", "apikey", 401, apperr.UnauthorizedErrorMessage},
}
+6 -6
View File
@@ -48,7 +48,7 @@ func newInviteManagementFixture(t *testing.T, maxAttempts int) *inviteManagement
token: authtoken.New([]byte("test-secret")),
}
f.invites.users = f.users
f.users.users[9] = user.User{ID: 9, Username: "jonas", Email: "jonas@renew.example", IsVerified: true, Role: user.RoleAdmin}
f.users.users[9] = user.User{ID: 9, Username: "jonas", Email: "jonas@renew.example", IsVerified: true, Role: user.RoleAdmin, RoleID: writerRoleID}
f.users.users[11] = user.User{ID: 11, Username: "unverified", Email: "unverified@renew.example", IsVerified: false, Role: user.RoleAdmin}
f.users.users[12] = user.User{ID: 12, Username: "stale", Email: "stale@renew.example", IsVerified: true, Role: user.RoleOwner, TokenVersion: 2}
f.users.nextID = 12
@@ -77,7 +77,7 @@ func newInviteManagementFixture(t *testing.T, maxAttempts int) *inviteManagement
r.Use(middleware.RequireVerifiedUser(getUser))
r.Get("/", handler.ListUsers)
r.Group(func(r chi.Router) {
r.Use(middleware.RequireOwner(getUser))
r.Use(middleware.RequirePermission(getUser, useCases.NewCheckPermissionUseCase(newSeededMockRoleRepo()), "users.invite"))
r.Use(middleware.TrustedProxyRealIP(nil))
r.Use(middleware.NewRateLimiter(maxAttempts, time.Hour))
r.Post("/invite", handler.CreateInvite)
@@ -383,7 +383,7 @@ func TestInviteManagementHttp(t *testing.T) {
{"no cookie", func(f *inviteManagementFixture) *http.Cookie { return nil }, http.StatusUnauthorized},
{"stale token_version", func(f *inviteManagementFixture) *http.Cookie { return displayNameCookie(t, f.token, 12, 1) }, http.StatusUnauthorized},
{"unverified user", func(f *inviteManagementFixture) *http.Cookie { return displayNameCookie(t, f.token, 11, 0) }, http.StatusForbidden},
{"admin", func(f *inviteManagementFixture) *http.Cookie { return displayNameCookie(t, f.token, 9, 0) }, http.StatusForbidden},
{"writer", func(f *inviteManagementFixture) *http.Cookie { return displayNameCookie(t, f.token, 9, 0) }, http.StatusForbidden},
}
for _, tc := range cases {
for _, call := range []struct{ method, path string }{
@@ -402,12 +402,12 @@ func TestInviteManagementHttp(t *testing.T) {
}
})
t.Run("An admin is refused with the owner message, even on an unknown id", func(t *testing.T) {
t.Run("A writer is refused with the missing permission message, even on an unknown id", func(t *testing.T) {
f := newInviteManagementFixture(t, 100)
rec := f.asUser(t, 9, 0, http.MethodDelete, "/v1/user/invite/999")
if rec.Code != http.StatusForbidden || rec.Body.String() != apperr.OwnerRequiredErrorMessage+"\n" {
if rec.Code != http.StatusForbidden || rec.Body.String() != apperr.MissingPermissionErrorMessage+"\n" {
t.Errorf("unexpected %d %q", rec.Code, rec.Body.String())
}
})
@@ -450,7 +450,7 @@ func TestInviteManagementHttp(t *testing.T) {
}
})
t.Run("An admin does not use up the quota", func(t *testing.T) {
t.Run("A writer does not use up the quota", func(t *testing.T) {
f := newInviteManagementFixture(t, 2)
for i := 0; i < 3; i++ {
if rec := f.asUser(t, 9, 0, http.MethodDelete, "/v1/user/invite/1"); rec.Code != http.StatusForbidden {
+3 -2
View File
@@ -58,8 +58,8 @@ func newMediaEnv(t *testing.T) *mediaEnv {
clock: time.Date(2026, time.October, 5, 9, 0, 0, 0, time.UTC),
logs: &bytes.Buffer{},
}
env.users.users[mediaVerifiedUserId] = user.User{ID: 1, Username: "ines", DisplayName: "Inès Duarte", IsVerified: true}
env.users.users[mediaSecondUserId] = user.User{ID: 2, Username: "marta", IsVerified: true}
env.users.users[mediaVerifiedUserId] = user.User{ID: 1, Username: "ines", DisplayName: "Inès Duarte", IsVerified: true, RoleID: adminRoleID}
env.users.users[mediaSecondUserId] = user.User{ID: 2, Username: "marta", IsVerified: true, RoleID: adminRoleID}
env.users.users[mediaUnverifiedUserId] = user.User{ID: 3, Username: "pending", IsVerified: false}
env.users.users[mediaStaleUserId] = user.User{ID: 4, Username: "stale", IsVerified: true, TokenVersion: 1}
@@ -73,6 +73,7 @@ func newMediaEnv(t *testing.T) *mediaEnv {
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(env.articles, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), env.images),
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(env.articles, discardRecorder),
GetUserUseCase: useCases.NewGetUserUseCase(env.users),
CheckPermissionUseCase: useCases.NewCheckPermissionUseCase(newSeededMockRoleRepo()),
GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()),
GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()),
CreateImageUseCase: useCases.NewCreateImageUseCase(env.images, env.storage, discardRecorder, imaging.NewMetadataStripper()),
+2
View File
@@ -4,6 +4,7 @@ import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/entry"
imageDomain "RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"bytes"
"encoding/json"
@@ -62,6 +63,7 @@ type mediaFieldEnv struct {
func newMediaFieldEnv(t *testing.T) *mediaFieldEnv {
t.Helper()
base := newContentTypeEnv(t)
base.users.users[ctMemberId] = user.User{ID: ctMemberId, Username: "pat", IsVerified: true, RoleID: adminRoleID}
env := &mediaFieldEnv{contentTypeEnv: base, articles: newMockArticleRepo(), spy: &mfEntrySpyRepo{mockEntryRepo: base.entries}, lookups: &mfImageSpyRepo{mockImageRepo: base.images}, logs: &bytes.Buffer{}}
settings := newMockPurgeSettingsRepo()
now := func() time.Time { return base.clock }
+1 -1
View File
@@ -15,7 +15,7 @@ const mediaGetEditorID = 9
func newMediaGetEnv(t *testing.T) *siteEnv {
t.Helper()
e := newSiteEnvWithMain(t)
e.users.users[mediaGetEditorID] = user.User{ID: mediaGetEditorID, Username: "editor", Email: "editor@example.com", IsVerified: true, Role: user.Role("editor")}
e.users.users[mediaGetEditorID] = user.User{ID: mediaGetEditorID, Username: "editor", Email: "editor@example.com", IsVerified: true, Role: user.Role("editor"), RoleID: writerRoleID}
e.seedImageRecord(5)
return e
}
+1
View File
@@ -106,6 +106,7 @@ func (e *hardeningEnv) mount() {
container := &api.UseCases{
Token: e.token,
GetUserUseCase: useCases.NewGetUserUseCase(e.users),
CheckPermissionUseCase: useCases.NewCheckPermissionUseCase(newSeededMockRoleRepo()),
MergeMediaUseCase: merge,
GetPurgeSettingsUseCase: useCases.NewGetPurgeSettingsUseCase(e.settings),
UpdatePurgeSettingsUseCase: useCases.NewUpdatePurgeSettingsUseCase(e.settings, time.Now),
+10 -8
View File
@@ -19,6 +19,7 @@ const (
purgeAdminId uint32 = 11
purgeEditorId uint32 = 12
purgeUnverifiedId uint32 = 13
purgeNoRoleId uint32 = 14
fingerprintLength = 64
internalError = "Internal server error\n"
)
@@ -98,6 +99,7 @@ func newPurgeEnv(t *testing.T) *mediaEnv {
env.users.users[purgeOwnerId] = user.User{ID: purgeOwnerId, Username: "owner", IsVerified: true, Role: user.RoleOwner}
env.users.users[purgeAdminId] = user.User{ID: purgeAdminId, Username: "admin", IsVerified: true, Role: user.RoleAdmin}
env.users.users[purgeEditorId] = user.User{ID: purgeEditorId, Username: "editor", IsVerified: true, Role: user.Role("editor")}
env.users.users[purgeNoRoleId] = user.User{ID: purgeNoRoleId, Username: "norole", IsVerified: true}
env.users.users[purgeUnverifiedId] = user.User{ID: purgeUnverifiedId, Username: "unverified", IsVerified: false, Role: user.RoleOwner}
return env
}
@@ -157,14 +159,14 @@ func TestPurgeRoutesRefuseCallersThatAreNotAllowed(t *testing.T) {
{http.MethodPost, "/v1/media/purge", 0, "no cookie", 401, ""},
{http.MethodGet, "/v1/media/purge-settings", mediaStaleUserId, "a stale token_version cookie", 401, apperr.UnauthorizedErrorMessage},
{http.MethodGet, "/v1/media/purge-settings", purgeUnverifiedId, "cookie of an unverified owner", 403, apperr.VerificationRequiredErrorMessage},
{http.MethodGet, "/v1/media/purge-settings", purgeEditorId, "editor", 403, apperr.OwnerOrAdminRequiredErrorMessage},
{http.MethodPatch, "/v1/media/purge-settings", purgeEditorId, "editor", 403, apperr.OwnerOrAdminRequiredErrorMessage},
{http.MethodGet, "/v1/media/purge-preview", purgeEditorId, "editor", 403, apperr.OwnerOrAdminRequiredErrorMessage},
{http.MethodPost, "/v1/media/purge", purgeEditorId, "editor", 403, apperr.OwnerOrAdminRequiredErrorMessage},
{http.MethodGet, "/v1/media/purge-settings", mediaVerifiedUserId, "empty role", 403, apperr.OwnerOrAdminRequiredErrorMessage},
{http.MethodPatch, "/v1/media/purge-settings", mediaVerifiedUserId, "empty role", 403, apperr.OwnerOrAdminRequiredErrorMessage},
{http.MethodGet, "/v1/media/purge-preview", mediaVerifiedUserId, "empty role", 403, apperr.OwnerOrAdminRequiredErrorMessage},
{http.MethodPost, "/v1/media/purge", mediaVerifiedUserId, "empty role", 403, apperr.OwnerOrAdminRequiredErrorMessage},
{http.MethodGet, "/v1/media/purge-settings", purgeEditorId, "editor", 403, apperr.MissingPermissionErrorMessage},
{http.MethodPatch, "/v1/media/purge-settings", purgeEditorId, "editor", 403, apperr.MissingPermissionErrorMessage},
{http.MethodGet, "/v1/media/purge-preview", purgeEditorId, "editor", 403, apperr.MissingPermissionErrorMessage},
{http.MethodPost, "/v1/media/purge", purgeEditorId, "editor", 403, apperr.MissingPermissionErrorMessage},
{http.MethodGet, "/v1/media/purge-settings", purgeNoRoleId, "empty role", 403, apperr.MissingPermissionErrorMessage},
{http.MethodPatch, "/v1/media/purge-settings", purgeNoRoleId, "empty role", 403, apperr.MissingPermissionErrorMessage},
{http.MethodGet, "/v1/media/purge-preview", purgeNoRoleId, "empty role", 403, apperr.MissingPermissionErrorMessage},
{http.MethodPost, "/v1/media/purge", purgeNoRoleId, "empty role", 403, apperr.MissingPermissionErrorMessage},
}
for _, example := range examples {
+1 -1
View File
@@ -28,7 +28,7 @@ func TestPurgeRoleGuardIsAnAllowlist(t *testing.T) {
rec := env.send(t, route.method, route.path, "", 20)
expectRaw(t, rec, 403, apperr.OwnerOrAdminRequiredErrorMessage+"\n")
expectRaw(t, rec, 403, apperr.MissingPermissionErrorMessage+"\n")
if env.purgeSettings.getCalls != 0 || env.purgeSettings.saveCalls != 0 {
t.Errorf("expected no purge setting access")
}
+2
View File
@@ -3,6 +3,7 @@ package test
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/internal/domain/entry"
"RenewCMS/internal/domain/user"
"encoding/json"
"net/http"
"net/http/httptest"
@@ -31,6 +32,7 @@ func eventRelationFields(venueRequired bool, speakersRequired bool) []map[string
func newRelationEnv(t *testing.T, venueRequired bool, speakersRequired bool) *relationEnv {
t.Helper()
env := &relationEnv{newContentTypeEnv(t)}
env.users.users[ctMemberId] = user.User{ID: ctMemberId, Username: "pat", IsVerified: true, RoleID: adminRoleID}
env.createType(t, "venue", []map[string]any{{"key": "title", "label": "Title", "type": "text", "required": true}, {"key": "city", "label": "City", "type": "text"}})
env.createType(t, "speaker", []map[string]any{{"key": "name", "label": "Name", "type": "text", "required": true}})
env.createType(t, "event", eventRelationFields(venueRequired, speakersRequired))
@@ -0,0 +1,499 @@
package test
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/user"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
const (
enforcementOwnerID uint32 = 3
enforcementAdminID uint32 = 5
enforcementWriterID uint32 = 9
enforcementUnverified uint32 = 12
enforcementOrphanID uint32 = 13
enforcementCustomID uint32 = 20
)
type gateOutcome struct {
rec *httptest.ResponseRecorder
reached bool
}
func (g gateOutcome) blocked() bool {
return !g.reached && g.rec.Code == http.StatusForbidden && g.rec.Body.String() == apperr.MissingPermissionErrorMessage+"\n"
}
func newEnforcementEnv(t *testing.T) *apiKeyEnv {
t.Helper()
e := newAPIKeyEnv(t)
e.addUser(enforcementOwnerID, user.RoleOwner, ownerRoleID, true)
e.addUser(enforcementAdminID, user.RoleAdmin, adminRoleID, true)
e.addUser(enforcementWriterID, "", writerRoleID, true)
e.addUser(enforcementUnverified, user.RoleAdmin, adminRoleID, false)
e.addUser(enforcementOrphanID, "", 99, true)
return e
}
func (e *apiKeyEnv) customRoleUser(id uint32, grants ...role.Grant) role.Role {
custom, _ := e.roles.Create(role.Role{Name: "Custom " + string(rune('a'+id)), Version: 1, Grants: grants})
e.addUser(id, "", custom.ID, true)
return custom
}
func (e *apiKeyEnv) gateRequest(t *testing.T, req *http.Request) gateOutcome {
t.Helper()
outcome := gateOutcome{rec: httptest.NewRecorder()}
func() {
defer func() {
if recover() != nil {
outcome.reached = true
}
}()
e.router.ServeHTTP(outcome.rec, req)
}()
return outcome
}
func (e *apiKeyEnv) gateAs(t *testing.T, userID uint32, method, path, body string) gateOutcome {
t.Helper()
req := httptest.NewRequest(method, path, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.AddCookie(e.cookie(t, userID, 0))
return e.gateRequest(t, req)
}
func (e *apiKeyEnv) gateWithKey(t *testing.T, raw, method, path, body string) gateOutcome {
t.Helper()
req := httptest.NewRequest(method, path, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+raw)
return e.gateRequest(t, req)
}
func assertGate(t *testing.T, got gateOutcome, wantKey string) {
t.Helper()
if wantKey == "" {
if got.blocked() {
t.Fatalf("expected the gate to pass, got %d %q (key %q)", got.rec.Code, got.rec.Body.String(), got.rec.Header().Get(apperr.MissingPermissionHeader))
}
return
}
if !got.blocked() {
t.Fatalf("expected missing_permission for %s, got reached=%v %d %q", wantKey, got.reached, got.rec.Code, got.rec.Body.String())
}
if header := got.rec.Header().Get(apperr.MissingPermissionHeader); header != wantKey {
t.Fatalf("expected %s header %q, got %q", apperr.MissingPermissionHeader, wantKey, header)
}
}
type gateRow struct {
caller uint32
method string
path string
body string
key string
}
func runGateRows(t *testing.T, rows []gateRow) {
t.Helper()
for _, row := range rows {
name := roleNameOf(row.caller) + " " + row.method + " " + row.path + " -> " + resultOf(row.key)
t.Run(name, func(t *testing.T) {
e := newEnforcementEnv(t)
assertGate(t, e.gateAs(t, row.caller, row.method, row.path, row.body), row.key)
})
}
}
func roleNameOf(id uint32) string {
switch id {
case enforcementOwnerID:
return "Owner"
case enforcementAdminID:
return "Admin"
case enforcementWriterID:
return "Writer"
}
return "user"
}
func resultOf(key string) string {
if key == "" {
return "ok"
}
return "403 " + key
}
func TestRolesPermissionsEnforcementHTTP(t *testing.T) {
const (
owner = enforcementOwnerID
admin = enforcementAdminID
writer = enforcementWriterID
)
t.Run("A refused request carries the missing_permission contract", func(t *testing.T) {
e := newEnforcementEnv(t)
got := e.gateAs(t, writer, http.MethodPost, "/v1/user/invite", `{"email": "new@example.com"}`)
assertGate(t, got, "users.invite")
if got.rec.Header().Get("Content-Type") == "" {
t.Error("expected a content type")
}
if len(e.invites.invites) != 0 || len(e.mail.SentEmails) != 0 {
t.Errorf("a refused invite must have no effect: %d invites, %d mails", len(e.invites.invites), len(e.mail.SentEmails))
}
})
t.Run("Verification and token checks still come first", func(t *testing.T) {
e := newEnforcementEnv(t)
unverified := e.send(t, apiKeyRequest{method: http.MethodPost, path: "/v1/user/invite", cookieUser: enforcementUnverified})
assertAPIKeyMessage(t, unverified, http.StatusForbidden, apperr.VerificationRequiredErrorMessage)
stored := e.users.users[writer]
stored.TokenVersion = 4
e.users.users[writer] = stored
stale := e.send(t, apiKeyRequest{method: http.MethodPost, path: "/v1/user/invite", cookieUser: writer, cookieVersion: 0})
assertAPIKeyMessage(t, stale, http.StatusUnauthorized, apperr.UnauthorizedErrorMessage)
anonymous := e.anonymous(t, http.MethodPost, "/v1/user/invite", "")
assertAPIKeyMessage(t, anonymous, http.StatusUnauthorized, apperr.UnauthorizedErrorMessage)
})
t.Run("Users and invites", func(t *testing.T) {
runGateRows(t, []gateRow{
{owner, http.MethodGet, "/v1/user", "", ""},
{admin, http.MethodGet, "/v1/user", "", ""},
{writer, http.MethodGet, "/v1/user", "", "users.view"},
{owner, http.MethodPost, "/v1/user/invite", "", ""},
{admin, http.MethodPost, "/v1/user/invite", "", ""},
{writer, http.MethodPost, "/v1/user/invite", "", "users.invite"},
{admin, http.MethodDelete, "/v1/user/invite/1", "", ""},
{writer, http.MethodDelete, "/v1/user/invite/1", "", "users.invite"},
{admin, http.MethodPost, "/v1/user/invite/1/renew", "", ""},
{writer, http.MethodPost, "/v1/user/invite/1/renew", "", "users.invite"},
})
})
t.Run("Settings, sites and system", func(t *testing.T) {
runGateRows(t, []gateRow{
{owner, http.MethodGet, "/v1/settings", "", ""},
{admin, http.MethodGet, "/v1/settings", "", ""},
{writer, http.MethodGet, "/v1/settings", "", "settings.view"},
{owner, http.MethodPatch, "/v1/settings", "", ""},
{admin, http.MethodPatch, "/v1/settings", "", "system.edit_retention"},
{writer, http.MethodPatch, "/v1/settings", "", "system.edit_retention"},
{owner, http.MethodPost, "/v1/site", "", ""},
{admin, http.MethodPost, "/v1/site", "", ""},
{writer, http.MethodPost, "/v1/site", "", "settings.manage_sites"},
{admin, http.MethodPatch, "/v1/site/2", "", ""},
{admin, http.MethodPatch, "/v1/site/2/default", "", ""},
{writer, http.MethodPatch, "/v1/site/2", "", "settings.manage_sites"},
{owner, http.MethodDelete, "/v1/site/2", "", ""},
{admin, http.MethodDelete, "/v1/site/2", "", "settings.delete_site"},
{writer, http.MethodDelete, "/v1/site/2", "", "settings.delete_site"},
{writer, http.MethodGet, "/v1/site", "", ""},
{owner, http.MethodGet, "/v1/system/update", "", ""},
{admin, http.MethodGet, "/v1/system/update", "", ""},
{writer, http.MethodGet, "/v1/system/update", "", "settings.view"},
{owner, http.MethodPost, "/v1/system/update/check", "", ""},
{admin, http.MethodPost, "/v1/system/update/check", "", "system.apply_updates"},
{owner, http.MethodPost, "/v1/system/update/download", "", ""},
{admin, http.MethodPost, "/v1/system/update/download", "", "system.apply_updates"},
{owner, http.MethodPost, "/v1/system/update/restart", "", ""},
{admin, http.MethodPost, "/v1/system/update/restart", "", "system.apply_updates"},
{writer, http.MethodPost, "/v1/system/update/restart", "", "system.apply_updates"},
})
})
t.Run("The permission gate runs before the update rate limiters", func(t *testing.T) {
e := newEnforcementEnv(t)
for attempt := 1; attempt <= 6; attempt++ {
assertGate(t, e.gateAs(t, admin, http.MethodPost, "/v1/system/update/check", ""), "system.apply_updates")
}
})
t.Run("The permission gate runs before the invite rate limiter", func(t *testing.T) {
e := newEnforcementEnv(t)
e.settings.AuthRateLimit.MaxAttempts = 1
e.rebuildRouter()
first := e.gateAs(t, owner, http.MethodPost, "/v1/user/invite", `{"email": "first@example.com"}`)
if first.blocked() {
t.Fatalf("owner invite refused: %q", first.rec.Body.String())
}
if again := e.gateAs(t, owner, http.MethodPost, "/v1/user/invite", `{"email": "second@example.com"}`); again.rec.Code != http.StatusTooManyRequests {
t.Fatalf("limiter is not exhausted, status %d", again.rec.Code)
}
got := e.gateAs(t, writer, http.MethodPost, "/v1/user/invite", `{"email": "third@example.com"}`)
assertGate(t, got, "users.invite")
})
t.Run("An admin reads the update status but cannot apply it", func(t *testing.T) {
e := newEnforcementEnv(t)
e.container.GetUpdateStatusUseCase = newUpdateStatusUseCase(e.updates, &fakeJobStore{}, &fakeHelperDetector{installed: true}, apiKeyRunningVersion)
e.rebuildRouter()
canApply := func(id uint32) bool {
rec := e.withCookie(t, http.MethodGet, "/v1/system/update", "", id)
assertAPIKeyStatus(t, rec, http.StatusOK)
var body struct {
CanApply bool `json:"can_apply"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
return body.CanApply
}
if canApply(admin) {
t.Error("admin must not be able to apply")
}
if !canApply(owner) {
t.Error("owner must be able to apply with the helper installed")
}
})
t.Run("An admin reads the activity retention but cannot change it", func(t *testing.T) {
e := newEnforcementEnv(t)
rec := e.withCookie(t, http.MethodGet, "/v1/settings", "", admin)
assertAPIKeyStatus(t, rec, http.StatusOK)
for _, field := range []string{"activity_retention_days", "activity_retention_max_gb"} {
if !strings.Contains(rec.Body.String(), field) {
t.Errorf("settings body lacks %s: %s", field, rec.Body.String())
}
}
assertGate(t, e.gateAs(t, admin, http.MethodPatch, "/v1/settings", `{"activity_retention_days": 30}`), "system.edit_retention")
})
t.Run("Authors, schema, media and API key creation", func(t *testing.T) {
runGateRows(t, []gateRow{
{owner, http.MethodGet, "/v1/author", "", ""},
{writer, http.MethodGet, "/v1/author", "", ""},
{admin, http.MethodPost, "/v1/author", "", ""},
{writer, http.MethodPost, "/v1/author", "", "authors.create"},
{admin, http.MethodDelete, "/v1/author/4", "", ""},
{writer, http.MethodDelete, "/v1/author/4", "", "authors.delete"},
{writer, http.MethodPost, "/v1/author/me", "", ""},
{admin, http.MethodGet, "/v1/content-type", "", ""},
{writer, http.MethodGet, "/v1/content-type", "", ""},
{writer, http.MethodGet, "/v1/content-type/events", "", ""},
{admin, http.MethodPost, "/v1/content-type", "", ""},
{writer, http.MethodPost, "/v1/content-type", "", "schema.edit"},
{writer, http.MethodPut, "/v1/content-type/events", "", "schema.edit"},
{admin, http.MethodDelete, "/v1/content-type/events", "", ""},
{writer, http.MethodDelete, "/v1/content-type/events", "", "schema.delete"},
{writer, http.MethodGet, "/v1/builtin-type", "", ""},
{writer, http.MethodGet, "/v1/media", "", ""},
{writer, http.MethodGet, "/v1/media/1", "", ""},
{writer, http.MethodGet, "/v1/media/1/usage", "", ""},
{writer, http.MethodPost, "/v1/media", "", ""},
{writer, http.MethodPatch, "/v1/media/1", "", ""},
{writer, http.MethodDelete, "/v1/media/1", "", "media.delete"},
{writer, http.MethodPost, "/v1/media/merge", "", "media.delete"},
{admin, http.MethodDelete, "/v1/media/1", "", ""},
{writer, http.MethodGet, "/v1/media/purge-settings", "", "settings.view"},
{writer, http.MethodPatch, "/v1/media/purge-settings", "", "settings.edit_general"},
{writer, http.MethodGet, "/v1/media/purge-preview", "", "media.delete"},
{writer, http.MethodPost, "/v1/media/purge", "", "media.delete"},
{admin, http.MethodPost, "/v1/media/purge", "", ""},
{writer, http.MethodGet, "/v1/media/1/variants", "", ""},
{writer, http.MethodPost, "/v1/media/1/variants", "", "settings.edit_general"},
{writer, http.MethodDelete, "/v1/media/1/variants/hero", "", "settings.edit_general"},
{writer, http.MethodGet, "/v1/image-variants", "", ""},
{writer, http.MethodGet, "/v1/image-variants/cache", "", "settings.view"},
{writer, http.MethodPost, "/v1/image-variants", "", "settings.edit_general"},
{writer, http.MethodPost, "/v1/image-variants/cache/purge", "", "settings.edit_general"},
{admin, http.MethodPut, "/v1/image-variants/hero", "", ""},
{admin, http.MethodPost, "/v1/api-key", `{"name": "ci"}`, ""},
{writer, http.MethodPost, "/v1/api-key", `{"name": "ci"}`, "apikeys.create"},
})
})
t.Run("Routes that keep their old behaviour for API keys", func(t *testing.T) {
e := newEnforcementEnv(t)
raw, _ := e.issueKey(admin, "ci")
for _, route := range [][2]string{{http.MethodPost, "/v1/api-key"}, {http.MethodGet, "/v1/activity"}, {http.MethodPost, "/v1/system/update/check"}} {
rec := e.withKey(t, route[0], route[1], "", raw)
assertAPIKeyMessage(t, rec, http.StatusForbidden, apperr.SessionRequiredErrorMessage)
}
rec := e.withKey(t, http.MethodGet, "/v1/media/1/variants", "", raw)
assertAPIKeyMessage(t, rec, http.StatusUnauthorized, apperr.UnauthorizedErrorMessage)
})
t.Run("Roles are judged by the keys they hold, with no implied keys", func(t *testing.T) {
rows := []struct {
grants []role.Grant
method string
path string
key string
}{
{grantsOf("schema.edit"), http.MethodPost, "/v1/content-type", ""},
{grantsOf("schema.edit"), http.MethodGet, "/v1/content-type", "schema.view"},
{grantsOf("schema.view"), http.MethodPut, "/v1/content-type/events", "schema.edit"},
{grantsOf("users.invite"), http.MethodGet, "/v1/user", "users.view"},
{grantsOf("users.view"), http.MethodPost, "/v1/user/invite", "users.invite"},
{grantsOf("settings.manage_sites"), http.MethodDelete, "/v1/site/2", "settings.delete_site"},
{grantsOf("settings.delete_site"), http.MethodPatch, "/v1/site/2", "settings.manage_sites"},
{grantsOf("media.upload"), http.MethodGet, "/v1/media", "media.view"},
{grantsOf("media.view"), http.MethodPost, "/v1/media", "media.upload"},
{grantsOf("apikeys.create"), http.MethodGet, "/v1/api-key", "apikeys.view"},
{grantsOf("settings.edit_general"), http.MethodPatch, "/v1/media/purge-settings", ""},
{grantsOf("system.apply_updates"), http.MethodPost, "/v1/system/update/check", ""},
{nil, http.MethodGet, "/v1/author", "authors.view"},
}
for _, row := range rows {
names := []string{}
for _, grant := range row.grants {
names = append(names, grant.Key)
}
t.Run(strings.Join(names, ",")+" "+row.method+" "+row.path+" -> "+resultOf(row.key), func(t *testing.T) {
e := newEnforcementEnv(t)
e.customRoleUser(enforcementCustomID, row.grants...)
assertGate(t, e.gateAs(t, enforcementCustomID, row.method, row.path, ""), row.key)
})
}
})
t.Run("A user whose role cannot be resolved is refused everywhere that is gated", func(t *testing.T) {
e := newEnforcementEnv(t)
assertGate(t, e.gateAs(t, enforcementOrphanID, http.MethodGet, "/v1/user", ""), "users.view")
assertGate(t, e.gateAs(t, enforcementOrphanID, http.MethodGet, "/v1/content-type", ""), "schema.view")
assertGate(t, e.gateAs(t, enforcementOrphanID, http.MethodPost, "/v1/media", ""), "media.upload")
})
t.Run("A repository failure while resolving the role is a server error", func(t *testing.T) {
e := newEnforcementEnv(t)
e.roles.fail.failNext("Get")
rec := e.withCookie(t, http.MethodGet, "/v1/user", "", owner)
assertAPIKeyMessage(t, rec, http.StatusInternalServerError, "Internal server error")
})
t.Run("The legacy role string is no longer read for authorization", func(t *testing.T) {
rows := []struct {
id uint32
legacy user.Role
roleID uint32
method string
path string
key string
}{
{14, user.RoleOwner, writerRoleID, http.MethodPost, "/v1/user/invite", "users.invite"},
{15, user.RoleAdmin, writerRoleID, http.MethodPost, "/v1/content-type", "schema.edit"},
{16, "", adminRoleID, http.MethodPost, "/v1/content-type", ""},
{17, user.RoleOwner, 0, http.MethodPost, "/v1/user/invite", ""},
{18, user.RoleAdmin, 0, http.MethodPost, "/v1/content-type", ""},
{19, user.RoleAdmin, 0, http.MethodPatch, "/v1/settings", "system.edit_retention"},
}
for _, row := range rows {
t.Run(string(row.legacy)+" "+row.method+" "+row.path+" -> "+resultOf(row.key), func(t *testing.T) {
e := newEnforcementEnv(t)
e.addUser(row.id, row.legacy, row.roleID, true)
assertGate(t, e.gateAs(t, row.id, row.method, row.path, ""), row.key)
})
}
})
t.Run("A session follows its role live", func(t *testing.T) {
e := newEnforcementEnv(t)
assertGate(t, e.gateAs(t, writer, http.MethodGet, "/v1/media/purge-settings", ""), "settings.view")
stored := e.roles.roles[writerRoleID]
stored.Grants = append(stored.Grants, role.Grant{Key: "settings.view", Scope: role.ScopeAll})
e.roles.roles[writerRoleID] = stored
rec := e.withCookie(t, http.MethodGet, "/v1/settings", "", writer)
assertAPIKeyStatus(t, rec, http.StatusOK)
})
t.Run("Owner-only items stay owner-only", func(t *testing.T) {
e := newEnforcementEnv(t)
for _, route := range [][2]string{{http.MethodDelete, "/v1/site/2"}, {http.MethodPatch, "/v1/settings"}, {http.MethodPost, "/v1/system/update/check"}, {http.MethodPost, "/v1/system/update/download"}, {http.MethodPost, "/v1/system/update/restart"}} {
if !e.gateAs(t, admin, route[0], route[1], "").blocked() {
t.Errorf("admin passed %s %s", route[0], route[1])
}
if e.gateAs(t, owner, route[0], route[1], "").blocked() {
t.Errorf("owner blocked on %s %s", route[0], route[1])
}
}
})
t.Run("An API key resolves its creator's current role on every request", func(t *testing.T) {
e := newEnforcementEnv(t)
raw, _ := e.issueKey(admin, "ci")
assertGate(t, e.gateWithKey(t, raw, http.MethodGet, "/v1/content-type", ""), "")
assertGate(t, e.gateWithKey(t, raw, http.MethodPost, "/v1/user/invite", `{"email": "k@example.com"}`), "")
changed := e.users.users[admin]
changed.RoleID = writerRoleID
e.users.users[admin] = changed
assertGate(t, e.gateWithKey(t, raw, http.MethodPost, "/v1/user/invite", `{"email": "k2@example.com"}`), "users.invite")
assertGate(t, e.gateWithKey(t, raw, http.MethodGet, "/v1/content-type", ""), "")
assertGate(t, e.gateWithKey(t, raw, http.MethodPost, "/v1/content-type", ""), "schema.edit")
})
t.Run("A key grows when its creator's role grows", func(t *testing.T) {
e := newEnforcementEnv(t)
raw, _ := e.issueKey(writer, "ci")
assertGate(t, e.gateWithKey(t, raw, http.MethodPost, "/v1/content-type", ""), "schema.edit")
changed := e.users.users[writer]
changed.RoleID = adminRoleID
e.users.users[writer] = changed
assertGate(t, e.gateWithKey(t, raw, http.MethodPost, "/v1/content-type", ""), "")
})
t.Run("Editing the creator's role grants shrinks the key as well", func(t *testing.T) {
e := newEnforcementEnv(t)
custom := e.customRoleUser(enforcementCustomID, grantsOf("media.view", "media.upload")...)
raw, _ := e.issueKey(enforcementCustomID, "ci")
assertGate(t, e.gateWithKey(t, raw, http.MethodPost, "/v1/media", ""), "")
stored := e.roles.roles[custom.ID]
stored.Grants = grantsOf("media.view")
e.roles.roles[custom.ID] = stored
assertGate(t, e.gateWithKey(t, raw, http.MethodPost, "/v1/media", ""), "media.upload")
assertGate(t, e.gateWithKey(t, raw, http.MethodGet, "/v1/media", ""), "")
})
t.Run("A key whose creator's role cannot be resolved is refused by the gate", func(t *testing.T) {
e := newEnforcementEnv(t)
raw, _ := e.issueKey(enforcementOrphanID, "ci")
assertGate(t, e.gateWithKey(t, raw, http.MethodGet, "/v1/media", ""), "media.view")
})
t.Run("Key authentication failures are unchanged", func(t *testing.T) {
e := newEnforcementEnv(t)
rec := e.withKey(t, http.MethodGet, "/v1/media", "", "rcms_unknownunknownunknownunknownunknownunknown1")
assertAPIKeyMessage(t, rec, http.StatusUnauthorized, apperr.UnauthorizedErrorMessage)
})
t.Run("Public and self-service routes keep no permission gate", func(t *testing.T) {
e := newEnforcementEnv(t)
e.customRoleUser(enforcementCustomID)
for _, route := range [][2]string{{http.MethodGet, "/v1/auth/me"}, {http.MethodGet, "/v1/permission"}, {http.MethodGet, "/v1/site"}, {http.MethodGet, "/v1/user/lookup?ids=1"}, {http.MethodPatch, "/v1/user/me"}, {http.MethodPost, "/v1/author/me"}} {
if e.gateAs(t, enforcementCustomID, route[0], route[1], `{}`).blocked() {
t.Errorf("%s %s is gated", route[0], route[1])
}
}
if e.gateAs(t, enforcementCustomID, http.MethodGet, "/v1/article", "").blocked() {
t.Error("article list is gated")
}
})
t.Run("Articles and entries are not gated in this slice", func(t *testing.T) {
e := newEnforcementEnv(t)
e.customRoleUser(enforcementCustomID)
for _, route := range [][2]string{{http.MethodPost, "/v1/article"}, {http.MethodDelete, "/v1/article/1"}, {http.MethodGet, "/v1/content-type/events/entry"}, {http.MethodPost, "/v1/content-type/events/entry"}} {
if e.gateAs(t, enforcementCustomID, route[0], route[1], `{}`).blocked() {
t.Errorf("%s %s is gated", route[0], route[1])
}
}
})
}
func grantsOf(keys ...string) []role.Grant {
grants := make([]role.Grant, 0, len(keys))
for _, key := range keys {
grants = append(grants, role.Grant{Key: key, Scope: role.ScopeAll})
}
return grants
}
@@ -0,0 +1,197 @@
package test
import (
"go/ast"
"go/parser"
"go/token"
"io/fs"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"testing"
"RenewCMS/internal/domain/role"
)
var legacyRoleSpots = map[string]bool{
"internal/infrastructure/useCases/GetMyAccess.go": true,
"internal/infrastructure/useCases/CreateUser.go": true,
"internal/infrastructure/useCases/AcceptInvite.go": true,
}
var legacyRoleComparison = regexp.MustCompile(`user\.Role(Owner|Admin)|\.Role\s*(==|!=)`)
func productionGoFiles(t *testing.T, roots ...string) map[string]string {
t.Helper()
files := map[string]string{}
for _, root := range roots {
err := filepath.WalkDir(filepath.Join("..", root), func(path string, entry fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if entry.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
return nil
}
content, err := os.ReadFile(path)
if err != nil {
return err
}
files[strings.TrimPrefix(filepath.ToSlash(path), "../")] = string(content)
return nil
})
if err != nil {
t.Fatalf("walk %s: %v", root, err)
}
}
return files
}
func TestRolesPermissionsEnforcementHygiene(t *testing.T) {
t.Run("The owner and admin gates are gone from the code", func(t *testing.T) {
for name, content := range productionGoFiles(t, "api") {
if strings.Contains(content, "RequireOwner") {
t.Errorf("%s still mentions RequireOwner", name)
}
if strings.Contains(content, "OwnerRequiredErrorMessage") || strings.Contains(content, "OwnerOrAdminRequiredErrorMessage") {
t.Errorf("%s still mentions a removed owner message", name)
}
}
})
t.Run("No authorization decision reads the legacy user role", func(t *testing.T) {
for name, content := range productionGoFiles(t, "api", "internal/infrastructure/useCases") {
if legacyRoleSpots[name] {
continue
}
if match := legacyRoleComparison.FindString(content); match != "" {
t.Errorf("%s compares the legacy role: %q", name, match)
}
}
})
t.Run("The legacy role is read for resolution only in the allowed spots", func(t *testing.T) {
for name, content := range productionGoFiles(t, "internal/infrastructure/useCases") {
if strings.Contains(content, "CallerRole") || strings.Contains(content, "isAdminRole") {
t.Errorf("%s still carries a role-based caller", name)
}
}
})
}
var permissionGateNames = map[string]bool{"require": true, "requireAll": true}
func roleKeyConstants(t *testing.T) map[string]string {
t.Helper()
file, err := parser.ParseFile(token.NewFileSet(), filepath.Join("..", "internal", "domain", "role", "roleKeys.go"), nil, 0)
if err != nil {
t.Fatalf("parse roleKeys.go: %v", err)
}
constants := map[string]string{}
ast.Inspect(file, func(node ast.Node) bool {
spec, ok := node.(*ast.ValueSpec)
if !ok {
return true
}
for i, name := range spec.Names {
if i >= len(spec.Values) {
continue
}
if literal, ok := spec.Values[i].(*ast.BasicLit); ok && literal.Kind == token.STRING {
value, _ := strconv.Unquote(literal.Value)
constants[name.Name] = value
}
}
return true
})
return constants
}
func routerGateKeys(t *testing.T) []string {
t.Helper()
file, err := parser.ParseFile(token.NewFileSet(), filepath.Join("..", "api", "router.go"), nil, 0)
if err != nil {
t.Fatalf("parse router.go: %v", err)
}
constants := roleKeyConstants(t)
found := map[string]bool{}
ast.Inspect(file, func(node ast.Node) bool {
call, ok := node.(*ast.CallExpr)
if !ok || len(call.Args) != 1 {
return true
}
selector, ok := call.Fun.(*ast.SelectorExpr)
if !ok || !permissionGateNames[selector.Sel.Name] {
return true
}
switch arg := call.Args[0].(type) {
case *ast.BasicLit:
value, _ := strconv.Unquote(arg.Value)
found[value] = true
case *ast.SelectorExpr:
found[constants[arg.Sel.Name]] = true
default:
t.Errorf("gate %s called with an argument the test cannot resolve", selector.Sel.Name)
}
return true
})
keys := make([]string, 0, len(found))
for key := range found {
keys = append(keys, key)
}
sort.Strings(keys)
return keys
}
func unknownCatalogueKeys(keys []string) []string {
var unknown []string
for _, key := range keys {
if _, err := role.Lookup(key); err != nil {
unknown = append(unknown, key)
}
}
return unknown
}
func TestRouterPermissionGateKeysAreInTheCatalogue(t *testing.T) {
t.Run("Every key passed to a router permission gate is in the catalogue", func(t *testing.T) {
keys := routerGateKeys(t)
if len(keys) == 0 {
t.Fatal("no permission gate keys found in api/router.go")
}
if unknown := unknownCatalogueKeys(keys); len(unknown) > 0 {
t.Errorf("router gates use keys missing from the catalogue: %v", unknown)
}
})
t.Run("Chained gates contribute both keys", func(t *testing.T) {
keys := routerGateKeys(t)
for _, want := range []string{"activity.export", "activity.view"} {
if sort.SearchStrings(keys, want) == len(keys) || keys[sort.SearchStrings(keys, want)] != want {
t.Errorf("expected gate key %q to be found", want)
}
}
})
t.Run("The checking function catches a misspelled key", func(t *testing.T) {
cases := []struct {
name string
keys []string
want int
}{
{"valid keys", []string{"activity.view", "activity.export"}, 0},
{"misspelled key", []string{"activity.veiw"}, 1},
{"one bad among good", []string{"activity.view", "apikeys.revok"}, 1},
{"empty key", []string{""}, 1},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := len(unknownCatalogueKeys(tc.keys)); got != tc.want {
t.Errorf("unknown = %d, want %d", got, tc.want)
}
})
}
})
}
@@ -0,0 +1,373 @@
package test
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"context"
"encoding/csv"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"slices"
"strconv"
"strings"
"testing"
"time"
)
const scopedUserID uint32 = 6
func (e *activityHTTPEnv) customUser(id uint32, grants ...role.Grant) {
custom, _ := e.roles.Create(role.Role{Name: "Scoped " + strconv.Itoa(int(id)), Version: 1, Grants: grants})
e.users.users[id] = user.User{ID: id, Username: "scoped" + strconv.Itoa(int(id)), Email: "scoped@example.com", IsVerified: true, RoleID: custom.ID}
}
func (e *activityHTTPEnv) seedActorEvents(t *testing.T, ids ...uint32) {
t.Helper()
for _, id := range ids {
e.seed(t, activityUserEvent(id, "actor"+strconv.Itoa(int(id)), e.now.Add(-time.Hour)))
}
}
func activityActorsOf(t *testing.T, rec *httptest.ResponseRecorder) []uint32 {
t.Helper()
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d %q", rec.Code, rec.Body.String())
}
var body struct {
Items []struct {
Actor struct {
ID *uint32 `json:"id"`
} `json:"actor"`
} `json:"items"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode %q: %v", rec.Body.String(), err)
}
actors := []uint32{}
for _, item := range body.Items {
actors = append(actors, *item.Actor.ID)
}
slices.Sort(actors)
return actors
}
func csvActorsOf(t *testing.T, rec *httptest.ResponseRecorder) []string {
t.Helper()
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d %q", rec.Code, rec.Body.String())
}
rows, err := csv.NewReader(strings.NewReader(rec.Body.String())).ReadAll()
if err != nil {
t.Fatalf("csv: %v", err)
}
actors := []string{}
for _, row := range rows[1:] {
actors = append(actors, row[1])
}
slices.Sort(actors)
return actors
}
func scopedGrant(key string, scope role.Scope) role.Grant {
return role.Grant{Key: key, Scope: scope}
}
func assertMissing(t *testing.T, rec *httptest.ResponseRecorder, key string) {
t.Helper()
assertPlain(t, rec, http.StatusForbidden, apperr.MissingPermissionErrorMessage)
if got := rec.Header().Get(apperr.MissingPermissionHeader); got != key {
t.Errorf("expected %s %q, got %q", apperr.MissingPermissionHeader, key, got)
}
}
func TestRolesPermissionsEnforcementActivityScope(t *testing.T) {
t.Run("activity.view scope decides what the list returns", func(t *testing.T) {
rows := []struct {
name string
scope role.Scope
query string
actors []uint32
missing bool
}{
{"all", role.ScopeAll, "", []uint32{1, 2, scopedUserID}, false},
{"all with an actor", role.ScopeAll, "?actor=user:2", []uint32{2}, false},
{"own", role.ScopeOwn, "", []uint32{scopedUserID}, false},
{"own asking for self", role.ScopeOwn, "?actor=user:6", []uint32{scopedUserID}, false},
{"own asking for another actor", role.ScopeOwn, "?actor=user:1", nil, true},
{"none", role.ScopeNone, "", nil, true},
}
for _, row := range rows {
t.Run(row.name, func(t *testing.T) {
e := newActivityHTTPEnv(t, 1000)
e.customUser(scopedUserID, scopedGrant(role.KeyActivityView, row.scope))
e.seedActorEvents(t, 1, 2, scopedUserID)
rec := e.get("/v1/activity"+row.query, asUser(e, scopedUserID))
if row.missing {
assertMissing(t, rec, role.KeyActivityView)
return
}
if got := activityActorsOf(t, rec); !slices.Equal(got, row.actors) {
t.Errorf("expected actors %v, got %v", row.actors, got)
}
})
}
})
t.Run("Activity summary follows the scope", func(t *testing.T) {
e := newActivityHTTPEnv(t, 1000)
e.customUser(scopedUserID, scopedGrant(role.KeyActivityView, role.ScopeAll))
if rec := e.get("/v1/activity/summary", asUser(e, scopedUserID)); rec.Code != http.StatusOK {
t.Errorf("all: expected 200, got %d", rec.Code)
}
e.customUser(7, scopedGrant(role.KeyActivityView, role.ScopeOwn))
assertMissing(t, e.get("/v1/activity/summary", asUser(e, 7)), role.KeyActivityView)
})
t.Run("Activity export follows the scope", func(t *testing.T) {
rows := []struct {
name string
grants []role.Grant
actors []string
key string
}{
{"view all and export all", []role.Grant{scopedGrant(role.KeyActivityView, role.ScopeAll), scopedGrant(role.KeyActivityExport, role.ScopeAll)}, []string{"actor1", "actor2", "actor6"}, ""},
{"view own and export all", []role.Grant{scopedGrant(role.KeyActivityView, role.ScopeOwn), scopedGrant(role.KeyActivityExport, role.ScopeAll)}, []string{"actor6"}, ""},
{"view none and export all", []role.Grant{scopedGrant(role.KeyActivityView, role.ScopeNone), scopedGrant(role.KeyActivityExport, role.ScopeAll)}, nil, role.KeyActivityView},
{"view all without export", []role.Grant{scopedGrant(role.KeyActivityView, role.ScopeAll)}, nil, role.KeyActivityExport},
}
for _, row := range rows {
t.Run(row.name, func(t *testing.T) {
e := newActivityHTTPEnv(t, 1000)
e.customUser(scopedUserID, row.grants...)
e.seedActorEvents(t, 1, 2, scopedUserID)
rec := e.get("/v1/activity/export", asUser(e, scopedUserID))
if row.key != "" {
assertMissing(t, rec, row.key)
return
}
if got := csvActorsOf(t, rec); !slices.Equal(got, row.actors) {
t.Errorf("expected %v, got %v", row.actors, got)
}
})
}
})
t.Run("Writer is effective on activity", func(t *testing.T) {
e := newActivityHTTPEnv(t, 1000)
e.seedActorEvents(t, 1, 2, activityEditorID)
got := activityActorsOf(t, e.get("/v1/activity", asUser(e, activityEditorID)))
if !slices.Equal(got, []uint32{activityEditorID}) {
t.Errorf("expected only the writer's events, got %v", got)
}
})
}
func TestRolesPermissionsEnforcementAPIKeyScope(t *testing.T) {
seedKeys := func(e *apiKeyEnv) {
e.issueKey(enforcementOwnerID, "owner key")
e.issueKey(enforcementAdminID, "admin key")
e.issueKey(enforcementCustomID, "caller key")
}
t.Run("apikeys.view scope decides which keys are listed", func(t *testing.T) {
rows := []struct {
name string
scope role.Scope
names []string
named bool
key string
}{
{"all", role.ScopeAll, []string{"caller key", "admin key", "owner key"}, true, ""},
{"own", role.ScopeOwn, []string{"caller key"}, false, ""},
{"none", role.ScopeNone, nil, false, "apikeys.view"},
}
for _, row := range rows {
t.Run(row.name, func(t *testing.T) {
e := newEnforcementEnv(t)
e.customRoleUser(enforcementCustomID, scopedGrant("apikeys.view", row.scope))
seedKeys(e)
rec := e.withCookie(t, http.MethodGet, "/v1/api-key", "", enforcementCustomID)
if row.key != "" {
assertGate(t, gateOutcome{rec: rec}, row.key)
return
}
assertAPIKeyStatus(t, rec, http.StatusOK)
if got := apiKeyEntryNames(t, rec); !slices.Equal(got, row.names) {
t.Errorf("expected %v, got %v", row.names, got)
}
for _, entry := range apiKeyDecodeList(t, rec) {
if _, present := entry["created_by_name"]; present != row.named {
t.Errorf("created_by_name present=%v, expected %v", present, row.named)
}
}
})
}
})
t.Run("apikeys.revoke scope decides which keys can be revoked", func(t *testing.T) {
rows := []struct {
name string
scope role.Scope
status int
message string
revoked bool
}{
{"all", role.ScopeAll, http.StatusNoContent, "", true},
{"own", role.ScopeOwn, http.StatusNotFound, apperr.APIKeyNotFoundErrorMessage, false},
{"none", role.ScopeNone, http.StatusForbidden, apperr.MissingPermissionErrorMessage, false},
}
for _, row := range rows {
t.Run(row.name, func(t *testing.T) {
e := newEnforcementEnv(t)
e.customRoleUser(enforcementCustomID, scopedGrant("apikeys.revoke", row.scope))
_, id := e.issueKey(enforcementAdminID, "admin key")
rec := e.withCookie(t, http.MethodDelete, "/v1/api-key/"+strconv.Itoa(int(id)), "", enforcementCustomID)
assertAPIKeyMessage(t, rec, row.status, row.message)
if got := e.keys.keys[id].RevokedAt != nil; got != row.revoked {
t.Errorf("revoked=%v, expected %v", got, row.revoked)
}
})
}
})
t.Run("revoke none answers before any lookup", func(t *testing.T) {
repo := newMockAPIKeyRepo()
stored := seedStubKey(repo, "rcms_a", nil)
repo.fail.failNext("Get")
uc := useCases.NewRevokeAPIKeyUseCase(repo, apiKeyUnitClock, discardRecorder)
err := uc.RevokeAPIKey(testCtx, 2, stored.ID, role.NewGrantSet(nil))
if !errors.Is(err, role.ErrMissingPermission) {
t.Errorf("expected a missing permission, got %v", err)
}
})
t.Run("The Admin lists and revokes every key", func(t *testing.T) {
e := newEnforcementEnv(t)
_, ownerKey := e.issueKey(enforcementOwnerID, "owner key")
list := e.withCookie(t, http.MethodGet, "/v1/api-key", "", enforcementAdminID)
assertAPIKeyStatus(t, list, http.StatusOK)
entries := apiKeyListedByName(t, list)
if entries["owner key"]["created_by_name"] == nil {
t.Errorf("the owner's key lacks created_by_name: %v", entries)
}
revoke := e.withCookie(t, http.MethodDelete, "/v1/api-key/"+strconv.Itoa(int(ownerKey)), "", enforcementAdminID)
assertAPIKeyStatus(t, revoke, http.StatusNoContent)
})
t.Run("A list with no apikeys.view grant is refused by the use case as well", func(t *testing.T) {
uc := useCases.NewListAPIKeysUseCase(newMockAPIKeyRepo(), apiKeyUnitUsers(), apiKeyUnitClock)
if _, err := uc.ListAPIKeys(2, role.NewGrantSet(nil)); !errors.Is(err, role.ErrMissingPermission) {
t.Errorf("expected a missing permission, got %v", err)
}
})
}
func TestRolesPermissionsEnforcementAuthorScope(t *testing.T) {
const writerUser, ownerUser uint32 = 9, 3
newUseCase := func() (*useCases.UpdateAuthorUseCase, *mockAuthorRepo) {
authors := plainAuthors()
authors.seedLinked(4, "Writer", "writer", writerUser)
authors.seedLinked(5, "Owner", "owner", ownerUser)
users := newMockUserRepo()
users.users[writerUser] = user.User{ID: writerUser, Username: "writer", Email: "w@example.com"}
users.users[ownerUser] = user.User{ID: ownerUser, Username: "owner", Email: "o@example.com"}
return useCases.NewUpdateAuthorUseCase(authors, users, newMockImageRepo(), discardRecorder), authors
}
bio := "x"
t.Run("authors.edit scope decides who can edit an author", func(t *testing.T) {
rows := []struct {
name string
grants role.GrantSet
id uint32
patch author.Patch
want error
any bool
}{
{"all edits another author", authorsEditAllGrants, 5, author.Patch{Bio: &bio}, nil, false},
{"all may relink", authorsEditAllGrants, 5, author.Patch{UserID: author.Nullable[uint32]{Set: true, Value: idPointer(writerUser)}}, nil, true},
{"own edits its linked author", authorsEditOwnGrants, 4, author.Patch{Bio: &bio}, nil, false},
{"own cannot edit another author", authorsEditOwnGrants, 5, author.Patch{Bio: &bio}, author.ErrAuthorRights, false},
{"own cannot unlink", authorsEditOwnGrants, 4, author.Patch{UserID: author.Nullable[uint32]{Set: true}}, author.ErrAuthorRights, false},
{"none is refused", role.NewGrantSet(nil), 4, author.Patch{Bio: &bio}, role.ErrMissingPermission, false},
{"none is refused before any lookup", role.NewGrantSet(nil), 999, author.Patch{Bio: &bio}, role.ErrMissingPermission, false},
}
for _, row := range rows {
t.Run(row.name, func(t *testing.T) {
uc, _ := newUseCase()
_, err := uc.Update(context.Background(), row.id, useCases.AuthorCaller{UserID: writerUser, Grants: row.grants}, row.patch)
if row.any {
if errors.Is(err, author.ErrAuthorRights) || errors.Is(err, role.ErrMissingPermission) {
t.Errorf("unexpected refusal %v", err)
}
return
}
if !errors.Is(err, row.want) && (row.want != nil || err != nil) {
t.Errorf("expected %v, got %v", row.want, err)
}
})
}
})
t.Run("user_email on authors needs users.view", func(t *testing.T) {
for name, grants := range map[string]role.GrantSet{
"with users.view": role.NewGrantSet([]role.Grant{scopedGrant(role.KeyUsersView, role.ScopeAll)}),
"without users.view": role.NewGrantSet(nil),
} {
t.Run(name, func(t *testing.T) {
authors := plainAuthors()
authors.seedLinked(4, "Writer", "writer", writerUser)
users := newMockUserRepo()
users.users[writerUser] = user.User{ID: writerUser, Email: "w@example.com"}
page, err := useCases.NewListAuthorsUseCase(authors, users).List(useCases.ListAuthorsCommand{Page: 1, Size: 25, Grants: grants})
if err != nil || len(page.Items) != 1 {
t.Fatalf("unexpected %v %+v", err, page)
}
if shown := page.Items[0].UserEmail != nil; shown != grants.Holds(role.KeyUsersView) {
t.Errorf("user_email shown=%v for %s", shown, name)
}
})
}
})
t.Run("Writer sees author items without user_email and the Admin with it", func(t *testing.T) {
e := newAuthorEnv(t)
e.authors.seedLinked(2, "Edith", "edith", authorEditorID)
writerList := e.as(t, authorEditorID, http.MethodGet, "/v1/author/", "")
adminList := e.as(t, apiKeyDevId, http.MethodGet, "/v1/author/", "")
if writerList.Code != http.StatusOK || strings.Contains(writerList.Body.String(), "user_email") {
t.Errorf("writer: %d %s", writerList.Code, writerList.Body.String())
}
if adminList.Code != http.StatusOK || !strings.Contains(adminList.Body.String(), "edith@example.com") {
t.Errorf("admin: %d %s", adminList.Code, adminList.Body.String())
}
})
t.Run("A role without authors.edit is refused before any lookup", func(t *testing.T) {
e := newEnforcementEnv(t)
e.customRoleUser(enforcementCustomID, scopedGrant("authors.view", role.ScopeAll))
assertGate(t, e.gateAs(t, enforcementCustomID, http.MethodPatch, "/v1/author/999", `{"bio": "x"}`), "authors.edit")
})
}
@@ -0,0 +1,108 @@
package test
import (
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"bytes"
"context"
"encoding/csv"
"errors"
"strings"
"testing"
"time"
)
func TestRolesPermissionsEnforcementActivityExportPaging(t *testing.T) {
w := newActivityWorld(t)
for i := 0; i < 501; i++ {
w.seedAt(t, time.Duration(i+1)*time.Second, func(e *audit.Event) { e.Action = "article.edit" })
}
w.probe.maxReads = 5
prepared, err := w.export.Prepare(useCases.ExportActivityCommand{Grants: activityViewAllGrants})
if err != nil {
t.Fatalf("prepare failed: %v", err)
}
var out bytes.Buffer
if err := prepared.WriteCSV(&out); err != nil {
t.Fatalf("write failed: %v", err)
}
rows, err := csv.NewReader(strings.NewReader(out.String())).ReadAll()
if err != nil || len(rows) != 502 {
t.Fatalf("expected 502 rows, got %d (%v)", len(rows), err)
}
if len(w.probe.listCalls) != 2 || w.probe.listCalls[0].Page != 1 || w.probe.listCalls[1].Page != 2 {
t.Errorf("expected pages 1 and 2, got %+v", w.probe.listCalls)
}
}
func TestRolesPermissionsEnforcementAuthorLinking(t *testing.T) {
t.Run("linking an author to an unknown user is refused", func(t *testing.T) {
authors := plainAuthors()
users := newMockUserRepo()
create := useCases.NewCreateAuthorUseCase(authors, users, newMockImageRepo(), discardRecorder)
unknown := uint32(77)
_, err := create.Create(context.Background(), useCases.CreateAuthorCommand{Name: "Ghost", Slug: "ghost", UserID: &unknown})
if !errors.Is(err, user.ErrUserNotFound) || len(authors.authors) != 0 {
t.Fatalf("expected ErrUserNotFound and no author, got %v (%d)", err, len(authors.authors))
}
})
t.Run("linking a user who already has an author is refused", func(t *testing.T) {
authors := plainAuthors()
authors.seedLinked(1, "Taken", "taken", 5)
users := newMockUserRepo()
users.users[5] = user.User{ID: 5, Email: "five@example.com"}
create := useCases.NewCreateAuthorUseCase(authors, users, newMockImageRepo(), discardRecorder)
five := uint32(5)
_, err := create.Create(context.Background(), useCases.CreateAuthorCommand{Name: "Second", Slug: "second", UserID: &five})
if !errors.Is(err, author.ErrUserAlreadyLinked) {
t.Fatalf("expected ErrUserAlreadyLinked, got %v", err)
}
})
t.Run("user_email is set only for a linked author whose user exists", func(t *testing.T) {
authors := plainAuthors()
authors.seed(1, "Unlinked", "unlinked")
authors.seedLinked(2, "Linked", "linked", 5)
authors.seedLinked(3, "Dangling", "dangling", 99)
users := newMockUserRepo()
users.users[5] = user.User{ID: 5, Email: "five@example.com"}
grants := role.NewGrantSet([]role.Grant{scopedGrant(role.KeyUsersView, role.ScopeAll)})
page, err := useCases.NewListAuthorsUseCase(authors, users).List(useCases.ListAuthorsCommand{Page: 1, Size: 25, Grants: grants})
if err != nil || len(page.Items) != 3 {
t.Fatalf("unexpected %v %+v", err, page)
}
emails := map[uint32]*string{}
for _, item := range page.Items {
emails[item.ID] = item.UserEmail
}
if emails[1] != nil || emails[3] != nil {
t.Errorf("expected no email for unlinked and dangling, got %v %v", emails[1], emails[3])
}
if emails[2] == nil || *emails[2] != "five@example.com" {
t.Errorf("expected the linked email, got %v", emails[2])
}
})
}
func TestRolesPermissionsEnforcementMissingPermissionMessage(t *testing.T) {
err := role.MissingPermissionError{Key: "users.invite"}
if got := err.Error(); got != role.ErrMissingPermission.Error()+": users.invite" {
t.Errorf("unexpected message %q", got)
}
if !errors.Is(err, role.ErrMissingPermission) || errors.Is(err, role.ErrRoleNotFound) {
t.Errorf("unexpected Is behaviour")
}
}
+172
View File
@@ -0,0 +1,172 @@
package test
import (
"RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"errors"
"testing"
)
func missingKeyOf(t *testing.T, err error) string {
t.Helper()
var missing role.MissingPermissionError
if !errors.As(err, &missing) {
t.Fatalf("expected a MissingPermissionError, got %v", err)
}
return missing.Key
}
func TestCheckPermissionUseCase(t *testing.T) {
newCheck := func() (*useCases.CheckPermissionUseCase, *mockRoleRepo) {
roles := newSeededMockRoleRepo()
return useCases.NewCheckPermissionUseCase(roles), roles
}
owner := user.User{ID: 3, RoleID: ownerRoleID}
admin := user.User{ID: 5, RoleID: adminRoleID}
writer := user.User{ID: 9, RoleID: writerRoleID}
t.Run("Require passes for held keys and refuses the others", func(t *testing.T) {
check, _ := newCheck()
cases := []struct {
name string
account user.User
key string
allowed bool
}{
{"owner holds an owner-only key", owner, "system.apply_updates", true},
{"admin lacks an owner-only key", admin, "system.apply_updates", false},
{"admin holds users.invite", admin, "users.invite", true},
{"writer lacks users.invite", writer, "users.invite", false},
{"writer holds schema.view", writer, "schema.view", true},
{"writer holds a scoped key at own", writer, "activity.view", true},
{"an unknown key is refused", owner, "no.such_key", false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
_, err := check.Require(c.account, c.key)
if c.allowed && err != nil {
t.Fatalf("expected pass, got %v", err)
}
if !c.allowed {
if !errors.Is(err, role.ErrMissingPermission) || missingKeyOf(t, err) != c.key {
t.Fatalf("expected missing permission for %s, got %v", c.key, err)
}
}
})
}
})
t.Run("RequireAll refuses a scoped key held only at own", func(t *testing.T) {
check, _ := newCheck()
if _, err := check.RequireAll(writer, "activity.view"); !errors.Is(err, role.ErrMissingPermission) {
t.Fatalf("expected refusal, got %v", err)
}
if _, err := check.RequireAll(admin, "activity.view"); err != nil {
t.Fatalf("expected admin to pass, got %v", err)
}
})
t.Run("Require returns the resolved grants", func(t *testing.T) {
check, _ := newCheck()
grants, err := check.Require(writer, "authors.view")
if err != nil {
t.Fatal(err)
}
if grants.Scope("authors.edit") != role.ScopeOwn || grants.Scope("users.view") != role.ScopeNone {
t.Errorf("unexpected grants %v", grants)
}
})
t.Run("A custom role is judged by its grants only, owner-only keys included", func(t *testing.T) {
check, roles := newCheck()
custom, _ := roles.Create(role.Role{Name: "Hand made", Version: 1, Grants: []role.Grant{{Key: "system.apply_updates", Scope: role.ScopeAll}}})
account := user.User{ID: 20, RoleID: custom.ID}
if _, err := check.Require(account, "system.apply_updates"); err != nil {
t.Errorf("expected the hand-placed owner-only key to be honoured, got %v", err)
}
if _, err := check.Require(account, "schema.view"); !errors.Is(err, role.ErrMissingPermission) {
t.Errorf("expected no implied keys, got %v", err)
}
})
t.Run("An unresolvable role is refused as a missing permission", func(t *testing.T) {
check, _ := newCheck()
for name, account := range map[string]user.User{
"unknown role_id": {ID: 13, RoleID: 99},
"empty legacy and no id": {ID: 14, Role: ""},
"editor legacy and no id": {ID: 15, Role: "editor"},
"legacy owner string ignored when role_id names Writer": {ID: 16, Role: user.RoleOwner, RoleID: writerRoleID},
} {
_, err := check.Require(account, "users.invite")
if !errors.Is(err, role.ErrMissingPermission) || missingKeyOf(t, err) != "users.invite" {
t.Errorf("%s: got %v", name, err)
}
}
})
t.Run("The legacy string resolves only owner and admin when there is no role_id", func(t *testing.T) {
check, _ := newCheck()
if _, err := check.Require(user.User{ID: 17, Role: user.RoleOwner}, "system.apply_updates"); err != nil {
t.Errorf("legacy owner: %v", err)
}
if _, err := check.Require(user.User{ID: 18, Role: user.RoleAdmin}, "system.apply_updates"); !errors.Is(err, role.ErrMissingPermission) {
t.Errorf("legacy admin should lack owner-only keys, got %v", err)
}
})
t.Run("A repository failure is not a missing permission", func(t *testing.T) {
check, roles := newCheck()
roles.fail.failNext("Get")
_, err := check.Require(owner, "users.view")
if err == nil || errors.Is(err, role.ErrMissingPermission) {
t.Fatalf("expected a plain failure, got %v", err)
}
roles.fail.failNext("ListGrants")
_, err = check.Require(owner, "users.view")
if err == nil || errors.Is(err, role.ErrMissingPermission) {
t.Fatalf("expected a plain failure, got %v", err)
}
})
t.Run("Grant changes apply to the next call with no cache", func(t *testing.T) {
check, roles := newCheck()
if _, err := check.Require(writer, "settings.view"); !errors.Is(err, role.ErrMissingPermission) {
t.Fatalf("expected refusal first, got %v", err)
}
stored := roles.roles[writerRoleID]
stored.Grants = append(stored.Grants, role.Grant{Key: "settings.view", Scope: role.ScopeAll})
roles.roles[writerRoleID] = stored
if _, err := check.Require(writer, "settings.view"); err != nil {
t.Fatalf("expected pass after the edit, got %v", err)
}
})
}
func TestGrantSet(t *testing.T) {
t.Run("an absent key is none and a nil set holds nothing", func(t *testing.T) {
var empty role.GrantSet
if empty.Scope("users.view") != role.ScopeNone || empty.Holds("users.view") || empty.HoldsAll("users.view") {
t.Error("a nil set must hold nothing")
}
})
t.Run("Holds is true for own and all, HoldsAll only for all", func(t *testing.T) {
set := role.NewGrantSet([]role.Grant{{Key: "a", Scope: role.ScopeOwn}, {Key: "b", Scope: role.ScopeAll}, {Key: "c", Scope: role.ScopeNone}})
if !set.Holds("a") || set.HoldsAll("a") || !set.Holds("b") || !set.HoldsAll("b") || set.Holds("c") {
t.Errorf("unexpected %v", set)
}
})
t.Run("MissingPermissionError matches the sentinel and carries the key", func(t *testing.T) {
err := error(role.MissingPermissionError{Key: "users.invite"})
if !errors.Is(err, role.ErrMissingPermission) || missingKeyOf(t, err) != "users.invite" {
t.Errorf("unexpected %v", err)
}
})
}
var (
activityViewAllGrants = role.NewGrantSet([]role.Grant{{Key: role.KeyActivityView, Scope: role.ScopeAll}})
activityViewOwnGrants = role.NewGrantSet([]role.Grant{{Key: role.KeyActivityView, Scope: role.ScopeOwn}})
authorsEditOwnGrants = role.NewGrantSet([]role.Grant{{Key: role.KeyAuthorsEdit, Scope: role.ScopeOwn}})
authorsEditAllGrants = role.NewGrantSet([]role.Grant{{Key: role.KeyAuthorsEdit, Scope: role.ScopeAll}})
)
@@ -308,7 +308,7 @@ func TestExistingRoleChecksAreUntouched(t *testing.T) {
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 invites (allowed since roles_permissions_enforcement)", apiKeyDevId, http.MethodPost, "/v1/user/invite", `{"email":"new@example.com"}`, false},
{"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},
@@ -317,8 +317,8 @@ func TestExistingRoleChecksAreUntouched(t *testing.T) {
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 || rec.Body.String() != apperr.MissingPermissionErrorMessage+"\n") {
t.Errorf("expected missing_permission 403, got %d %q", rec.Code, rec.Body.String())
}
if !c.denied && rec.Code == http.StatusForbidden {
t.Errorf("unexpected 403 %q", rec.Body.String())
@@ -136,6 +136,7 @@ func newRuntimeSettingsContainer(t *testing.T) *api.UseCases {
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo()),
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
GetUserUseCase: useCases.NewGetUserUseCase(userRepo),
CheckPermissionUseCase: useCases.NewCheckPermissionUseCase(newSeededMockRoleRepo()),
GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()),
GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()),
CreateUserUseCase: useCases.NewCreateUserUseCase(userRepo, hasher, mailRepo, "https://cms.example.com", discardRecorder),
+16 -5
View File
@@ -1,6 +1,7 @@
package test
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/site"
"net/http"
@@ -217,7 +218,7 @@ func TestSeoDataSiteFields(t *testing.T) {
})
t.Run("Other roles and keys are refused", func(t *testing.T) {
const ownerText, sessionText = "Only the owner can do this.", "This action needs a real session."
const ownerText, sessionText = apperr.MissingPermissionErrorMessage, "This action needs a real session."
cases := []struct {
name string
method string
@@ -225,10 +226,9 @@ func TestSeoDataSiteFields(t *testing.T) {
send func(e *seoEnv, t *testing.T, method string, path string, body string) *seoResult
text string
}{
{"PATCH with a verified admin", http.MethodPatch, "/v1/site/2", seoAsUser(apiKeyDevId), ownerText},
{"PATCH with a verified account with another role", http.MethodPatch, "/v1/site/2", seoAsUser(seoEditorID), ownerText},
{"PATCH with a valid API key", http.MethodPatch, "/v1/site/2", seoAsKeyOf(apiKeyDevId), sessionText},
{"POST with a verified admin", http.MethodPost, "/v1/site/", seoAsUser(apiKeyDevId), ownerText},
{"POST with a verified account with another role", http.MethodPost, "/v1/site/", seoAsUser(seoEditorID), ownerText},
{"POST with a valid API key", http.MethodPost, "/v1/site/", seoAsKeyOf(apiKeyDevId), sessionText},
}
for _, c := range cases {
@@ -262,9 +262,20 @@ func TestSeoDataSiteFields(t *testing.T) {
t.Run("The role is checked before the body", func(t *testing.T) {
e := newSeoEnv(t)
rec := e.as(t, apiKeyDevId, http.MethodPatch, "/v1/site/2", "{not json")
rec := e.as(t, seoEditorID, http.MethodPatch, "/v1/site/2", "{not json")
expectStatusAndText(t, rec, http.StatusForbidden, "Only the owner can do this.")
expectStatusAndText(t, rec, http.StatusForbidden, apperr.MissingPermissionErrorMessage)
})
t.Run("An admin edits the SEO defaults of a site", func(t *testing.T) {
e := newSeoEnv(t)
rec := e.as(t, apiKeyDevId, http.MethodPatch, "/v1/site/2", `{"default_robots": "noindex"}`)
got := decodeSiteBody(t, rec)
if rec.Code != http.StatusOK || got.DefaultRobots != "noindex" {
t.Errorf("status %d site %+v", rec.Code, got)
}
})
t.Run("A no-op site edit records nothing", func(t *testing.T) {
+1 -1
View File
@@ -41,7 +41,7 @@ type seoEnv struct {
func newSeoEnv(t *testing.T) *seoEnv {
t.Helper()
p := newProducersEnv(t)
p.users.users[seoEditorID] = user.User{ID: seoEditorID, Username: "edith", Email: "edith@example.com", IsVerified: true, Role: user.Role("editor")}
p.users.users[seoEditorID] = user.User{ID: seoEditorID, Username: "edith", Email: "edith@example.com", IsVerified: true, Role: user.Role("editor"), RoleID: writerRoleID}
p.users.nextID = seoEditorID
authors := newMockAuthorRepo(p.articles)
sites := newMockSiteRepo(p.articles)
+1 -1
View File
@@ -164,7 +164,7 @@ func TestSeoOutputSitemap(t *testing.T) {
t.Run("The route does not disturb the owner-only site routes", func(t *testing.T) {
e := newSeoOutEnv(t)
expectStatusAndText(t, e.call(t, "admin", http.MethodPatch, "/v1/site/2", `{"site_name": "x"}`), 403, apperr.OwnerRequiredErrorMessage)
expectStatusAndText(t, e.call(t, "editor", http.MethodPatch, "/v1/site/2", `{"site_name": "x"}`), 403, apperr.MissingPermissionErrorMessage)
})
t.Run("The browser can read the total header", func(t *testing.T) {
+1
View File
@@ -175,6 +175,7 @@ func newSessionRevocationEnv(t *testing.T) *sessionRevocationEnv {
container := &api.UseCases{
Token: authtoken.New([]byte(testJWTSecret), authtoken.WithRevocationStore(useCases.NewSessionRevocationUseCase(sessions, time.Now))),
GetUserUseCase: useCases.NewGetUserUseCase(userRepo),
CheckPermissionUseCase: useCases.NewCheckPermissionUseCase(newSeededMockRoleRepo()),
GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()),
GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()),
CreateUserUseCase: useCases.NewCreateUserUseCase(userRepo, hasher, newMockMailRepo(), "https://cms.example.com", discardRecorder),
+26 -4
View File
@@ -4,6 +4,7 @@ import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/internal/domain/site"
"net/http"
"net/http/httptest"
"reflect"
"strconv"
"strings"
@@ -11,8 +12,9 @@ import (
)
const (
siteOwnerOnlyBody = "Only the owner can do this."
siteOwnerOnlyBody = apperr.MissingPermissionErrorMessage
siteSessionOnlyBody = "This action needs a real session."
siteWriterID = 7
)
func TestSitesReading(t *testing.T) {
@@ -637,10 +639,11 @@ func TestSitesOnlyTheOwnerWrites(t *testing.T) {
{http.MethodDelete, "/v1/site/2", ""},
}
for _, request := range requests {
t.Run(request.method+" "+request.path+" with a verified admin's jwt cookie", func(t *testing.T) {
t.Run(request.method+" "+request.path+" with a verified writer's jwt cookie", func(t *testing.T) {
e := newSiteEnvWithShop(t)
e.addUser(siteWriterID, "", writerRoleID, true)
before := e.sites.snapshot()
rec := e.as(t, apiKeyDevId, request.method, request.path, request.body)
rec := e.as(t, siteWriterID, request.method, request.path, request.body)
expectSiteStatusAndBody(t, rec, http.StatusForbidden, siteOwnerOnlyBody)
if !reflect.DeepEqual(before, e.sites.snapshot()) {
t.Errorf("a site changed")
@@ -661,9 +664,28 @@ func TestSitesOnlyTheOwnerWrites(t *testing.T) {
t.Run("The role is checked before the body", func(t *testing.T) {
e := newSiteEnvWithMain(t)
e.addUser(siteWriterID, "", writerRoleID, true)
rec := e.as(t, apiKeyDevId, http.MethodPost, "/v1/site/", "{not json")
rec := e.as(t, siteWriterID, http.MethodPost, "/v1/site/", "{not json")
expectSiteStatusAndBody(t, rec, http.StatusForbidden, siteOwnerOnlyBody)
})
t.Run("An admin manages sites but cannot delete one", func(t *testing.T) {
e := newSiteEnvWithShop(t)
created := e.as(t, apiKeyDevId, http.MethodPost, "/v1/site/", `{"name": "Blog", "key": "blog", "base_url": "https://blog.example.com"}`)
renamed := e.as(t, apiKeyDevId, http.MethodPatch, "/v1/site/2", `{"name": "Boutique"}`)
madeDefault := e.as(t, apiKeyDevId, http.MethodPatch, "/v1/site/2/default", "")
for name, rec := range map[string]*httptest.ResponseRecorder{"create": created, "update": renamed, "default": madeDefault} {
if rec.Code >= http.StatusBadRequest {
t.Errorf("%s: status %d %s", name, rec.Code, rec.Body.String())
}
}
before := e.sites.snapshot()
expectSiteStatusAndBody(t, e.as(t, apiKeyDevId, http.MethodDelete, "/v1/site/2", ""), http.StatusForbidden, siteOwnerOnlyBody)
if !reflect.DeepEqual(before, e.sites.snapshot()) {
t.Errorf("a site changed")
}
})
}
+2 -1
View File
@@ -124,7 +124,7 @@ func TestSitesActivityLog(t *testing.T) {
{"PATCH /v1/site/999 as the owner", apiKeyOwnerId, http.MethodPatch, "/v1/site/999", `{"name": "X"}`},
{"DELETE /v1/site/999 as the owner", apiKeyOwnerId, http.MethodDelete, "/v1/site/999", ""},
{"POST /v1/site/ with an unknown image", apiKeyOwnerId, http.MethodPost, "/v1/site/", `{"name": "Other", "key": "other", "base_url": "https://other.example.com", "default_social_image_id": 999}`},
{"POST /v1/site/ as an admin (403)", apiKeyDevId, http.MethodPost, "/v1/site/", `{"name": "Shop", "key": "shop", "base_url": "https://shop.example.com"}`},
{"POST /v1/site/ as a writer (403)", siteWriterID, http.MethodPost, "/v1/site/", `{"name": "Shop", "key": "shop", "base_url": "https://shop.example.com"}`},
{"POST /v1/site/ with no credentials (401)", 0, http.MethodPost, "/v1/site/", `{"name": "Shop", "key": "shop", "base_url": "https://shop.example.com"}`},
{"PATCH /v1/site/abc as the owner", apiKeyOwnerId, http.MethodPatch, "/v1/site/abc", `{"name": "X"}`},
{"POST /v1/site/ with a malformed body", apiKeyOwnerId, http.MethodPost, "/v1/site/", "{not json"},
@@ -132,6 +132,7 @@ func TestSitesActivityLog(t *testing.T) {
for _, c := range cases {
t.Run(c.request, func(t *testing.T) {
e := newSiteEnvWithShop(t)
e.addUser(siteWriterID, "", writerRoleID, true)
e.as(t, c.as, c.method, c.path, c.body)
e.noEvent(t)
})
+2 -1
View File
@@ -38,6 +38,7 @@ func newDownloadHTTP(t *testing.T, rig *downloadRig) *updateHarness {
container := &api.UseCases{
Token: token,
GetUserUseCase: useCases.NewGetUserUseCase(users),
CheckPermissionUseCase: useCases.NewCheckPermissionUseCase(newSeededMockRoleRepo()),
GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()),
GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()),
GetUpdateStatusUseCase: rig.status,
@@ -184,7 +185,7 @@ func TestStartUpdateDownloadHTTP(t *testing.T) {
rec := h.sendAs(t, http.MethodPost, downloadPath, updateAdminId)
if rec.Code != http.StatusForbidden || rec.Body.String() != apperr.OwnerRequiredErrorMessage+"\n" {
if rec.Code != http.StatusForbidden || rec.Body.String() != apperr.MissingPermissionErrorMessage+"\n" {
t.Errorf("status = %d body = %q", rec.Code, rec.Body.String())
}
if _, found := rig.jobs.Load(); found || rig.fetcher.totalCalls() != 0 {
+2 -1
View File
@@ -63,6 +63,7 @@ func newUpdateHarness(t *testing.T, running string) *updateHarness {
container := &api.UseCases{
Token: token,
GetUserUseCase: useCases.NewGetUserUseCase(users),
CheckPermissionUseCase: useCases.NewCheckPermissionUseCase(newSeededMockRoleRepo()),
GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()),
GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()),
GetUpdateStatusUseCase: newUpdateStatusUseCase(store, &fakeJobStore{}, &fakeHelperDetector{}, running),
@@ -245,7 +246,7 @@ func TestUpdateCheckHTTP(t *testing.T) {
t.Run("An admin cannot check", func(t *testing.T) {
h := newUpdateHarness(t, updateRunning)
rec := h.sendAs(t, http.MethodPost, "/update/check", updateAdminId)
if rec.Code != http.StatusForbidden || rec.Body.String() != apperr.OwnerRequiredErrorMessage+"\n" {
if rec.Code != http.StatusForbidden || rec.Body.String() != apperr.MissingPermissionErrorMessage+"\n" {
t.Errorf("status = %d body = %q", rec.Code, rec.Body.String())
}
if h.feed.calls != 0 {
+7 -6
View File
@@ -56,10 +56,11 @@ func newRestartHTTP(t *testing.T) *restartHTTP {
h.markers = infraUpdate.NewJSONRestartRequestStore(h.markerPath)
h.resultFiles = infraUpdate.NewJSONResultStore(h.resultPath)
container := &api.UseCases{
Token: h.token,
GetUserUseCase: useCases.NewGetUserUseCase(newUpdateUserRepo()),
GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()),
GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()),
Token: h.token,
GetUserUseCase: useCases.NewGetUserUseCase(newUpdateUserRepo()),
CheckPermissionUseCase: useCases.NewCheckPermissionUseCase(newSeededMockRoleRepo()),
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(
@@ -260,8 +261,8 @@ func TestRestartHTTP(t *testing.T) {
{"no jwt cookie", 0, 0, http.StatusUnauthorized, ""},
{"a stale token version", updateOwnerId, 1, http.StatusUnauthorized, apperr.UnauthorizedErrorMessage + "\n"},
{"an unverified owner", updateUnverifiedOwner, 0, http.StatusForbidden, apperr.VerificationRequiredErrorMessage + "\n"},
{"an admin", updateAdminId, 0, http.StatusForbidden, apperr.OwnerRequiredErrorMessage + "\n"},
{"an admin with a bad body", updateAdminId, 0, http.StatusForbidden, apperr.OwnerRequiredErrorMessage + "\n"},
{"an admin", updateAdminId, 0, http.StatusForbidden, apperr.MissingPermissionErrorMessage + "\n"},
{"an admin with a bad body", updateAdminId, 0, http.StatusForbidden, apperr.MissingPermissionErrorMessage + "\n"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
+28 -10
View File
@@ -86,7 +86,7 @@ func newUserRolesFixture(t *testing.T, maxAttempts int) *userRolesFixture {
r.Use(middleware.RequireVerifiedUser(getUser))
r.Get("/", inviteHandler.ListUsers)
r.Group(func(r chi.Router) {
r.Use(middleware.RequireOwner(getUser))
r.Use(middleware.RequirePermission(getUser, useCases.NewCheckPermissionUseCase(newSeededMockRoleRepo()), "users.invite"))
r.Use(middleware.TrustedProxyRealIP(nil))
r.Use(middleware.NewRateLimiter(maxAttempts, time.Hour))
r.Post("/invite", inviteHandler.CreateInvite)
@@ -108,6 +108,13 @@ func (f *userRolesFixture) seedUser(id uint32, username string, role user.Role,
}
}
func (f *userRolesFixture) seedWriter(id uint32, username string) {
f.seedUser(id, username, "", true)
stored := f.users.users[id]
stored.RoleID = writerRoleID
f.users.users[id] = stored
}
func (f *userRolesFixture) do(t *testing.T, method, target, body string, cookie *http.Cookie) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(method, target, bytes.NewBufferString(body))
@@ -268,25 +275,36 @@ func TestUserRolesHttp(t *testing.T) {
}
})
t.Run("An admin cannot invite", func(t *testing.T) {
t.Run("An admin can invite", func(t *testing.T) {
f := newUserRolesFixture(t, 100)
f.seedUser(2, "jonas", user.RoleAdmin, true)
rec := f.do(t, http.MethodPost, "/v1/user/invite", rolesInviteBody, f.as(t, 2))
if rec.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d: %s", rec.Code, rec.Body.String())
}
})
t.Run("A writer cannot invite", func(t *testing.T) {
f := newUserRolesFixture(t, 100)
f.seedWriter(2, "jonas")
rec := f.do(t, http.MethodPost, "/v1/user/invite", rolesInviteBody, f.as(t, 2))
if rec.Code != http.StatusForbidden {
t.Fatalf("expected 403, got %d", rec.Code)
}
if rec.Body.String() != apperr.OwnerRequiredErrorMessage+"\n" {
if rec.Body.String() != apperr.MissingPermissionErrorMessage+"\n" {
t.Errorf("unexpected body %q", rec.Body.String())
}
assertNothingInvited(t, f)
})
for _, body := range []string{`{"email": "not-an-email"}`, `not json`} {
t.Run("The owner check comes before validation and the rate limiter: "+body, func(t *testing.T) {
t.Run("The permission check comes before validation and the rate limiter: "+body, func(t *testing.T) {
f := newUserRolesFixture(t, 100)
f.seedUser(2, "jonas", user.RoleAdmin, true)
f.seedWriter(2, "jonas")
rec := f.do(t, http.MethodPost, "/v1/user/invite", body, f.as(t, 2))
@@ -297,9 +315,9 @@ func TestUserRolesHttp(t *testing.T) {
})
}
t.Run("An admin's refused invites do not use up the rate limit", func(t *testing.T) {
t.Run("A writer's refused invites do not use up the rate limit", func(t *testing.T) {
f := newUserRolesFixture(t, 2)
f.seedUser(2, "jonas", user.RoleAdmin, true)
f.seedWriter(2, "jonas")
for i := 0; i < 3; i++ {
rec := f.do(t, http.MethodPost, "/v1/user/invite", rolesInviteBody, f.as(t, 2))
@@ -314,7 +332,7 @@ func TestUserRolesHttp(t *testing.T) {
f.seedUser(2, "jonas", user.RoleOwner, true)
cookie := f.as(t, 2)
stored := f.users.users[2]
stored.Role = user.RoleAdmin
stored.RoleID = writerRoleID
f.users.users[2] = stored
rec := f.do(t, http.MethodPost, "/v1/user/invite", rolesInviteBody, cookie)
@@ -384,7 +402,7 @@ func TestUserRolesUseCases(t *testing.T) {
})
}
func TestRequireOwnerRepositoryFailure(t *testing.T) {
func TestRequirePermissionRepositoryFailure(t *testing.T) {
t.Run("An unexpected repository error is a 500, not a 403", func(t *testing.T) {
token := authtoken.New([]byte("test-secret"))
repo := newMockUserRepo()
@@ -392,7 +410,7 @@ func TestRequireOwnerRepositoryFailure(t *testing.T) {
r := chi.NewRouter()
r.Use(authtoken.Verifier(token))
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireOwner(useCases.NewGetUserUseCase(repo)))
r.Use(middleware.RequirePermission(useCases.NewGetUserUseCase(repo), useCases.NewCheckPermissionUseCase(newSeededMockRoleRepo()), "users.invite"))
r.Post("/", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) })
repo.fail.failNext("Get")