374 lines
13 KiB
Go
374 lines
13 KiB
Go
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")
|
|
})
|
|
}
|