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") }) }