test(19.6.1-03): last-superuser race, lifecycle, revocation, seam and roles tests
- TestAdministratorsLastSuperuser (D-09): A and B reduce each other on the five paths while a test transaction holds the guard's advisory lock until pg_locks shows both requests waiting; one succeeds, one gets last_superuser - TestAdministratorsLastSuperuserConcurrent: ten unsynchronized rounds - TestAdministratorsLifecycle (D-10) and TestAdministratorsRevocation (D-12), two-row bulk selections included - TestActionExtensionBoot (-short) and TestActionExtensionRun (D-14, D-15) - TestRolesLifecycle (D-16, D-17), TestRolesPermissionCeiling (D-07) - TestBackendScreensActivation (-short, D-01, D-04)
This commit is contained in:
63
modules/cabana/action_extensions_postgres_test.go
Normal file
63
modules/cabana/action_extensions_postgres_test.go
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
package cabana_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestActionExtensionRun (D-14, D-15): a contributed record action on the
|
||||||
|
// Administrators screen is offered and runs only for principals holding both
|
||||||
|
// the screen's and the action's permissions, answers 409 once it no longer
|
||||||
|
// applies, and runs in the row-locked transaction with the record.
|
||||||
|
func TestActionExtensionRun(t *testing.T) {
|
||||||
|
env := newScreensEnv(t)
|
||||||
|
both := env.seedAdmin(t, adminSeed{Name: "ext-both", RolePermissions: map[string]int{cabana.PermissionManageUsers: 1, pingPermission: 1}})
|
||||||
|
bothToken := env.login(t, both.Login)
|
||||||
|
screenOnly := env.seedAdmin(t, adminSeed{Name: "ext-screen", RolePermissions: manageUsers})
|
||||||
|
screenToken := env.login(t, screenOnly.Login)
|
||||||
|
actionOnly := env.seedAdmin(t, adminSeed{Name: "ext-action", RolePermissions: map[string]int{pingPermission: 1}})
|
||||||
|
actionToken := env.login(t, actionOnly.Login)
|
||||||
|
target := env.seedAdmin(t, adminSeed{Name: "ext-target"})
|
||||||
|
ping := userURL(target.ID, "actions", "acme_ping")
|
||||||
|
|
||||||
|
names := recordActionNames(env.expect(t, http.StatusOK, http.MethodGet, userURL(target.ID), bothToken, nil))
|
||||||
|
if len(names) == 0 || names[len(names)-1] != "acme_ping" || names[0] != "deactivate" {
|
||||||
|
t.Fatalf("offered to the holder of both = %v, want the owner's actions then acme_ping", names)
|
||||||
|
}
|
||||||
|
if names := recordActionNames(env.expect(t, http.StatusOK, http.MethodGet, userURL(target.ID), screenToken, nil)); containsName(names, "acme_ping") {
|
||||||
|
t.Fatalf("offered without %s = %v", pingPermission, names)
|
||||||
|
}
|
||||||
|
env.expectRefusal(t, http.StatusForbidden, http.MethodPost, ping, screenToken, map[string]any{}, "")
|
||||||
|
env.expectRefusal(t, http.StatusForbidden, http.MethodGet, userURL(target.ID), actionToken, nil, "")
|
||||||
|
env.expectRefusal(t, http.StatusForbidden, http.MethodPost, ping, actionToken, map[string]any{}, "")
|
||||||
|
if runs, _ := env.spy.runs(); len(runs) != 0 {
|
||||||
|
t.Fatalf("refused requests ran the action: %v", runs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shown while it applies, then Applies turns false before the POST.
|
||||||
|
env.spy.refuse.Store(true)
|
||||||
|
if rec := env.call(t, http.MethodPost, ping, bothToken, map[string]any{}); rec.Code != http.StatusConflict {
|
||||||
|
t.Fatalf("POST after Applies turned false status=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if names := recordActionNames(env.expect(t, http.StatusOK, http.MethodGet, userURL(target.ID), bothToken, nil)); containsName(names, "acme_ping") {
|
||||||
|
t.Fatalf("offered while it does not apply: %v", names)
|
||||||
|
}
|
||||||
|
env.spy.refuse.Store(false)
|
||||||
|
if runs, _ := env.spy.runs(); len(runs) != 0 {
|
||||||
|
t.Fatalf("a 409 ran the action: %v", runs)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := dataOf(env.expect(t, http.StatusOK, http.MethodPost, ping, bothToken, map[string]any{}))
|
||||||
|
if result["message"] != "Pinged" {
|
||||||
|
t.Fatalf("result = %v", result)
|
||||||
|
}
|
||||||
|
runs, withTx := env.spy.runs()
|
||||||
|
if len(runs) != 1 || runs[0] != uint64(target.ID) || !withTx[0] {
|
||||||
|
t.Fatalf("runs = %v tx = %v, want one run on %d inside the transaction", runs, withTx, target.ID)
|
||||||
|
}
|
||||||
|
// A record the screen hides is not found for the action either.
|
||||||
|
hidden := env.seedAdmin(t, adminSeed{Name: "ext-hidden", Superuser: true})
|
||||||
|
env.expectRefusal(t, http.StatusNotFound, http.MethodPost, userURL(hidden.ID, "actions", "acme_ping"), bothToken, map[string]any{}, "")
|
||||||
|
}
|
||||||
155
modules/cabana/action_extensions_test.go
Normal file
155
modules/cabana/action_extensions_test.go
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
package cabana
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/pact"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/party"
|
||||||
|
)
|
||||||
|
|
||||||
|
// actionContributor owns no admin controller; it declares permissions and
|
||||||
|
// adds record actions to other controllers.
|
||||||
|
type actionContributor struct {
|
||||||
|
id string
|
||||||
|
permissions []pact.Permission
|
||||||
|
extensions []pact.AdminActionExtension
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p actionContributor) ID() string { return p.id }
|
||||||
|
func (actionContributor) Requires() []string { return nil }
|
||||||
|
func (actionContributor) Register(*backpack.App) error { return nil }
|
||||||
|
func (actionContributor) Boot(*backpack.App) error { return nil }
|
||||||
|
func (p actionContributor) Permissions() []pact.Permission { return p.permissions }
|
||||||
|
func (p actionContributor) AdminActionExtensions() []pact.AdminActionExtension {
|
||||||
|
return p.extensions
|
||||||
|
}
|
||||||
|
|
||||||
|
// namedAction is a valid record action named name behind permission.
|
||||||
|
func namedAction(name, permission string) pact.AdminRecordAction {
|
||||||
|
action := pingAction()
|
||||||
|
action.Name = name
|
||||||
|
action.Label = "Run " + name
|
||||||
|
action.Permissions = []string{permission}
|
||||||
|
return action
|
||||||
|
}
|
||||||
|
|
||||||
|
func toUsers(actions ...pact.AdminRecordAction) []pact.AdminActionExtension {
|
||||||
|
return []pact.AdminActionExtension{{Controller: AdministratorsControllerID, RecordActions: actions}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestActionExtensionBoot (D-14, SC-3): every refusal of the seam stops boot
|
||||||
|
// with an error naming the plugin, the action and the controller; valid
|
||||||
|
// contributions land after the owner's actions in plugin and declared order.
|
||||||
|
func TestActionExtensionBoot(t *testing.T) {
|
||||||
|
base := screensExtender{} // owns acme.ext.items (list only) and acme.ext.ping
|
||||||
|
contributor := func(id string, ext []pact.AdminActionExtension) actionContributor {
|
||||||
|
return actionContributor{id: id, permissions: []pact.Permission{{Code: id + ".run"}}, extensions: ext}
|
||||||
|
}
|
||||||
|
noRun := namedAction("acme_norun", "acme.one.run")
|
||||||
|
noRun.Run = nil
|
||||||
|
noLabel := namedAction("acme_nolabel", "acme.one.run")
|
||||||
|
noLabel.Label = " "
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
plugins []party.Plugin
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{"unknown controller", []party.Plugin{base, contributor("acme.one", []pact.AdminActionExtension{{
|
||||||
|
Controller: "acme.missing.things", RecordActions: []pact.AdminRecordAction{namedAction("acme_x", "acme.one.run")},
|
||||||
|
}})}, []string{"acme.one", "acme.missing.things", "unknown admin controller"}},
|
||||||
|
{"target without a preview block", []party.Plugin{base, contributor("acme.one", []pact.AdminActionExtension{{
|
||||||
|
Controller: "acme.ext.items", RecordActions: []pact.AdminRecordAction{namedAction("acme_x", "acme.one.run")},
|
||||||
|
}})}, []string{"acme.one", "acme.ext.items", "no preview block"}},
|
||||||
|
{"duplicate of the owner's action", []party.Plugin{base, contributor("acme.one", toUsers(namedAction("deactivate", "acme.one.run")))},
|
||||||
|
[]string{"acme.one", `"deactivate"`, AdministratorsControllerID, "plugin summercms.cabana already registers"}},
|
||||||
|
{"duplicate between two contributors", []party.Plugin{base,
|
||||||
|
contributor("acme.one", toUsers(namedAction("acme_same", "acme.one.run"))),
|
||||||
|
contributor("acme.two", toUsers(namedAction("acme_same", "acme.two.run"))),
|
||||||
|
}, []string{"plugin acme.two adds", `"acme_same"`, "plugin acme.one already registers"}},
|
||||||
|
{"reserved name create", []party.Plugin{base, contributor("acme.one", toUsers(namedAction("create", "acme.one.run")))},
|
||||||
|
[]string{"acme.one", `"create"`, "reserved"}},
|
||||||
|
{"reserved name delete", []party.Plugin{base, contributor("acme.one", toUsers(namedAction("delete", "acme.one.run")))},
|
||||||
|
[]string{"acme.one", `"delete"`, "reserved"}},
|
||||||
|
{"name is not an identifier", []party.Plugin{base, contributor("acme.one", toUsers(namedAction("acme-ping", "acme.one.run")))},
|
||||||
|
[]string{"acme.one", `"acme-ping"`, "not an identifier"}},
|
||||||
|
{"nil Run", []party.Plugin{base, contributor("acme.one", toUsers(noRun))},
|
||||||
|
[]string{"acme.one", `"acme_norun"`, "no Run function"}},
|
||||||
|
{"empty label", []party.Plugin{base, contributor("acme.one", toUsers(noLabel))},
|
||||||
|
[]string{"acme.one", `"acme_nolabel"`, "needs a label"}},
|
||||||
|
{"unknown permission", []party.Plugin{base, contributor("acme.one", toUsers(namedAction("acme_x", "acme.nobody.run")))},
|
||||||
|
[]string{"acme.nobody.run"}},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
_, err := compileWithScreens(t, tc.plugins)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("compiled; want an error naming %v", tc.want)
|
||||||
|
}
|
||||||
|
for _, want := range tc.want {
|
||||||
|
if !strings.Contains(err.Error(), want) {
|
||||||
|
t.Fatalf("err = %v, want it to contain %q", err, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("valid contributions land after the owner's in order", func(t *testing.T) {
|
||||||
|
plugins := []party.Plugin{base,
|
||||||
|
contributor("acme.one", toUsers(namedAction("acme_a", "acme.one.run"), namedAction("acme_b", "acme.one.run"))),
|
||||||
|
contributor("acme.two", toUsers(namedAction("acme_c", "acme.two.run"))),
|
||||||
|
}
|
||||||
|
reg, err := compileWithScreens(t, plugins)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cc, _ := reg.Get(AdministratorsControllerID)
|
||||||
|
if got := fmt.Sprint(cc.Form.recordActions); got != "[activate deactivate restore acme_a acme_b acme_c]" {
|
||||||
|
t.Fatalf("record actions = %s", got)
|
||||||
|
}
|
||||||
|
for name, plugin := range map[string]string{"acme_a": "acme.one", "acme_b": "acme.one", "acme_c": "acme.two"} {
|
||||||
|
if cc.contributed[name] != plugin || !recordActionOfName(cc, name) {
|
||||||
|
t.Fatalf("%s contributed by %q (want %s), resolvable %v", name, cc.contributed[name], plugin, recordActionOfName(cc, name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, ok := cc.contributed["activate"]; ok {
|
||||||
|
t.Fatal("the owner's action is marked contributed")
|
||||||
|
}
|
||||||
|
// The roles screen is untouched.
|
||||||
|
roles, _ := reg.Get(RolesControllerID)
|
||||||
|
if roles.contributed != nil {
|
||||||
|
t.Fatalf("roles contributed = %v", roles.contributed)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("an extension without record actions is ignored", func(t *testing.T) {
|
||||||
|
plugins := []party.Plugin{base, contributor("acme.one", []pact.AdminActionExtension{{Controller: "acme.missing.things"}})}
|
||||||
|
if _, err := compileWithScreens(t, plugins); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a contributed action runs with the owner's record", func(t *testing.T) {
|
||||||
|
var got pact.AdminRecordActionInput
|
||||||
|
action := namedAction("acme_spy", "acme.one.run")
|
||||||
|
action.Run = func(_ context.Context, in pact.AdminRecordActionInput) (pact.AdminRecordActionResult, error) {
|
||||||
|
got = in
|
||||||
|
return pact.AdminRecordActionResult{}, nil
|
||||||
|
}
|
||||||
|
reg, err := compileWithScreens(t, []party.Plugin{base, contributor("acme.one", toUsers(action))})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cc, _ := reg.Get(AdministratorsControllerID)
|
||||||
|
resolved, ok := recordActionOf(cc, "acme_spy")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("acme_spy not resolvable")
|
||||||
|
}
|
||||||
|
if _, err := resolved.Run(context.Background(), pact.AdminRecordActionInput{RecordID: 7, Record: &BackendUser{ID: 7}}); err != nil || got.RecordID != 7 {
|
||||||
|
t.Fatalf("run = %+v, %v", got, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
443
modules/cabana/administrators_lifecycle_postgres_test.go
Normal file
443
modules/cabana/administrators_lifecycle_postgres_test.go
Normal file
@@ -0,0 +1,443 @@
|
|||||||
|
package cabana_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||||
|
)
|
||||||
|
|
||||||
|
// superuserPath is one write that could remove the last active superuser:
|
||||||
|
// actor (signed in with token) acts on target.
|
||||||
|
type superuserPath func(e *screensEnv, token string, target cabana.BackendUser) *httptest.ResponseRecorder
|
||||||
|
|
||||||
|
// The five reducing paths of D-09.
|
||||||
|
func demoteUpdate(e *screensEnv, token string, target cabana.BackendUser) *httptest.ResponseRecorder {
|
||||||
|
return e.serve(http.MethodPut, userURL(target.ID), token, map[string]any{"login": target.Login, "email": target.Email, "is_superuser": false})
|
||||||
|
}
|
||||||
|
|
||||||
|
func deactivateRecord(e *screensEnv, token string, target cabana.BackendUser) *httptest.ResponseRecorder {
|
||||||
|
return e.serve(http.MethodPost, userURL(target.ID, "actions", "deactivate"), token, map[string]any{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func deactivateBulk(e *screensEnv, token string, target cabana.BackendUser) *httptest.ResponseRecorder {
|
||||||
|
return e.serve(http.MethodPost, usersPath+"/bulk/deactivate", token, map[string]any{"ids": []uint{target.ID}})
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteForm(e *screensEnv, token string, target cabana.BackendUser) *httptest.ResponseRecorder {
|
||||||
|
return e.serve(http.MethodDelete, userURL(target.ID), token, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteBulk(e *screensEnv, token string, target cabana.BackendUser) *httptest.ResponseRecorder {
|
||||||
|
return e.serve(http.MethodPost, usersPath+"/bulk-delete", token, map[string]any{"ids": []uint{target.ID}})
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitingAdvisory counts the advisory lock requests that wait.
|
||||||
|
func (e *screensEnv) waitingAdvisory(t *testing.T) int64 {
|
||||||
|
t.Helper()
|
||||||
|
var n int64
|
||||||
|
if err := e.gdb.Raw(`SELECT count(*) FROM pg_locks WHERE locktype = 'advisory' AND NOT granted`).Scan(&n).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// activeSuperusers counts which of ids are activated, undeleted superusers.
|
||||||
|
func (e *screensEnv) activeSuperusers(t *testing.T, ids ...uint) int64 {
|
||||||
|
t.Helper()
|
||||||
|
var n int64
|
||||||
|
if err := e.gdb.Raw(`SELECT count(*) FROM backend_users WHERE id IN ? AND is_superuser AND is_activated AND deleted_at IS NULL`, ids).Scan(&n).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// noServerFailure fails on a 5xx or a body naming a deadlock.
|
||||||
|
func noServerFailure(t *testing.T, label string, rec *httptest.ResponseRecorder) {
|
||||||
|
t.Helper()
|
||||||
|
if rec.Code >= 500 || strings.Contains(strings.ToLower(rec.Body.String()), "deadlock") {
|
||||||
|
t.Fatalf("%s status=%d body=%s", label, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// raceLastSuperusers runs path for A on B and for B on A while a test
|
||||||
|
// transaction holds the guard's advisory lock, releases the lock once both
|
||||||
|
// requests wait on it, and returns both responses.
|
||||||
|
func (e *screensEnv) raceLastSuperusers(t *testing.T, path superuserPath, a, b cabana.BackendUser, aToken, bToken string) (*httptest.ResponseRecorder, *httptest.ResponseRecorder) {
|
||||||
|
t.Helper()
|
||||||
|
hold := e.gdb.Begin()
|
||||||
|
if hold.Error != nil {
|
||||||
|
t.Fatal(hold.Error)
|
||||||
|
}
|
||||||
|
released := false
|
||||||
|
release := func() {
|
||||||
|
if !released {
|
||||||
|
released = true
|
||||||
|
hold.Rollback()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defer release()
|
||||||
|
if err := hold.Exec("SELECT pg_advisory_xact_lock(?)", cabana.SuperuserLockKey).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
results := make(chan [2]any, 2)
|
||||||
|
go func() { results <- [2]any{"a", path(e, aToken, b)} }()
|
||||||
|
go func() { results <- [2]any{"b", path(e, bToken, a)} }()
|
||||||
|
|
||||||
|
deadline := time.Now().Add(10 * time.Second)
|
||||||
|
for e.waitingAdvisory(t) < 2 {
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
release()
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
r := <-results
|
||||||
|
rec := r[1].(*httptest.ResponseRecorder)
|
||||||
|
t.Logf("%s: status=%d body=%s", r[0], rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
t.Fatal("the two requests did not both wait on the advisory lock within 10s")
|
||||||
|
}
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
}
|
||||||
|
release()
|
||||||
|
var aRec, bRec *httptest.ResponseRecorder
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
r := <-results
|
||||||
|
if r[0] == "a" {
|
||||||
|
aRec = r[1].(*httptest.ResponseRecorder)
|
||||||
|
} else {
|
||||||
|
bRec = r[1].(*httptest.ResponseRecorder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return aRec, bRec
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAdministratorsLastSuperuser (D-09): with A and B the only active
|
||||||
|
// superusers, A and B reduce each other at the same time on every path.
|
||||||
|
// Both requests queue on the guard's advisory lock; exactly one succeeds,
|
||||||
|
// the other is refused with the last_superuser text, and one superuser
|
||||||
|
// stays.
|
||||||
|
func TestAdministratorsLastSuperuser(t *testing.T) {
|
||||||
|
env := newScreensEnv(t)
|
||||||
|
t.Run("demote_update", func(t *testing.T) { lastSuperuserRace(t, env, demoteUpdate) })
|
||||||
|
t.Run("deactivate_record", func(t *testing.T) { lastSuperuserRace(t, env, deactivateRecord) })
|
||||||
|
t.Run("deactivate_bulk", func(t *testing.T) { lastSuperuserRace(t, env, deactivateBulk) })
|
||||||
|
t.Run("delete_form", func(t *testing.T) { lastSuperuserRace(t, env, deleteForm) })
|
||||||
|
t.Run("delete_bulk", func(t *testing.T) { lastSuperuserRace(t, env, deleteBulk) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// lastSuperuserRace seeds fresh superusers A and B, makes them the only
|
||||||
|
// active ones, races path for A on B and B on A on the held lock, and checks
|
||||||
|
// the outcome.
|
||||||
|
func lastSuperuserRace(t *testing.T, env *screensEnv, path superuserPath) {
|
||||||
|
t.Helper()
|
||||||
|
a := env.seedAdmin(t, adminSeed{Name: "last-a", Superuser: true})
|
||||||
|
b := env.seedAdmin(t, adminSeed{Name: "last-b", Superuser: true})
|
||||||
|
aToken, bToken := env.login(t, a.Login), env.login(t, b.Login)
|
||||||
|
env.isolateSuperusers(t, a.ID, b.ID)
|
||||||
|
|
||||||
|
aRec, bRec := env.raceLastSuperusers(t, path, a, b, aToken, bToken)
|
||||||
|
noServerFailure(t, "A on B", aRec)
|
||||||
|
noServerFailure(t, "B on A", bRec)
|
||||||
|
ok, refused := 0, 0
|
||||||
|
for _, rec := range []*httptest.ResponseRecorder{aRec, bRec} {
|
||||||
|
switch {
|
||||||
|
case rec.Code >= 200 && rec.Code < 300:
|
||||||
|
ok++
|
||||||
|
case rec.Code == http.StatusForbidden:
|
||||||
|
if message, details := errorOf(t, rec); message != enLastSuperuser {
|
||||||
|
t.Fatalf("refusal message=%q details=%v, want the last_superuser text", message, details)
|
||||||
|
}
|
||||||
|
refused++
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected status=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ok != 1 || refused != 1 {
|
||||||
|
t.Fatalf("A on B status=%d, B on A status=%d: want one success and one refusal", aRec.Code, bRec.Code)
|
||||||
|
}
|
||||||
|
if n := env.activeSuperusers(t, a.ID, b.ID); n != 1 {
|
||||||
|
t.Fatalf("%d active superusers left of A and B, want 1", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAdministratorsLastSuperuserConcurrent (D-09): unsynchronized rounds of
|
||||||
|
// A demoting B while B demotes A always leave an active superuser and never
|
||||||
|
// answer 500.
|
||||||
|
func TestAdministratorsLastSuperuserConcurrent(t *testing.T) {
|
||||||
|
env := newScreensEnv(t)
|
||||||
|
env.isolateSuperusers(t)
|
||||||
|
for round := 0; round < 10; round++ {
|
||||||
|
a := env.seedAdmin(t, adminSeed{Name: "conc-a", Superuser: true})
|
||||||
|
b := env.seedAdmin(t, adminSeed{Name: "conc-b", Superuser: true})
|
||||||
|
aToken, bToken := env.login(t, a.Login), env.login(t, b.Login)
|
||||||
|
demote := demoteUpdate
|
||||||
|
results := make(chan *httptest.ResponseRecorder, 2)
|
||||||
|
start := make(chan struct{})
|
||||||
|
go func() { <-start; results <- demote(env, aToken, b) }()
|
||||||
|
go func() { <-start; results <- demote(env, bToken, a) }()
|
||||||
|
close(start)
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
noServerFailure(t, fmt.Sprintf("round %d", round), <-results)
|
||||||
|
}
|
||||||
|
if n := env.activeSuperusers(t, a.ID, b.ID); n < 1 {
|
||||||
|
t.Fatalf("round %d left no active superuser", round)
|
||||||
|
}
|
||||||
|
// Retire both before the next round, so A and B are again the only
|
||||||
|
// active superusers.
|
||||||
|
if err := env.gdb.Exec(`UPDATE backend_users SET is_superuser = FALSE WHERE id IN ?`, []uint{a.ID, b.ID}).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// countUsers counts every backend_users row, deleted ones included.
|
||||||
|
func (e *screensEnv) countUsers(t *testing.T) int64 {
|
||||||
|
t.Helper()
|
||||||
|
var n int64
|
||||||
|
if err := e.gdb.Raw(`SELECT count(*) FROM backend_users`).Scan(&n).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// lifecycleActions keeps only the lifecycle names of a preview's actions.
|
||||||
|
func lifecycleActions(body map[string]any) []string {
|
||||||
|
out := []string{}
|
||||||
|
for _, name := range recordActionNames(body) {
|
||||||
|
if name == "activate" || name == "deactivate" || name == "restore" {
|
||||||
|
out = append(out, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// hintText is the rendered hint partial of an administrator.
|
||||||
|
func (e *screensEnv) hintText(t *testing.T, token string, id uint) string {
|
||||||
|
t.Helper()
|
||||||
|
body := e.expect(t, http.StatusOK, http.MethodGet, fmt.Sprintf("%s/partials/hint?id=%d", usersPath, id), token, nil)
|
||||||
|
return fmt.Sprint(body["data"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAdministratorsLifecycle (D-10): activate, deactivate and restore as
|
||||||
|
// record and bulk actions offered only where they apply, soft delete that
|
||||||
|
// keeps every row, the trashed filter and the preview hint.
|
||||||
|
func TestAdministratorsLifecycle(t *testing.T) {
|
||||||
|
env := newScreensEnv(t)
|
||||||
|
super := env.seedAdmin(t, adminSeed{Name: "life-super", Superuser: true})
|
||||||
|
token := env.login(t, super.Login)
|
||||||
|
active := env.seedAdmin(t, adminSeed{Name: "life-active"})
|
||||||
|
inactive := env.seedAdmin(t, adminSeed{Name: "life-inactive", Inactive: true})
|
||||||
|
deleted := env.seedAdmin(t, adminSeed{Name: "life-deleted", Deleted: true, Inactive: true})
|
||||||
|
|
||||||
|
// Offered actions follow Applies.
|
||||||
|
for _, tc := range []struct {
|
||||||
|
user cabana.BackendUser
|
||||||
|
want string
|
||||||
|
}{{active, "[deactivate]"}, {inactive, "[activate]"}, {deleted, "[restore]"}, {super, "[]"}} {
|
||||||
|
got := lifecycleActions(env.expect(t, http.StatusOK, http.MethodGet, userURL(tc.user.ID), token, nil))
|
||||||
|
if fmt.Sprint(got) != tc.want {
|
||||||
|
t.Fatalf("actions offered on %s = %v, want %s", tc.user.Login, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// An action that does not apply answers 409.
|
||||||
|
for _, tc := range []struct {
|
||||||
|
user cabana.BackendUser
|
||||||
|
action string
|
||||||
|
}{{active, "activate"}, {active, "restore"}, {inactive, "deactivate"}, {deleted, "activate"}, {super, "deactivate"}} {
|
||||||
|
if rec := env.call(t, http.MethodPost, userURL(tc.user.ID, "actions", tc.action), token, map[string]any{}); rec.Code != http.StatusConflict {
|
||||||
|
t.Fatalf("%s on %s status=%d body=%s", tc.action, tc.user.Login, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The hint follows deleted > deactivated > own account.
|
||||||
|
for _, tc := range []struct {
|
||||||
|
id uint
|
||||||
|
want string
|
||||||
|
}{{deleted.ID, "This account has been deleted"}, {inactive.ID, "This account is deactivated"}, {super.ID, "This is your account"}} {
|
||||||
|
if got := env.hintText(t, token, tc.id); !strings.Contains(got, tc.want) {
|
||||||
|
t.Fatalf("hint of %d = %q, want %q", tc.id, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := env.hintText(t, token, active.ID); strings.Contains(got, "summer-callout") {
|
||||||
|
t.Fatalf("hint of an active administrator = %q, want none", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record actions.
|
||||||
|
env.expect(t, http.StatusOK, http.MethodPost, userURL(inactive.ID, "actions", "activate"), token, map[string]any{})
|
||||||
|
if got := env.storedUser(t, inactive.ID); !got.IsActivated || got.ActivatedAt == nil {
|
||||||
|
t.Fatalf("activated = %+v", got)
|
||||||
|
}
|
||||||
|
env.expect(t, http.StatusOK, http.MethodPost, userURL(inactive.ID, "actions", "deactivate"), token, map[string]any{})
|
||||||
|
if got := env.storedUser(t, inactive.ID); got.IsActivated {
|
||||||
|
t.Fatal("not deactivated")
|
||||||
|
}
|
||||||
|
env.expect(t, http.StatusOK, http.MethodPost, userURL(deleted.ID, "actions", "restore"), token, map[string]any{})
|
||||||
|
if got := env.storedUser(t, deleted.ID); got.DeletedAt.Valid || got.IsActivated {
|
||||||
|
t.Fatalf("restored = %+v, want undeleted and still deactivated", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bulk actions skip rows they do not apply to and report Affected.
|
||||||
|
extra := env.seedAdmin(t, adminSeed{Name: "life-extra"})
|
||||||
|
result := dataOf(env.expect(t, http.StatusOK, http.MethodPost, usersPath+"/bulk/deactivate", token, map[string]any{"ids": []uint{active.ID, extra.ID, inactive.ID}}))
|
||||||
|
if result["affected"] != float64(2) {
|
||||||
|
t.Fatalf("bulk deactivate result = %v", result)
|
||||||
|
}
|
||||||
|
for _, id := range []uint{active.ID, extra.ID} {
|
||||||
|
if got := env.storedUser(t, id); got.IsActivated {
|
||||||
|
t.Fatalf("admin %d still active after the bulk deactivate: %+v", id, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result = dataOf(env.expect(t, http.StatusOK, http.MethodPost, usersPath+"/bulk/activate", token, map[string]any{"ids": []uint{active.ID, extra.ID}}))
|
||||||
|
if result["affected"] != float64(2) || !strings.Contains(fmt.Sprint(result["message"]), "Selected administrators activated") {
|
||||||
|
t.Fatalf("bulk activate result = %v", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soft delete keeps every row.
|
||||||
|
rows := env.countUsers(t)
|
||||||
|
env.expect(t, http.StatusOK, http.MethodDelete, userURL(active.ID), token, nil)
|
||||||
|
env.expect(t, http.StatusOK, http.MethodPost, usersPath+"/bulk-delete", token, map[string]any{"ids": []uint{extra.ID}})
|
||||||
|
if got := env.countUsers(t); got != rows {
|
||||||
|
t.Fatalf("backend_users rows %d -> %d after deletes", rows, got)
|
||||||
|
}
|
||||||
|
for _, id := range []uint{active.ID, extra.ID} {
|
||||||
|
if got := env.storedUser(t, id); !got.DeletedAt.Valid {
|
||||||
|
t.Fatalf("admin %d not soft-deleted", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
list := env.expect(t, http.StatusOK, http.MethodGet, usersPath+"?search="+env.stamp, token, nil)
|
||||||
|
if ids := listIDs(t, list); !ids[active.ID] || !ids[inactive.ID] {
|
||||||
|
t.Fatalf("unfiltered list = %v", ids)
|
||||||
|
}
|
||||||
|
if states := rowStates(list, active.ID); fmt.Sprint(states) != "[deleted]" {
|
||||||
|
t.Fatalf("row states of a deleted admin = %v", states)
|
||||||
|
}
|
||||||
|
if states := rowStates(list, inactive.ID); fmt.Sprint(states) != "[disabled]" {
|
||||||
|
t.Fatalf("row states of a deactivated admin = %v", states)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The trashed filter: two options; hide and only narrow the list.
|
||||||
|
options := env.expect(t, http.StatusOK, http.MethodGet, usersPath+"/filters/trashed/options", token, nil)
|
||||||
|
raw, _ := json.Marshal(options["data"])
|
||||||
|
if !strings.Contains(string(raw), "Hide deleted") || !strings.Contains(string(raw), "Only deleted") {
|
||||||
|
t.Fatalf("trashed filter options = %s", raw)
|
||||||
|
}
|
||||||
|
hidden := listIDs(t, env.expect(t, http.StatusOK, http.MethodGet, usersPath+"?search="+env.stamp+"&filter[trashed]=hide", token, nil))
|
||||||
|
if hidden[active.ID] || hidden[extra.ID] || !hidden[inactive.ID] {
|
||||||
|
t.Fatalf("trashed=hide list = %v", hidden)
|
||||||
|
}
|
||||||
|
only := listIDs(t, env.expect(t, http.StatusOK, http.MethodGet, usersPath+"?search="+env.stamp+"&filter[trashed]=only", token, nil))
|
||||||
|
if !only[active.ID] || !only[extra.ID] || only[inactive.ID] {
|
||||||
|
t.Fatalf("trashed=only list = %v", only)
|
||||||
|
}
|
||||||
|
// Bulk restore brings both back.
|
||||||
|
result = dataOf(env.expect(t, http.StatusOK, http.MethodPost, usersPath+"/bulk/restore", token, map[string]any{"ids": []uint{active.ID, extra.ID, inactive.ID}}))
|
||||||
|
if result["affected"] != float64(2) {
|
||||||
|
t.Fatalf("bulk restore result = %v", result)
|
||||||
|
}
|
||||||
|
if got := env.storedUser(t, extra.ID); got.DeletedAt.Valid {
|
||||||
|
t.Fatal("bulk restore left a row deleted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// meStatus is the status of GET /auth/me with token.
|
||||||
|
func (e *screensEnv) meStatus(t *testing.T, token string) int {
|
||||||
|
t.Helper()
|
||||||
|
return e.call(t, http.MethodGet, "/auth/me", token, nil).Code
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAdministratorsRevocation (D-12): deactivate and delete end the
|
||||||
|
// target's sessions and personal tokens at once; a password set by another
|
||||||
|
// administrator ends its sessions; restore and activate do not move the
|
||||||
|
// cutoff.
|
||||||
|
func TestAdministratorsRevocation(t *testing.T) {
|
||||||
|
env := newScreensEnv(t)
|
||||||
|
super := env.seedAdmin(t, adminSeed{Name: "rev-super", Superuser: true})
|
||||||
|
token := env.login(t, super.Login)
|
||||||
|
|
||||||
|
type target struct {
|
||||||
|
user cabana.BackendUser
|
||||||
|
jwt, apiKey string
|
||||||
|
}
|
||||||
|
signIn := func(name string) target {
|
||||||
|
user := env.seedAdmin(t, adminSeed{Name: name})
|
||||||
|
apiKey, err := cabana.IssueTestAPIToken(env.gdb, user.ID, []string{"*"}, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
tg := target{user: user, jwt: env.login(t, user.Login), apiKey: apiKey}
|
||||||
|
if env.meStatus(t, tg.jwt) != http.StatusOK || env.meStatus(t, tg.apiKey) != http.StatusOK {
|
||||||
|
t.Fatalf("%s cannot use its session or token before the action", name)
|
||||||
|
}
|
||||||
|
return tg
|
||||||
|
}
|
||||||
|
ended := func(label string, tg target, apiKeyEnded bool) {
|
||||||
|
t.Helper()
|
||||||
|
if got := env.meStatus(t, tg.jwt); got != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("%s: session status=%d, want 401", label, got)
|
||||||
|
}
|
||||||
|
want := http.StatusOK
|
||||||
|
if apiKeyEnded {
|
||||||
|
want = http.StatusUnauthorized
|
||||||
|
}
|
||||||
|
if got := env.meStatus(t, tg.apiKey); got != want {
|
||||||
|
t.Fatalf("%s: personal token status=%d, want %d", label, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
record := signIn("rev-record")
|
||||||
|
env.expect(t, http.StatusOK, http.MethodPost, userURL(record.user.ID, "actions", "deactivate"), token, map[string]any{})
|
||||||
|
ended("record deactivate", record, true)
|
||||||
|
|
||||||
|
// Two rows in one selection: both end.
|
||||||
|
bulk, bulk2 := signIn("rev-bulk"), signIn("rev-bulk-2")
|
||||||
|
env.expect(t, http.StatusOK, http.MethodPost, usersPath+"/bulk/deactivate", token, map[string]any{"ids": []uint{bulk.user.ID, bulk2.user.ID}})
|
||||||
|
ended("bulk deactivate", bulk, true)
|
||||||
|
ended("bulk deactivate, second row", bulk2, true)
|
||||||
|
|
||||||
|
deleted := signIn("rev-deleted")
|
||||||
|
env.expect(t, http.StatusOK, http.MethodDelete, userURL(deleted.user.ID), token, nil)
|
||||||
|
ended("delete", deleted, true)
|
||||||
|
|
||||||
|
bulkDeleted, bulkDeleted2 := signIn("rev-bulk-deleted"), signIn("rev-bulk-deleted-2")
|
||||||
|
env.expect(t, http.StatusOK, http.MethodPost, usersPath+"/bulk-delete", token, map[string]any{"ids": []uint{bulkDeleted.user.ID, bulkDeleted2.user.ID}})
|
||||||
|
ended("bulk delete", bulkDeleted, true)
|
||||||
|
ended("bulk delete, second row", bulkDeleted2, true)
|
||||||
|
|
||||||
|
password := signIn("rev-password")
|
||||||
|
env.expect(t, http.StatusOK, http.MethodPut, userURL(password.user.ID), token,
|
||||||
|
map[string]any{"login": password.user.Login, "email": password.user.Email, "password": "pass-5678", "password_confirmation": "pass-5678"})
|
||||||
|
// A password set by another administrator ends sessions; personal
|
||||||
|
// tokens stay valid (D-12).
|
||||||
|
ended("password set", password, false)
|
||||||
|
// A new session works once the cutoff (one second ahead, whole-second
|
||||||
|
// token times) has passed.
|
||||||
|
if cut := env.storedUser(t, password.user.ID).TokensValidAfter; cut != nil {
|
||||||
|
time.Sleep(time.Until(*cut) + 1100*time.Millisecond)
|
||||||
|
}
|
||||||
|
if fresh := env.loginWith(t, password.user.Login, "pass-5678"); env.meStatus(t, fresh) != http.StatusOK {
|
||||||
|
t.Fatal("a session after the password set does not work")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore and activate leave tokens_valid_after where it was.
|
||||||
|
cutoff := env.storedUser(t, deleted.user.ID).TokensValidAfter
|
||||||
|
if cutoff == nil {
|
||||||
|
t.Fatal("delete left no cutoff")
|
||||||
|
}
|
||||||
|
env.expect(t, http.StatusOK, http.MethodPost, userURL(deleted.user.ID, "actions", "restore"), token, map[string]any{})
|
||||||
|
if got := env.storedUser(t, deleted.user.ID).TokensValidAfter; got == nil || !got.Equal(*cutoff) {
|
||||||
|
t.Fatalf("restore moved the cutoff %v -> %v", cutoff, got)
|
||||||
|
}
|
||||||
|
cutoff = env.storedUser(t, record.user.ID).TokensValidAfter
|
||||||
|
env.expect(t, http.StatusOK, http.MethodPost, userURL(record.user.ID, "actions", "activate"), token, map[string]any{})
|
||||||
|
if got := env.storedUser(t, record.user.ID).TokensValidAfter; got == nil || cutoff == nil || !got.Equal(*cutoff) {
|
||||||
|
t.Fatalf("activate moved the cutoff %v -> %v", cutoff, got)
|
||||||
|
}
|
||||||
|
// The personal token works again after activation; the old session not.
|
||||||
|
if env.meStatus(t, record.apiKey) != http.StatusOK || env.meStatus(t, record.jwt) != http.StatusUnauthorized {
|
||||||
|
t.Fatal("after activate: want the personal token back and the old session still ended")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -173,3 +173,66 @@ func recordActionOfName(cc *CompiledController, name string) bool {
|
|||||||
_, ok := recordActionOf(cc, name)
|
_, ok := recordActionOf(cc, name)
|
||||||
return ok
|
return ok
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestBackendScreensActivation (D-01, D-04): the admin's own screens join
|
||||||
|
// only an application that has an admin; then they bring both controllers,
|
||||||
|
// both settings links (Administrators before Roles), both permission codes
|
||||||
|
// granted to no role, and no navigation entry.
|
||||||
|
func TestBackendScreensActivation(t *testing.T) {
|
||||||
|
// No admin controllers: no admin, and no admin config is read (the app
|
||||||
|
// has no admin.jwt.secret).
|
||||||
|
app := backpack.New(nil)
|
||||||
|
plain := contributionPlugin{id: "acme.plain", permissions: []pact.Permission{{Code: "acme.plain.use"}}}
|
||||||
|
routes, err := Activate(app, []party.Plugin{plain})
|
||||||
|
if routes != nil || err != nil {
|
||||||
|
t.Fatalf("Activate without admin controllers = %v, %v", routes, err)
|
||||||
|
}
|
||||||
|
// With a controller the same app needs the secret: the admin is active.
|
||||||
|
if _, err := Activate(app, []party.Plugin{screensExtender{}}); err == nil || !strings.Contains(err.Error(), "admin.jwt.secret") {
|
||||||
|
t.Fatalf("Activate with a controller and no secret = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reg, err := compileWithScreens(t, []party.Plugin{screensExtender{}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, id := range []string{AdministratorsControllerID, RolesControllerID} {
|
||||||
|
cc, ok := reg.Get(id)
|
||||||
|
if !ok || cc.PluginID != "summercms.cabana" || cc.List == nil || cc.Form == nil {
|
||||||
|
t.Fatalf("controller %s = %#v", id, cc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, code := range []string{PermissionManageUsers, PermissionManageRoles} {
|
||||||
|
permission, ok := reg.permissions[code]
|
||||||
|
if !ok || len(permission.Roles) != 0 {
|
||||||
|
t.Fatalf("permission %s = %+v (present %v), want no role grants", code, permission, ok)
|
||||||
|
}
|
||||||
|
for _, role := range []string{"developer", "publisher"} {
|
||||||
|
if reg.rolePermissions(role)[code] {
|
||||||
|
t.Fatalf("role %s holds %s", role, code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
navigation, settings := reg.Metadata(ctx, &bouncer.Principal{ID: 1, Backend: true, IsSuperuser: true}, nil)
|
||||||
|
var codes []string
|
||||||
|
for _, entry := range settings {
|
||||||
|
if entry.Controller == AdministratorsControllerID || entry.Controller == RolesControllerID {
|
||||||
|
codes = append(codes, entry.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Join(codes, ",") != "administrators,roles" {
|
||||||
|
t.Fatalf("superuser settings for the screens = %v, want administrators then roles", codes)
|
||||||
|
}
|
||||||
|
for _, entry := range navigation {
|
||||||
|
if strings.HasPrefix(entry.Controller, "summercms.cabana.") {
|
||||||
|
t.Fatalf("navigation entry %+v from the screens", entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A holder of backend.manage_roles alone sees the Roles link only.
|
||||||
|
_, settings = reg.Metadata(ctx, &bouncer.Principal{ID: 2, Backend: true, PermissionGrants: map[string]bool{PermissionManageRoles: true}}, nil)
|
||||||
|
if len(settings) != 1 || settings[0].Code != "roles" {
|
||||||
|
t.Fatalf("holder of %s sees %+v", PermissionManageRoles, settings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
178
modules/cabana/roles_postgres_test.go
Normal file
178
modules/cabana/roles_postgres_test.go
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
package cabana_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/pact"
|
||||||
|
)
|
||||||
|
|
||||||
|
// roleURL is the record path of a role.
|
||||||
|
func roleURL(id uint) string { return fmt.Sprintf("%s/%d", rolesPath, id) }
|
||||||
|
|
||||||
|
// roleBody is a role save body.
|
||||||
|
func roleBody(role cabana.BackendUserRole, extra map[string]any) map[string]any {
|
||||||
|
body := map[string]any{"name": role.Name, "code": role.Code, "description": role.Description}
|
||||||
|
for key, value := range extra {
|
||||||
|
body[key] = value
|
||||||
|
}
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
// roleExists reports whether a role row with id exists.
|
||||||
|
func (e *screensEnv) roleExists(t *testing.T, id uint) bool {
|
||||||
|
t.Helper()
|
||||||
|
var n int64
|
||||||
|
if err := e.gdb.Raw(`SELECT count(*) FROM backend_user_roles WHERE id = ?`, id).Scan(&n).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return n == 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRolesLifecycle (D-16, D-17): role codes are unique; a system role (is_system
|
||||||
|
// or one code-declared grants attach to) keeps its code and cannot be
|
||||||
|
// deleted; a role any administrator holds, deleted ones included, cannot be
|
||||||
|
// deleted, alone or in a bulk selection.
|
||||||
|
func TestRolesLifecycle(t *testing.T) {
|
||||||
|
declared := fmt.Sprintf("plugrole-%d", time.Now().UnixNano())
|
||||||
|
env := newScreensEnv(t, grantPlugin{id: "acme.granter", grants: []pact.Permission{{Code: "acme.granter.use", Roles: []string{declared}}}})
|
||||||
|
super := env.seedAdmin(t, adminSeed{Name: "roles-super", Superuser: true})
|
||||||
|
token := env.login(t, super.Login)
|
||||||
|
|
||||||
|
// Create with the slug the form's preset makes from the name.
|
||||||
|
name := "Editors " + env.stamp
|
||||||
|
code := "editors-" + env.stamp
|
||||||
|
created := dataOf(env.expect(t, http.StatusCreated, http.MethodPost, rolesPath, token,
|
||||||
|
map[string]any{"name": name, "code": code, "description": "Edit things"}))
|
||||||
|
createdID := uint(created["id"].(float64))
|
||||||
|
t.Cleanup(func() { env.gdb.Exec(`DELETE FROM backend_user_roles WHERE id = ?`, createdID) })
|
||||||
|
var stored cabana.BackendUserRole
|
||||||
|
if err := env.gdb.First(&stored, createdID).Error; err != nil || stored.Code != code || stored.IsSystem {
|
||||||
|
t.Fatalf("created role = %+v, %v", stored, err)
|
||||||
|
}
|
||||||
|
env.expectFieldRefusal(t, http.StatusUnprocessableEntity, http.MethodPost, rolesPath, token,
|
||||||
|
map[string]any{"name": "Other " + env.stamp, "code": code}, "code", enCodeTaken, false)
|
||||||
|
before := env.roleRow(t, createdID)
|
||||||
|
other := env.seedRole(t, roleSeed{Name: "roles-other"})
|
||||||
|
env.expectFieldRefusal(t, http.StatusUnprocessableEntity, http.MethodPut, roleURL(other.ID), token,
|
||||||
|
roleBody(other, map[string]any{"code": code}), "code", enCodeTaken, false)
|
||||||
|
if env.roleRow(t, createdID) != before {
|
||||||
|
t.Fatal("a refused code change touched the created role")
|
||||||
|
}
|
||||||
|
|
||||||
|
// System roles: is_system, and a role a plugin's code-declared grant
|
||||||
|
// attaches to.
|
||||||
|
var developer cabana.BackendUserRole
|
||||||
|
if err := env.gdb.Where("code = ?", "developer").First(&developer).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
pluginRole := env.seedRole(t, roleSeed{Name: "roles-declared", Code: declared})
|
||||||
|
for _, role := range []cabana.BackendUserRole{developer, pluginRole} {
|
||||||
|
rowBefore := env.roleRow(t, role.ID)
|
||||||
|
env.expectFieldRefusal(t, http.StatusUnprocessableEntity, http.MethodPut, roleURL(role.ID), token,
|
||||||
|
roleBody(role, map[string]any{"code": role.Code + "-renamed"}), "code", enCodeLocked, false)
|
||||||
|
env.expectRefusal(t, http.StatusForbidden, http.MethodDelete, roleURL(role.ID), token, nil, enDeleteSystem)
|
||||||
|
if env.roleRow(t, role.ID) != rowBefore {
|
||||||
|
t.Fatalf("a refused change touched system role %s", role.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A system role's name and description stay editable.
|
||||||
|
env.expect(t, http.StatusOK, http.MethodPut, roleURL(pluginRole.ID), token, roleBody(pluginRole, map[string]any{"description": "Changed"}))
|
||||||
|
|
||||||
|
// A held role, one holder active and one soft-deleted.
|
||||||
|
held := env.seedRole(t, roleSeed{Name: "roles-held"})
|
||||||
|
env.seedAdmin(t, adminSeed{Name: "roles-holder", Role: held.ID})
|
||||||
|
env.seedAdmin(t, adminSeed{Name: "roles-gone", Role: held.ID, Deleted: true})
|
||||||
|
env.expectRefusal(t, http.StatusForbidden, http.MethodDelete, roleURL(held.ID), token, nil,
|
||||||
|
"The role “"+held.Name+"” is assigned to 2 administrators.")
|
||||||
|
if !env.roleExists(t, held.ID) {
|
||||||
|
t.Fatal("a refused delete removed the role")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A bulk delete of an unused role and the held one is refused as a whole.
|
||||||
|
unused := env.seedRole(t, roleSeed{Name: "roles-unused"})
|
||||||
|
env.expectRefusal(t, http.StatusForbidden, http.MethodPost, rolesPath+"/bulk-delete", token,
|
||||||
|
map[string]any{"ids": []uint{unused.ID, held.ID}}, "is assigned to 2 administrators")
|
||||||
|
if !env.roleExists(t, unused.ID) || !env.roleExists(t, held.ID) {
|
||||||
|
t.Fatal("a refused bulk delete removed a role")
|
||||||
|
}
|
||||||
|
// The unused role alone deletes.
|
||||||
|
env.expect(t, http.StatusOK, http.MethodDelete, roleURL(unused.ID), token, nil)
|
||||||
|
if env.roleExists(t, unused.ID) {
|
||||||
|
t.Fatal("the unused role was not deleted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRolesPermissionCeiling (D-07): a non-superuser holding
|
||||||
|
// backend.manage_roles can neither add nor remove a code it does not hold on
|
||||||
|
// any role; codes it holds toggle.
|
||||||
|
func TestRolesPermissionCeiling(t *testing.T) {
|
||||||
|
env := newScreensEnv(t)
|
||||||
|
editor := env.seedAdmin(t, adminSeed{Name: "rceil-editor", RolePermissions: map[string]int{cabana.PermissionManageRoles: 1, pingPermission: 1}})
|
||||||
|
token := env.login(t, editor.Login)
|
||||||
|
|
||||||
|
field := schemaFields(env.expect(t, http.StatusOK, http.MethodGet, rolesPath+"/schema/form", token, nil))["permissions"]
|
||||||
|
locked := permissionLocked(field)
|
||||||
|
if len(locked) < 4 {
|
||||||
|
t.Fatalf("permission options = %v", locked)
|
||||||
|
}
|
||||||
|
for code, isLocked := range locked {
|
||||||
|
held := code == cabana.PermissionManageRoles || code == pingPermission
|
||||||
|
if isLocked == held {
|
||||||
|
t.Fatalf("option %s locked=%v, want %v", code, isLocked, !held)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
strong := env.seedRole(t, roleSeed{Name: "rceil-strong", Permissions: map[string]int{"acme.demo.access": 1}})
|
||||||
|
empty := env.seedRole(t, roleSeed{Name: "rceil-empty"})
|
||||||
|
strong, empty = env.reloadRole(t, strong.ID), env.reloadRole(t, empty.ID)
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
role cabana.BackendUserRole
|
||||||
|
permissions map[string]any
|
||||||
|
}{
|
||||||
|
{"add a locked code", empty, map[string]any{"acme.demo.access": 1}},
|
||||||
|
{"add a locked code next to a held one", empty, map[string]any{"acme.demo.access": 1, pingPermission: 1}},
|
||||||
|
{"remove a locked code", strong, map[string]any{}},
|
||||||
|
{"remove a locked code, add a held one", strong, map[string]any{pingPermission: 1}},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
before := env.roleRow(t, tc.role.ID)
|
||||||
|
env.expectFieldRefusal(t, http.StatusForbidden, http.MethodPut, roleURL(tc.role.ID), token,
|
||||||
|
roleBody(tc.role, map[string]any{"permissions": tc.permissions}), "permissions", enPermissionLocked, false)
|
||||||
|
if env.roleRow(t, tc.role.ID) != before {
|
||||||
|
t.Fatal("a refused permission change touched the role")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
env.expect(t, http.StatusOK, http.MethodPut, roleURL(strong.ID), token,
|
||||||
|
roleBody(strong, map[string]any{"permissions": map[string]any{"acme.demo.access": 1, pingPermission: 1}}))
|
||||||
|
if got := decodeJSON(t, env.reloadRole(t, strong.ID).Permissions); got["acme.demo.access"] != 1 || got[pingPermission] != 1 || len(got) != 2 {
|
||||||
|
t.Fatalf("strong role after adding the held code = %v", got)
|
||||||
|
}
|
||||||
|
env.expect(t, http.StatusOK, http.MethodPut, roleURL(strong.ID), token,
|
||||||
|
roleBody(strong, map[string]any{"permissions": map[string]any{"acme.demo.access": 1}}))
|
||||||
|
if got := decodeJSON(t, env.reloadRole(t, strong.ID).Permissions); got["acme.demo.access"] != 1 || len(got) != 1 {
|
||||||
|
t.Fatalf("strong role after removing the held code = %v", got)
|
||||||
|
}
|
||||||
|
// Without the permissions key the stored grants stay.
|
||||||
|
env.expect(t, http.StatusOK, http.MethodPut, roleURL(strong.ID), token, roleBody(strong, map[string]any{"description": "Renamed"}))
|
||||||
|
if got := env.reloadRole(t, strong.ID); got.Description != "Renamed" || got.Permissions != `{"acme.demo.access":1}` {
|
||||||
|
t.Fatalf("strong role after a description change = %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// reloadRole reads a role row.
|
||||||
|
func (e *screensEnv) reloadRole(t *testing.T, id uint) cabana.BackendUserRole {
|
||||||
|
t.Helper()
|
||||||
|
var role cabana.BackendUserRole
|
||||||
|
if err := e.gdb.First(&role, id).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return role
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user