Files
RenewCMS/test/roles_api_http_test.go
T

561 lines
28 KiB
Go

package test
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/internal/domain/role"
"encoding/json"
"net/http"
"net/http/httptest"
"reflect"
"strconv"
"strings"
"testing"
)
type rolesAPIBody struct {
ID uint32 `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
BuiltIn bool `json:"built_in"`
Locked bool `json:"locked"`
Version uint32 `json:"version"`
Grants []role.Grant `json:"grants"`
Members int `json:"members"`
UpdatedAt *string `json:"updated_at"`
UpdatedBy *struct {
ID uint32 `json:"id"`
Name string `json:"name"`
} `json:"updated_by"`
}
func decodeRolesAPIBody(t *testing.T, rec *httptest.ResponseRecorder, status int) rolesAPIBody {
t.Helper()
if rec.Code != status {
t.Fatalf("expected %d, got %d %q", status, rec.Code, rec.Body.String())
}
var body rolesAPIBody
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode %q: %v", rec.Body.String(), err)
}
return body
}
func assertRolesAPIResult(t *testing.T, rec *httptest.ResponseRecorder, want string) {
t.Helper()
switch {
case strings.HasPrefix(want, "gate:"):
assertScopeWant(t, rec, refusedWant(strings.TrimPrefix(want, "gate:")))
case strings.HasPrefix(want, "escalation"):
assertEscalation(t, rec, strings.TrimPrefix(strings.TrimPrefix(want, "escalation"), ":"))
case strings.HasPrefix(want, "message:"):
parts := strings.SplitN(strings.TrimPrefix(want, "message:"), ":", 2)
status, _ := strconv.Atoi(parts[0])
assertAPIKeyMessage(t, rec, status, parts[1])
case strings.Contains(want, " "):
parts := strings.SplitN(want, " ", 2)
status, _ := strconv.Atoi(parts[0])
assertRefusal(t, rec, status, parts[1])
default:
status, _ := strconv.Atoi(want)
if rec.Code != status {
t.Fatalf("expected %d, got %d %q", status, rec.Code, rec.Body.String())
}
}
}
func TestRolesAPIGate(t *testing.T) {
rows := []struct {
name string
caller uint32
method string
path string
body string
want string
}{
{"Owner lists", raOwner, http.MethodGet, "/v1/role", "", "200"},
{"Admin lists", raAdmin, http.MethodGet, "/v1/role", "", "200"},
{"Viewer lists", raViewer, http.MethodGet, "/v1/role", "", "200"},
{"Viewer reads the Writer", raViewer, http.MethodGet, "/v1/role/3", "", "200"},
{"Writer lists", raWriter, http.MethodGet, "/v1/role", "", "gate:roles.view"},
{"Writer reads a malformed id", raWriter, http.MethodGet, "/v1/role/abc", "", "gate:roles.view"},
{"Viewer creates", raViewer, http.MethodPost, "/v1/role", `{"name": "X"}`, "gate:roles.manage"},
{"Viewer edits before any lookup", raViewer, http.MethodPut, "/v1/role/999", "", "gate:roles.manage"},
{"Viewer deletes", raViewer, http.MethodDelete, "/v1/role/999", "", "gate:roles.manage"},
{"Viewer reassigns", raViewer, http.MethodPost, "/v1/role/999/reassign", "", "gate:roles.manage"},
{"Viewer resets", raViewer, http.MethodPost, "/v1/role/3/reset", `{"version": 1}`, "gate:roles.manage"},
{"Writer edits a malformed id", raWriter, http.MethodPut, "/v1/role/abc", "", "gate:roles.manage"},
{"Writer deletes an unknown id", raWriter, http.MethodDelete, "/v1/role/999", "", "gate:roles.manage"},
{"no cookie", 0, http.MethodGet, "/v1/role", "", "message:401:" + apperr.UnauthorizedErrorMessage},
{"unverified Admin", raUnverified, http.MethodGet, "/v1/role", "", "message:403:" + apperr.VerificationRequiredErrorMessage},
}
for _, c := range rows {
t.Run(c.name, func(t *testing.T) {
world := newRolesAPIWorld(t)
assertRolesAPIResult(t, world.do(t, c.caller, c.method, c.path, c.body), c.want)
if c.method != http.MethodGet && len(world.recorder.events) != 0 {
t.Fatal("a refused call records nothing")
}
})
}
t.Run("API key created by user 5 reads", func(t *testing.T) {
world := newRolesAPIWorld(t)
raw, _ := world.issueKey(raAdmin, "ci")
assertRolesAPIResult(t, world.withKey(t, http.MethodGet, "/v1/role", "", raw), "200")
})
t.Run("API key created by user 5 writes nothing", func(t *testing.T) {
world := newRolesAPIWorld(t)
raw, _ := world.issueKey(raAdmin, "ci")
before := len(world.roles.roles)
for _, call := range []struct{ method, path, body string }{
{http.MethodPost, "/v1/role", `{"name": "Keyed"}`},
{http.MethodPut, world.path("Contributor"), rolesAPIUpdateBody("Contributor", "x", 1, rolesAPIContributorRows()...)},
{http.MethodDelete, world.path("Intern"), ""},
{http.MethodPost, world.path("Contributor") + "/reassign", `{"target_role_id": 3}`},
{http.MethodPost, world.path("Writer") + "/reset", `{"version": 1}`},
} {
assertRolesAPIResult(t, world.withKey(t, call.method, call.path, call.body, raw), "message:403:"+apperr.SessionRequiredErrorMessage)
}
if len(world.roles.roles) != before || len(world.recorder.events) != 0 {
t.Fatal("nothing may be stored or recorded")
}
})
t.Run("stale token_version", func(t *testing.T) {
world := newRolesAPIWorld(t)
rec := world.send(t, apiKeyRequest{method: http.MethodGet, path: "/v1/role", cookieUser: raAdmin, cookieVersion: 1})
assertAPIKeyStatus(t, rec, http.StatusUnauthorized)
})
t.Run("a session follows the role live", func(t *testing.T) {
world := newRolesAPIWorld(t)
assertRolesAPIResult(t, world.do(t, raViewer, http.MethodPost, "/v1/role", `{"name": "Live"}`), "gate:roles.manage")
viewer := world.role("Viewer")
viewer.Grants = append(viewer.Grants, role.Grant{Key: role.KeyRolesManage, Scope: role.ScopeAll})
world.roles.roles[viewer.ID] = viewer
assertRolesAPIResult(t, world.do(t, raViewer, http.MethodPost, "/v1/role", `{"name": "Live"}`), "201")
})
t.Run("reading one role and /auth/me never List", func(t *testing.T) {
world := newRolesAPIWorld(t)
world.resetCounters()
assertRolesAPIResult(t, world.do(t, raViewer, http.MethodGet, world.path("Contributor"), ""), "200")
if world.roles.calls["List"] != 0 {
t.Errorf("reading one role must not List, got %d", world.roles.calls["List"])
}
world.resetCounters()
assertRolesAPIResult(t, world.do(t, raViewer, http.MethodGet, "/v1/auth/me", ""), "200")
if world.roles.calls["List"] != 0 {
t.Errorf("/auth/me must not List, got %d", world.roles.calls["List"])
}
})
}
func TestRolesAPIReading(t *testing.T) {
t.Run("The list returns every role with grants and member counts", func(t *testing.T) {
world := newRolesAPIWorld(t)
rec := world.do(t, raAdmin, http.MethodGet, "/v1/role", "")
if rec.Code != http.StatusOK || !strings.Contains(rec.Header().Get("Content-Type"), "application/json") {
t.Fatalf("unexpected %d %q", rec.Code, rec.Header().Get("Content-Type"))
}
var listed []rolesAPIBody
if err := json.Unmarshal(rec.Body.Bytes(), &listed); err != nil {
t.Fatal(err)
}
members := map[string]int{}
for index, item := range listed {
members[item.Name] = item.Members
if index > 0 && listed[index-1].ID >= item.ID {
t.Fatal("the list must be ordered by id")
}
}
if listed[0].Name != "Owner" || listed[1].Name != "Admin" || listed[2].Name != "Writer" || len(listed[0].Grants) != 34 || len(listed[1].Grants) != 29 {
t.Fatalf("unexpected built-ins %+v", listed[:3])
}
for name, count := range map[string]int{"Owner": 1, "Admin": 3, "Writer": 1, "Contributor": 4, "Intern": 0} {
if members[name] != count {
t.Errorf("%s: expected %d members, got %d", name, count, members[name])
}
}
if !strings.Contains(rec.Body.String(), `"updated_by":null`) {
t.Error("a never edited role has a null updated_by")
}
})
t.Run("Reading one role", func(t *testing.T) {
world := newRolesAPIWorld(t)
body := decodeRolesAPIBody(t, world.do(t, raViewer, http.MethodGet, world.path("Contributor"), ""), http.StatusOK)
if body.Members != 4 || body.Version != 1 || !reflect.DeepEqual(body.Grants, grantRows(rolesAPIContributorRows()...)) {
t.Fatalf("unexpected role %+v", body)
}
assertRolesAPIResult(t, world.do(t, raViewer, http.MethodGet, "/v1/role/999", ""), "404 role_not_found")
assertAPIKeyMessage(t, world.do(t, raViewer, http.MethodGet, "/v1/role/abc", ""), http.StatusBadRequest, apperr.IdUint32ErrorMessage)
})
t.Run("A role with a hand-placed owner-only key or duplicate rows is returned normalised", func(t *testing.T) {
world := newRolesAPIWorld(t)
hand, _ := world.roles.Create(role.Role{Name: "Hand placed", Version: 1, Grants: grantRows("content.update=own", "content.update=all", "system.apply_updates=all")})
body := decodeRolesAPIBody(t, world.do(t, raAdmin, http.MethodGet, "/v1/role/"+strconv.Itoa(int(hand.ID)), ""), http.StatusOK)
if !reflect.DeepEqual(body.Grants, grantRows("content.update=own")) {
t.Fatalf("unexpected grants %v", body.Grants)
}
})
t.Run("Who saved last", func(t *testing.T) {
world := newRolesAPIWorld(t)
assertRolesAPIResult(t, world.do(t, raAdmin, http.MethodPut, world.path("Contributor"), rolesAPIUpdateBody("Contributor", "d", 1, rolesAPIContributorRows()...)), "200")
body := decodeRolesAPIBody(t, world.do(t, raViewer, http.MethodGet, world.path("Contributor"), ""), http.StatusOK)
if body.UpdatedAt == nil || *body.UpdatedAt != "2026-10-04T12:00:00Z" || body.UpdatedBy == nil || body.UpdatedBy.ID != raAdmin || body.UpdatedBy.Name != world.users.users[raAdmin].Username {
t.Fatalf("unexpected editor %+v at %v", body.UpdatedBy, body.UpdatedAt)
}
delete(world.users.users, raAdmin)
body = decodeRolesAPIBody(t, world.do(t, raViewer, http.MethodGet, world.path("Contributor"), ""), http.StatusOK)
if body.UpdatedBy != nil {
t.Fatal("a deleted editor reads as null")
}
})
}
func TestRolesAPICreate(t *testing.T) {
t.Run("Creating the Contributor role (design flow, section 11)", func(t *testing.T) {
world := newRolesAPIWorld(t)
request := `{"name": "Writer plus", "description": "Writes posts and edits only their own", "built_in": true, "locked": true, "grants": [{"key": "content.read", "scope": "all"}, {"key": "content.create", "scope": "all"}, {"key": "content.update", "scope": "own"}, {"key": "schema.view", "scope": "all"}, {"key": "media.view", "scope": "all"}]}`
rec := world.do(t, raAdmin, http.MethodPost, "/v1/role", request)
body := decodeRolesAPIBody(t, rec, http.StatusCreated)
if body.BuiltIn || body.Locked || body.Version != 1 || body.Members != 0 || body.Name != "Writer plus" {
t.Fatalf("unexpected role %+v", body)
}
if !reflect.DeepEqual(body.Grants, grantRows("content.read=all", "content.create=all", "content.update=own", "schema.view=all", "media.view=all")) {
t.Fatalf("unexpected grants %v", body.Grants)
}
listed := world.do(t, raAdmin, http.MethodGet, "/v1/role", "")
if !strings.Contains(listed.Body.String(), `"name":"Writer plus"`) {
t.Fatal("the new role must be listed")
}
if len(world.recorder.events) != 1 || world.recorder.events[0].Action != "role.create" {
t.Fatalf("expected exactly one role.create event, got %v", world.recorder.actions())
}
})
t.Run("A new role may start with nothing granted", func(t *testing.T) {
world := newRolesAPIWorld(t)
for _, request := range []string{`{"name": "Empty"}`, `{"name": "Empty 2", "grants": []}`} {
rec := world.do(t, raAdmin, http.MethodPost, "/v1/role", request)
if rec.Code != http.StatusCreated || !strings.Contains(rec.Body.String(), `"grants":[]`) {
t.Fatalf("unexpected %d %q", rec.Code, rec.Body.String())
}
}
})
validation := []struct {
grants string
want string
}{
{"content.update=all", "201"},
{"content.update:events=own", "201"},
{"content.update:article=none", "201"},
{"content.update:events=all,content.update=own", "201"},
{"content.explode=all", "400 unknown_permission"},
{"schema.view:events=all", "400 unknown_permission"},
{"content.update:Bad Key=all", "400 unknown_permission"},
{"content.update:nope=all", "400 unknown_content_type"},
{"content.update:author=all", "400 unknown_content_type"},
{"content.update=none", "400 invalid_scope"},
{"schema.view=own", "400 invalid_scope"},
{"content.update=banana", "400 invalid_scope"},
{"content.create:events=own", "400 invalid_scope"},
{"settings.delete_site=all", "400 owner_only_permission"},
{"system.apply_updates=all", "400 owner_only_permission"},
{"content.update=all,content.update=own", "400 duplicate_permission"},
{"content.update:events=all,content.update:events=own", "400 duplicate_permission"},
}
for _, c := range validation {
t.Run("Write-time validation of "+c.grants, func(t *testing.T) {
world := newRolesAPIWorld(t)
before := len(world.roles.roles)
request := `{"name": "Probe", "grants": ` + rolesAPIGrantsJSON(strings.Split(c.grants, ",")...) + `}`
assertRolesAPIResult(t, world.do(t, raAdmin, http.MethodPost, "/v1/role", request), c.want)
if c.want != "201" && (len(world.roles.roles) != before || len(world.recorder.events) != 0) {
t.Fatal("nothing is stored and no audit event is recorded")
}
})
}
t.Run("owner-only keys are refused on a built-in edit too", func(t *testing.T) {
world := newRolesAPIWorld(t)
body := rolesAPIUpdateBody("Admin", "", 1, append(adminRows(), "system.apply_updates=all")...)
assertRolesAPIResult(t, world.do(t, raOwner, http.MethodPut, world.path("Admin"), body), "400 owner_only_permission")
})
longName := strings.Repeat("n", 101)
names := []struct {
name string
request string
want string
storedName string
description string
}{
{"an empty name", `{"name": ""}`, "400", "", ""},
{"a blank name", `{"name": " "}`, "400 invalid_role_name", "", ""},
{"101 characters", `{"name": "` + longName + `"}`, "400", "", ""},
{"100 characters", `{"name": "` + longName[:100] + `"}`, "201", longName[:100], ""},
{"surrounding spaces", `{"name": " Reviewer "}`, "201", "Reviewer", ""},
{"256 characters of description", `{"name": "Reviewer 2", "description": "` + strings.Repeat("d", 256) + `"}`, "400", "", ""},
{"Writer", `{"name": "Writer"}`, "409 role_name_taken", "", ""},
{"WRITER", `{"name": "WRITER"}`, "409 role_name_taken", "", ""},
{"padded contributor", `{"name": " contributor "}`, "409 role_name_taken", "", ""},
}
for _, c := range names {
t.Run("Name and description rules: "+c.name, func(t *testing.T) {
world := newRolesAPIWorld(t)
rec := world.do(t, raAdmin, http.MethodPost, "/v1/role", c.request)
if c.want == "400" {
assertAPIKeyStatus(t, rec, http.StatusBadRequest)
return
}
assertRolesAPIResult(t, rec, c.want)
if c.want == "201" && decodeRolesAPIBody(t, rec, http.StatusCreated).Name != c.storedName {
t.Fatalf("expected the name %q", c.storedName)
}
})
}
t.Run("a malformed body is the body error", func(t *testing.T) {
world := newRolesAPIWorld(t)
assertAPIKeyMessage(t, world.do(t, raAdmin, http.MethodPost, "/v1/role", `{"name": `), http.StatusBadRequest, apperr.BodyErrorMessage)
assertAPIKeyMessage(t, world.do(t, raAdmin, http.MethodPut, world.path("Contributor"), `[`), http.StatusBadRequest, apperr.BodyErrorMessage)
})
t.Run("a grant without a key fails the validate tag", func(t *testing.T) {
world := newRolesAPIWorld(t)
assertAPIKeyStatus(t, world.do(t, raAdmin, http.MethodPost, "/v1/role", `{"name": "X", "grants": [{"scope": "all"}]}`), http.StatusBadRequest)
})
t.Run("No-escalation on create", func(t *testing.T) {
world := newRolesAPIWorld(t)
request := func(rows ...string) string {
return `{"name": "Escalating", "grants": ` + rolesAPIGrantsJSON(rows...) + `}`
}
assertRolesAPIResult(t, world.do(t, raRoleManager, http.MethodPost, "/v1/role", request("content.publish=all")), "escalation:content.publish")
assertRolesAPIResult(t, world.do(t, raRoleManager, http.MethodPost, "/v1/role", request("content.publish:events=all")), "escalation:content.publish:events")
assertRolesAPIResult(t, world.do(t, raRoleManager, http.MethodPost, "/v1/role", request("content.publish=own")), "201")
if len(world.recorder.events) != 1 {
t.Fatalf("only the successful create is recorded, got %v", world.recorder.actions())
}
})
}
func TestRolesAPIEditing(t *testing.T) {
contributor := rolesAPIContributorRows
cases := []struct {
name string
caller uint32
target string
body func(*rolesAPIWorld) string
want string
after func(*testing.T, *rolesAPIWorld, rolesAPIBody)
}{
{"grants plus delete own", raAdmin, "Contributor", func(*rolesAPIWorld) string {
return rolesAPIUpdateBody("Contributor", "", 1, contributor("content.delete=own")...)
}, "200", func(t *testing.T, w *rolesAPIWorld, b rolesAPIBody) {
if b.Version != 2 || b.Members != 4 || !reflect.DeepEqual(b.Grants, grantRows(contributor("content.delete=own")...)) {
t.Fatalf("unexpected role %+v", b)
}
}},
{"grants minus update", raAdmin, "Contributor", func(*rolesAPIWorld) string {
return rolesAPIUpdateBody("Contributor", "", 1, "content.read=all", "content.create=all")
}, "200", nil},
{"nothing changed", raAdmin, "Contributor", func(*rolesAPIWorld) string {
return rolesAPIUpdateBody("Contributor", "", 1, contributor()...)
}, "200", func(t *testing.T, w *rolesAPIWorld, b rolesAPIBody) {
if b.Version != 1 || len(w.recorder.events) != 0 {
t.Fatalf("a no-op must not bump the version or record, got %+v", b)
}
}},
{"nothing changed on a stale version", raAdmin, "Contributor", func(*rolesAPIWorld) string {
return rolesAPIUpdateBody("Contributor", "", 7, contributor()...)
}, "409 role_conflict", nil},
{"no version field", raAdmin, "Contributor", func(*rolesAPIWorld) string {
return `{"name": "Contributor", "grants": []}`
}, "400", nil},
{"name taken", raAdmin, "Contributor", func(*rolesAPIWorld) string {
return rolesAPIUpdateBody("writer", "", 1, contributor()...)
}, "409 role_name_taken", nil},
{"case-only rename", raAdmin, "Contributor", func(*rolesAPIWorld) string {
return rolesAPIUpdateBody("contributor", "", 1, contributor()...)
}, "200", func(t *testing.T, w *rolesAPIWorld, b rolesAPIBody) {
if b.Name != "contributor" {
t.Fatalf("expected the name as sent, got %q", b.Name)
}
}},
{"unknown key", raAdmin, "Contributor", func(*rolesAPIWorld) string {
return rolesAPIUpdateBody("Contributor", "", 1, "content.explode=all")
}, "400 unknown_permission", nil},
{"unknown role", raAdmin, "Missing", func(*rolesAPIWorld) string {
return rolesAPIUpdateBody("X", "", 1)
}, "404 role_not_found", nil},
{"owner edits the writer description", raOwner, "Writer", func(*rolesAPIWorld) string {
return rolesAPIUpdateBody("Writer", "d", 1, writerRows()...)
}, "200", func(t *testing.T, w *rolesAPIWorld, b rolesAPIBody) {
if b.Version != 2 || !b.BuiltIn {
t.Fatalf("the role keeps built_in, got %+v", b)
}
}},
{"owner renames the writer", raOwner, "Writer", func(*rolesAPIWorld) string {
return rolesAPIUpdateBody("Author", "", 1, writerRows()...)
}, "400 built_in_name", nil},
{"admin edits the writer", raAdmin, "Writer", func(*rolesAPIWorld) string {
return rolesAPIUpdateBody("Writer", "d", 1, writerRows()...)
}, "gate:roles.edit_owner_only", nil},
{"admin edits admin", raAdmin, "Admin", func(*rolesAPIWorld) string {
return rolesAPIUpdateBody("Admin", "d", 1, adminRows()...)
}, "gate:roles.edit_owner_only", nil},
{"owner edits the owner", raOwner, "Owner", func(*rolesAPIWorld) string { return rolesAPIUpdateBody("Owner", "", 1) }, "403 role_locked", nil},
{"admin edits the owner", raAdmin, "Owner", func(*rolesAPIWorld) string { return rolesAPIUpdateBody("Owner", "x", 1) }, "403 role_locked", nil},
{"role manager edits their own role", raRoleManager, "Role manager", func(*rolesAPIWorld) string {
return rolesAPIUpdateBody("Role manager", "d", 1)
}, "403 own_role", nil},
}
for _, c := range cases {
t.Run("Editing a role: "+c.name, func(t *testing.T) {
world := newRolesAPIWorld(t)
rec := world.do(t, c.caller, http.MethodPut, world.path(c.target), c.body(world))
if c.want == "400" {
assertAPIKeyStatus(t, rec, http.StatusBadRequest)
return
}
assertRolesAPIResult(t, rec, c.want)
if c.after != nil {
c.after(t, world, decodeRolesAPIBody(t, rec, http.StatusOK))
}
if c.want != "200" && c.target != "Missing" && world.role(c.target).Version != 1 {
t.Fatal("a refusal bumps no version")
}
})
}
t.Run("Editing a role: a refused call with a stale version still reports the escalation first", func(t *testing.T) {
world := newRolesAPIWorld(t)
rec := world.do(t, raRoleManager, http.MethodPut, world.path("Content lead"), rolesAPIUpdateBody("Content lead", "", 9))
assertRolesAPIResult(t, rec, "escalation")
})
t.Run("Order: an unknown key is a 400 before the escalation", func(t *testing.T) {
world := newRolesAPIWorld(t)
rec := world.do(t, raRoleManager, http.MethodPut, world.path("Contributor"), rolesAPIUpdateBody("Contributor", "", 1, "content.explode=all", "content.delete=all"))
assertRolesAPIResult(t, rec, "400 unknown_permission")
})
t.Run("Members see the new grants on their next request, with the same cookie and API key", func(t *testing.T) {
world := newRolesAPIWorld(t)
raw, _ := world.issueKey(raContributorOne, "contributor key")
world.seedArticle(1, true)
assertAPIKeyStatus(t, world.do(t, raContributorOne, http.MethodDelete, "/v1/article/1", ""), http.StatusForbidden)
assertRolesAPIResult(t, world.do(t, raAdmin, http.MethodPut, world.path("Contributor"), rolesAPIUpdateBody("Contributor", "", 1, contributor("content.delete=all")...)), "200")
assertAPIKeyStatus(t, world.withKey(t, http.MethodDelete, "/v1/article/1", "", raw), http.StatusNoContent)
})
t.Run("A stale editor gets a conflict, then reads the new state", func(t *testing.T) {
world := newRolesAPIWorld(t)
first := rolesAPIUpdateBody("Contributor", "from 3", 1, contributor()...)
second := rolesAPIUpdateBody("Contributor", "from 5", 1, contributor()...)
assertRolesAPIResult(t, world.do(t, raOwner, http.MethodPut, world.path("Contributor"), first), "200")
assertRolesAPIResult(t, world.do(t, raAdmin, http.MethodPut, world.path("Contributor"), second), "409 role_conflict")
if stored := world.role("Contributor"); stored.Description != "from 3" || stored.Version != 2 {
t.Fatalf("the stored role must be the first save, got %+v", stored)
}
})
t.Run("A save that loses the race at the repository answers role_conflict", func(t *testing.T) {
world := newRolesAPIWorld(t)
world.roles.beforeSave = func() {
stored := world.role("Contributor")
stored.Version = 2
world.roles.roles[stored.ID] = stored
}
assertRolesAPIResult(t, world.do(t, raAdmin, http.MethodPut, world.path("Contributor"), rolesAPIUpdateBody("Contributor", "d", 1, contributor()...)), "409 role_conflict")
})
t.Run("A storage failure at the save is a 500", func(t *testing.T) {
world := newRolesAPIWorld(t)
world.roles.fail.failNext("Save")
assertRolesAPIResult(t, world.do(t, raAdmin, http.MethodPut, world.path("Contributor"), rolesAPIUpdateBody("Contributor", "d", 1, contributor()...)), "500 Internal server error")
})
t.Run("A non-numeric id is the id error", func(t *testing.T) {
world := newRolesAPIWorld(t)
assertAPIKeyMessage(t, world.do(t, raAdmin, http.MethodPut, "/v1/role/abc", "{}"), http.StatusBadRequest, apperr.IdUint32ErrorMessage)
assertAPIKeyMessage(t, world.do(t, raAdmin, http.MethodDelete, "/v1/role/abc", ""), http.StatusBadRequest, apperr.IdUint32ErrorMessage)
assertAPIKeyMessage(t, world.do(t, raAdmin, http.MethodPost, "/v1/role/abc/reassign", "{}"), http.StatusBadRequest, apperr.IdUint32ErrorMessage)
assertAPIKeyMessage(t, world.do(t, raOwner, http.MethodPost, "/v1/role/abc/reset", "{}"), http.StatusBadRequest, apperr.IdUint32ErrorMessage)
})
noEscalation := []struct {
caller uint32
extra []string
want string
}{
{raRoleManager, []string{"content.publish=own"}, "200"},
{raRoleManager, []string{"content.publish=all"}, "escalation:content.publish"},
{raRoleManager, []string{"content.delete=own"}, "escalation:content.delete"},
{raRoleManager, []string{"schema.edit=all"}, "escalation"},
{raRoleManager, []string{"content.publish:events=none"}, "200"},
{raRoleManager, []string{"content.publish:events=all"}, "escalation:content.publish:events"},
{raRoleManager, []string{"content.update:events=own"}, "200"},
{raRoleManager, []string{"content.update:events=all"}, "escalation"},
{raAdmin, []string{"content.delete=all"}, "200"},
{raOwner, []string{"content.delete=all"}, "200"},
}
for _, c := range noEscalation {
t.Run("No-escalation on edit: "+c.extra[0]+" by user "+strconv.Itoa(int(c.caller)), func(t *testing.T) {
world := newRolesAPIWorld(t)
rec := world.do(t, c.caller, http.MethodPut, world.path("Contributor"), rolesAPIUpdateBody("Contributor", "", 1, contributor(c.extra...)...))
assertRolesAPIResult(t, rec, c.want)
if c.want != "200" && (world.role("Contributor").Version != 1 || len(world.recorder.events) != 0) {
t.Fatal("a refused call changes no field, bumps no version and records no event")
}
})
}
t.Run("No-escalation with a typed-only reader and a typed none", func(t *testing.T) {
world := newRolesAPIWorld(t)
assertRolesAPIResult(t, world.do(t, raNarrowManager, http.MethodPut, world.path("Contributor"), rolesAPIUpdateBody("Contributor", "", 1, contributor()...)), "escalation")
assertRolesAPIResult(t, world.do(t, raTypedManager, http.MethodPut, world.path("Typed target"), rolesAPIUpdateBody("Typed target", "", 1, "content.read=all", "content.update=all")), "escalation:content.update:article")
assertRolesAPIResult(t, world.do(t, raTypedManager, http.MethodPut, world.path("Typed target"), rolesAPIUpdateBody("Typed target", "", 1, "content.read=all", "content.update=all", "content.update:article=none")), "200")
})
t.Run("No-escalation with a typed manager: a typed own above the caller's none is refused", func(t *testing.T) {
world := newRolesAPIWorld(t)
rec := world.do(t, raTypedManager, http.MethodPut, world.path("Typed target"), rolesAPIUpdateBody("Typed target", "", 1, "content.read=all", "content.update=all", "content.update:article=own"))
assertRolesAPIResult(t, rec, "escalation:content.update:article")
if world.role("Typed target").Version != 1 || len(world.recorder.events) != 0 {
t.Fatal("a refused call changes no field, bumps no version and records no event")
}
})
above := []struct {
name string
caller uint32
method string
path func(*rolesAPIWorld) string
body func(*rolesAPIWorld) string
want string
}{
{"PUT Content lead by Role manager", raRoleManager, http.MethodPut, func(w *rolesAPIWorld) string { return w.path("Content lead") }, func(*rolesAPIWorld) string { return rolesAPIUpdateBody("Content lead", "", 1) }, "escalation"},
{"DELETE Content lead by Role manager", raRoleManager, http.MethodDelete, func(w *rolesAPIWorld) string { return w.path("Content lead") }, func(*rolesAPIWorld) string { return "" }, "escalation"},
{"reassign Content lead by Role manager", raRoleManager, http.MethodPost, func(w *rolesAPIWorld) string { return w.path("Content lead") + "/reassign" }, func(w *rolesAPIWorld) string { return `{"target_role_id": ` + strconv.Itoa(int(w.ids["Intern"])) + `}` }, "escalation"},
{"PUT Contributor with a harmless change by Role manager", raRoleManager, http.MethodPut, func(w *rolesAPIWorld) string { return w.path("Contributor") }, func(*rolesAPIWorld) string { return rolesAPIUpdateBody("Contributor", "harmless", 1, contributor()...) }, "200"},
{"PUT Content lead with a harmless change by Admin", raAdmin, http.MethodPut, func(w *rolesAPIWorld) string { return w.path("Content lead") }, func(*rolesAPIWorld) string {
return rolesAPIUpdateBody("Content lead", "harmless", 1, "content.read=all", "content.update=all", "content.publish=all", "content.delete=all")
}, "200"},
}
for _, c := range above {
t.Run("A caller cannot touch a role above their own grants: "+c.name, func(t *testing.T) {
world := newRolesAPIWorld(t)
assertRolesAPIResult(t, world.do(t, c.caller, c.method, c.path(world), c.body(world)), c.want)
})
}
}