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