Found live while verifying the admin email-edit fix: the Owner account could
not load /api/v1/users at all. Root cause: migration 0011 adds the 'owner'
role and gates every user-administration route on it, but NOTHING ever wrote
it. break-glass (UpsertOwner), the setup MC-bind (CompleteOwnerSetup), and the
re-provision path all forced 'admin', so in a fresh install the entire
owner tier — list/create/edit/disable/delete users, quotas, sessions — was
unreachable. The role was a dead letter in the other direction too: staff
predicates that predate the role did not know it.
- UpsertOwner and CompleteOwnerSetup now write role='owner'; the username-
conflict arm re-asserts it, which is also the documented pre-0011 promotion
path ("re-provision via break-glass"). InsertOperator stays plain 'admin'.
- Staff doors learn the role: op-login start/finish admit the Owner; the
player email door refuses it like any staff account; the in-game approver
check already used staffRole.
- Reclaim protection: IsProtectedAdminLink (and the break-glass bootstrap
switch AdminExists) count admin OR owner — the Owner must never be displaced
by a Mojang-priority reclaim.
- Panel guards make migration 0011's claim true now that owner rows exist: an
owner can never be demoted, deleted, or disabled through the API (only the
local break-glass console resets the identity); username/email edits still
work.
Tests: pgint pins both provisioning paths, the protected-link predicate and
the reset/promote semantics; hermetic suites cover the owner-admitting staff
door, the owner-refusing player door, the three panel guards, and break-glass
attribution.
54 lines
2.2 KiB
Go
54 lines
2.2 KiB
Go
package api
|
|
|
|
import (
|
|
"net/http"
|
|
"testing"
|
|
)
|
|
|
|
// The owner row is the one identity the panel may never demote, delete, or
|
|
// disable (migration 0011) — only the local break-glass console resets it.
|
|
// These guards became load-bearing the moment provisioning started actually
|
|
// writing role='owner'; before that the owner tier was simply unreachable, so
|
|
// nothing could reach them.
|
|
func TestOwnerAccountProtectedFromPanelMutations(t *testing.T) {
|
|
owner := &Principal{UserID: "usr-root", Role: "owner", ViaAdminAccess: true}
|
|
repo := newFakeRepo()
|
|
repo.seedUser(UserView{ID: "usr-root", Username: "root", Role: "owner"})
|
|
repo.seedUser(UserView{ID: "usr-owner2", Username: "spare-owner", Role: "owner"})
|
|
repo.seedUser(UserView{ID: "u2", Username: "alice", Role: "user"})
|
|
api := newTestAPI(repo, newFakeCluster())
|
|
api.External = staticExternal{p: owner}
|
|
eh := api.ExternalHandler()
|
|
|
|
t.Run("role change refused", func(t *testing.T) {
|
|
w := do(eh, "PATCH", "/api/v1/users/usr-owner2", `{"role":"admin"}`, jsonHeader)
|
|
if w.Code != http.StatusForbidden {
|
|
t.Fatalf("demote owner: code = %d body %s, want 403", w.Code, w.Body.String())
|
|
}
|
|
})
|
|
t.Run("delete refused", func(t *testing.T) {
|
|
w := do(eh, "DELETE", "/api/v1/users/usr-owner2", "", nil)
|
|
if w.Code != http.StatusForbidden {
|
|
t.Fatalf("delete owner: code = %d body %s, want 403", w.Code, w.Body.String())
|
|
}
|
|
})
|
|
t.Run("disable refused", func(t *testing.T) {
|
|
w := do(eh, "POST", "/api/v1/users/usr-owner2/disable", `{"disabled":true}`, jsonHeader)
|
|
if w.Code != http.StatusForbidden {
|
|
t.Fatalf("disable owner: code = %d body %s, want 403", w.Code, w.Body.String())
|
|
}
|
|
})
|
|
t.Run("email edits on an owner stay allowed", func(t *testing.T) {
|
|
w := do(eh, "PATCH", "/api/v1/users/usr-owner2", `{"email":"[email protected]"}`, jsonHeader)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("edit owner email: code = %d body %s, want 200", w.Code, w.Body.String())
|
|
}
|
|
})
|
|
t.Run("control: a normal user can still be promoted", func(t *testing.T) {
|
|
w := do(eh, "PATCH", "/api/v1/users/u2", `{"role":"admin"}`, jsonHeader)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("promote user: code = %d body %s, want 200", w.Code, w.Body.String())
|
|
}
|
|
})
|
|
}
|