237 lines
9.8 KiB
Go
237 lines
9.8 KiB
Go
package server
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
|
|
"agent-system-backend/internal/store"
|
|
)
|
|
|
|
const superAdminFixturePassword = "Super-Admin-Pass-1"
|
|
|
|
// bootSuperAdmin establishes "admin" as the configured super administrator
|
|
// (as startup bootstrap does) and returns a full password session cookie.
|
|
func bootSuperAdmin(t *testing.T, h http.Handler, st *store.Store) string {
|
|
t.Helper()
|
|
hash, err := store.HashPassword(superAdminFixturePassword)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := st.BootstrapSuperAdmin("admin", hash); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return loginFixture(t, h, "admin", superAdminFixturePassword)
|
|
}
|
|
|
|
// bootOrdinaryAdmin creates a legacy/ordinary administrator with a usable
|
|
// password session.
|
|
func bootOrdinaryAdmin(t *testing.T, h http.Handler, st *store.Store, owner string) string {
|
|
t.Helper()
|
|
hash, err := store.HashPassword("ordinary-admin-1")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := st.CreateAccount(owner, "", hash, true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := st.ClearMustChangePassword(owner); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return loginFixture(t, h, owner, "ordinary-admin-1")
|
|
}
|
|
|
|
func loginFixture(t *testing.T, h http.Handler, owner, password string) string {
|
|
t.Helper()
|
|
loginClear(owner)
|
|
t.Cleanup(func() { loginClear(owner) })
|
|
body, _ := json.Marshal(map[string]string{"owner": owner, "password": password})
|
|
rec := do(h, "POST", "/api/auth/login", string(body), "")
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("login %s: %d %s", owner, rec.Code, rec.Body.String())
|
|
}
|
|
cookie := cookieOf(rec)
|
|
if cookie == "" {
|
|
t.Fatalf("login %s: missing session cookie", owner)
|
|
}
|
|
return cookie
|
|
}
|
|
|
|
func decodeAccountHTTP(t *testing.T, rec interface{ Bytes() []byte }) map[string]any {
|
|
t.Helper()
|
|
var out map[string]any
|
|
if err := json.Unmarshal(rec.Bytes(), &out); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func stateVersionOf(t *testing.T, st *store.Store, owner string) (string, string) {
|
|
t.Helper()
|
|
account, err := st.GetAccountByOwner(owner)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return account.ID, strconv.FormatInt(account.StateVersion, 10)
|
|
}
|
|
|
|
func TestSuperAdminHTTPAppointsAdministrators(t *testing.T) {
|
|
srv, st := newAuthTestServer(t)
|
|
h := srv.Handler()
|
|
super := bootSuperAdmin(t, h, st)
|
|
|
|
me := do(h, "GET", "/api/me", "", super)
|
|
if me.Code != http.StatusOK || decodeAccountHTTP(t, me.Body)["isSuperAdmin"] != true {
|
|
t.Fatalf("super admin /api/me: %d %s", me.Code, me.Body.String())
|
|
}
|
|
if strings.Contains(me.Body.String(), superAdminFixturePassword) {
|
|
t.Fatal("/api/me leaked the super admin password")
|
|
}
|
|
|
|
created := do(h, "POST", "/api/admin/users", `{"owner":"boss","isAdmin":true}`, super)
|
|
if created.Code != http.StatusOK {
|
|
t.Fatalf("super admin creates administrator: %d %s", created.Code, created.Body.String())
|
|
}
|
|
out := decodeAccountHTTP(t, created.Body)
|
|
if out["isAdmin"] != true || out["isSuperAdmin"] != false {
|
|
t.Fatalf("created administrator projection: %v", out)
|
|
}
|
|
byRole := do(h, "POST", "/api/admin/users", `{"owner":"boss2","role":"admin"}`, super)
|
|
if byRole.Code != http.StatusOK || decodeAccountHTTP(t, byRole.Body)["isAdmin"] != true {
|
|
t.Fatalf("super admin creates administrator by role: %d %s", byRole.Code, byRole.Body.String())
|
|
}
|
|
|
|
if rec := do(h, "POST", "/api/admin/users", `{"owner":"carol"}`, super); rec.Code != http.StatusOK {
|
|
t.Fatalf("super admin creates user: %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
carolID, version := stateVersionOf(t, st, "carol")
|
|
promoted := do(h, "PUT", "/api/admin/users/"+carolID+"/admin", `{"expectedStateVersion":`+version+`,"isAdmin":true}`, super)
|
|
if promoted.Code != http.StatusOK || decodeAccountHTTP(t, promoted.Body)["isAdmin"] != true {
|
|
t.Fatalf("super admin promotes user: %d %s", promoted.Code, promoted.Body.String())
|
|
}
|
|
_, version = stateVersionOf(t, st, "carol")
|
|
demoted := do(h, "PUT", "/api/admin/users/"+carolID+"/admin", `{"expectedStateVersion":`+version+`,"isAdmin":false}`, super)
|
|
if demoted.Code != http.StatusOK || decodeAccountHTTP(t, demoted.Body)["isAdmin"] != false {
|
|
t.Fatalf("super admin demotes administrator: %d %s", demoted.Code, demoted.Body.String())
|
|
}
|
|
|
|
list := do(h, "GET", "/api/admin/users", "", super)
|
|
if list.Code != http.StatusOK {
|
|
t.Fatalf("list users: %d %s", list.Code, list.Body.String())
|
|
}
|
|
var users []map[string]any
|
|
if err := json.Unmarshal(list.Body.Bytes(), &users); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, u := range users {
|
|
if u["isSuperAdmin"] != (u["owner"] == "admin") {
|
|
t.Fatalf("admin listing isSuperAdmin for %v = %v", u["owner"], u["isSuperAdmin"])
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestOrdinaryAdminHTTPCannotGrantAdministrativeAuthority(t *testing.T) {
|
|
srv, st := newAuthTestServer(t)
|
|
h := srv.Handler()
|
|
super := bootSuperAdmin(t, h, st)
|
|
ops := bootOrdinaryAdmin(t, h, st, "ops")
|
|
|
|
me := do(h, "GET", "/api/me", "", ops)
|
|
if me.Code != http.StatusOK || decodeAccountHTTP(t, me.Body)["isSuperAdmin"] != false {
|
|
t.Fatalf("ordinary admin /api/me: %d %s", me.Code, me.Body.String())
|
|
}
|
|
for _, body := range []string{
|
|
`{"owner":"sneaky","isAdmin":true}`,
|
|
`{"owner":"sneaky","role":"admin"}`,
|
|
`{"owner":"sneaky","isAdmin":false,"role":"admin"}`,
|
|
} {
|
|
if rec := do(h, "POST", "/api/admin/users", body, ops); rec.Code != http.StatusForbidden {
|
|
t.Fatalf("ordinary admin created administrator with %s: %d %s", body, rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
if _, err := st.GetAccountByOwner("sneaky"); err != store.ErrNotFound {
|
|
t.Fatalf("rejected administrator creation left an account: %v", err)
|
|
}
|
|
created := do(h, "POST", "/api/admin/users", `{"owner":"dave","isAdmin":false}`, ops)
|
|
if created.Code != http.StatusOK || decodeAccountHTTP(t, created.Body)["isAdmin"] != false {
|
|
t.Fatalf("ordinary admin creates user: %d %s", created.Code, created.Body.String())
|
|
}
|
|
daveID, version := stateVersionOf(t, st, "dave")
|
|
if rec := do(h, "PUT", "/api/admin/users/"+daveID+"/admin", `{"expectedStateVersion":`+version+`,"isAdmin":true}`, ops); rec.Code != http.StatusForbidden {
|
|
t.Fatalf("ordinary admin promoted a user: %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
epoch, _ := st.AuthorityEpoch()
|
|
epochText := strconv.FormatInt(epoch, 10)
|
|
if rec := do(h, "PUT", "/api/admin/users/"+daveID+"/system-grants",
|
|
`{"expectedAuthorityEpoch":`+epochText+`,"idempotencyKey":"ops-grant-dave","actions":["system.users.create"]}`, ops); rec.Code != http.StatusForbidden {
|
|
t.Fatalf("ordinary admin granted account administration: %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
opsID, _ := stateVersionOf(t, st, "ops")
|
|
if rec := do(h, "PUT", "/api/admin/users/"+opsID+"/system-grants",
|
|
`{"expectedAuthorityEpoch":`+epochText+`,"idempotencyKey":"ops-self","actions":[]}`, ops); rec.Code != http.StatusForbidden {
|
|
t.Fatalf("ordinary admin changed its own administrative grants: %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
if dave, _ := st.GetAccountByOwner("dave"); dave.IsAdmin {
|
|
t.Fatal("rejected requests elevated dave")
|
|
}
|
|
if actions, _, _ := st.SystemGrantsForUser(daveID); len(actions) != 0 {
|
|
t.Fatalf("rejected grant left actions: %v", actions)
|
|
}
|
|
if rec := do(h, "PUT", "/api/admin/users/"+daveID+"/system-grants",
|
|
`{"expectedAuthorityEpoch":`+epochText+`,"idempotencyKey":"super-grant-dave","actions":["system.users.view"]}`, super); rec.Code != http.StatusOK {
|
|
t.Fatalf("super admin grants account administration: %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestOrdinaryAdminHTTPCannotMutateSuperAdmin(t *testing.T) {
|
|
srv, st := newAuthTestServer(t)
|
|
h := srv.Handler()
|
|
super := bootSuperAdmin(t, h, st)
|
|
ops := bootOrdinaryAdmin(t, h, st, "ops")
|
|
root, err := st.GetAccountByOwner("admin")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
version := strconv.FormatInt(root.StateVersion, 10)
|
|
role := do(h, "POST", "/api/admin/roles", `{"name":"普通职位"}`, ops)
|
|
if role.Code != http.StatusOK {
|
|
t.Fatalf("create role: %d %s", role.Code, role.Body.String())
|
|
}
|
|
roleID := decodeRoleHTTP(t, role).RoleID
|
|
epoch, _ := st.AuthorityEpoch()
|
|
for _, route := range []struct{ method, path, body string }{
|
|
{"PATCH", "/api/admin/users/" + root.ID + "/profile", `{"expectedProfileVersion":1,"patch":{"name":"renamed"}}`},
|
|
{"PUT", "/api/admin/users/" + root.ID + "/state", `{"expectedStateVersion":` + version + `,"enabled":false}`},
|
|
{"POST", "/api/admin/users/admin/one-time-password", `{}`},
|
|
{"PUT", "/api/admin/users/" + root.ID + "/admin", `{"expectedStateVersion":` + version + `,"isAdmin":false}`},
|
|
{"PUT", "/api/admin/users/" + root.ID + "/role", `{"roleId":"` + roleID + `","expectedStateVersion":` + version + `,"idempotencyKey":"ops-role-root"}`},
|
|
{"PUT", "/api/admin/users/" + root.ID + "/system-grants", `{"expectedAuthorityEpoch":` + strconv.FormatInt(epoch, 10) + `,"idempotencyKey":"ops-grants-root","actions":[]}`},
|
|
} {
|
|
if rec := do(h, route.method, route.path, route.body, ops); rec.Code != http.StatusForbidden {
|
|
t.Errorf("ordinary admin %s %s: %d %s; want 403", route.method, route.path, rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
for _, route := range []struct{ method, path, body string }{
|
|
{"PUT", "/api/admin/users/" + root.ID + "/state", `{"expectedStateVersion":` + version + `,"enabled":false}`},
|
|
{"PUT", "/api/admin/users/" + root.ID + "/admin", `{"expectedStateVersion":` + version + `,"isAdmin":false}`},
|
|
{"POST", "/api/admin/users/admin/one-time-password", `{}`},
|
|
} {
|
|
if rec := do(h, route.method, route.path, route.body, super); rec.Code != http.StatusForbidden {
|
|
t.Errorf("super admin %s %s on itself: %d %s; want 403", route.method, route.path, rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
after, err := st.GetAccountByOwner("admin")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if after != root {
|
|
t.Fatalf("rejected requests changed the super admin: before=%+v after=%+v", root, after)
|
|
}
|
|
if rec := do(h, "GET", "/api/me", "", super); rec.Code != http.StatusOK {
|
|
t.Fatalf("super admin session revoked by rejected requests: %d", rec.Code)
|
|
}
|
|
loginFixture(t, h, "admin", superAdminFixturePassword)
|
|
}
|