Implemented admin pane + user permission
This commit is contained in:
+147
-1
@@ -61,7 +61,7 @@ func setup(t *testing.T) (*httptest.Server, func()) {
|
||||
clEngine := controllogic.NewEngine(ctx, brk, clStore, cfgStore, audit.Nop(), log)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
api.New(brk, nil, store, cfgStore, access.New("", nil, nil, nil, nil), acl, clStore, clEngine, audit.Nop(), "", "", log).Register(mux, "/api/v1")
|
||||
api.New(brk, nil, store, cfgStore, access.New("", nil), acl, clStore, clEngine, audit.Nop(), "", "", log).Register(mux, "/api/v1")
|
||||
|
||||
srv := httptest.NewServer(mux)
|
||||
return srv, func() {
|
||||
@@ -452,6 +452,152 @@ func TestStorageValidateID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── /api/v1/admin ─────────────────────────────────────────────────────────────
|
||||
|
||||
// adminSetup builds a server whose mux injects the user named by the
|
||||
// "X-Test-User" header into the request context (mirroring the real access
|
||||
// middleware), so admin-gating can be exercised. The policy restricts admin to
|
||||
// the "ops" group, of which "alice" is a member.
|
||||
func adminSetup(t *testing.T) (*httptest.Server, func()) {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
brk := broker.New(ctx, log)
|
||||
ds := stub.New()
|
||||
if err := ds.Connect(ctx); err != nil {
|
||||
t.Fatal("stub connect:", err)
|
||||
}
|
||||
brk.Register(ds)
|
||||
|
||||
dir := t.TempDir()
|
||||
store, _ := storage.New(dir)
|
||||
acl, _ := panelacl.New(dir)
|
||||
clStore, _ := controllogic.NewStore(dir)
|
||||
cfgStore, _ := confmgr.New(dir)
|
||||
clEngine := controllogic.NewEngine(ctx, brk, clStore, cfgStore, audit.Nop(), log)
|
||||
|
||||
// "alice" is an admin (via the ops group); everyone else is a viewer.
|
||||
policy := access.New("", []access.GroupSpec{
|
||||
{Name: "ops", Members: map[string]access.Role{"alice": access.RoleAdmin}},
|
||||
})
|
||||
if err := policy.EnablePersistence(dir); err != nil {
|
||||
t.Fatal("EnablePersistence:", err)
|
||||
}
|
||||
|
||||
inner := http.NewServeMux()
|
||||
api.New(brk, nil, store, cfgStore, policy, acl, clStore, clEngine, audit.Nop(), "", "", log).Register(inner, "/api/v1")
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
if u := r.Header.Get("X-Test-User"); u != "" {
|
||||
r = r.WithContext(access.WithUser(r.Context(), u))
|
||||
}
|
||||
inner.ServeHTTP(w, r)
|
||||
})
|
||||
|
||||
srv := httptest.NewServer(mux)
|
||||
return srv, func() { srv.Close(); cancel() }
|
||||
}
|
||||
|
||||
func reqAs(t *testing.T, srv *httptest.Server, method, path, user string, body []byte) *http.Response {
|
||||
t.Helper()
|
||||
var rdr io.Reader
|
||||
if body != nil {
|
||||
rdr = bytes.NewReader(body)
|
||||
}
|
||||
req, err := http.NewRequest(method, srv.URL+path, rdr)
|
||||
if err != nil {
|
||||
t.Fatal("NewRequest:", err)
|
||||
}
|
||||
if user != "" {
|
||||
req.Header.Set("X-Test-User", user)
|
||||
}
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(method, path, err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func TestAdminForbiddenForNonAdmin(t *testing.T) {
|
||||
srv, teardown := adminSetup(t)
|
||||
defer teardown()
|
||||
|
||||
// "bob" is only a viewer → 403 on every admin route.
|
||||
assertStatus(t, reqAs(t, srv, http.MethodGet, "/api/v1/admin/access", "bob", nil), http.StatusForbidden)
|
||||
assertStatus(t, reqAs(t, srv, http.MethodGet, "/api/v1/admin/stats", "bob", nil), http.StatusForbidden)
|
||||
assertStatus(t, reqAs(t, srv, http.MethodPut, "/api/v1/admin/users/carol", "bob",
|
||||
[]byte(`{"roles":{"public":"operator"}}`)), http.StatusForbidden)
|
||||
}
|
||||
|
||||
func TestAdminUserAndGroupMutations(t *testing.T) {
|
||||
srv, teardown := adminSetup(t)
|
||||
defer teardown()
|
||||
|
||||
// alice (admin) assigns carol an auditor role in a new "team" group.
|
||||
resp := reqAs(t, srv, http.MethodPut, "/api/v1/admin/users/carol", "alice",
|
||||
[]byte(`{"roles":{"team":"auditor"}}`))
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
|
||||
var snap access.AccessSnapshot
|
||||
readJSON(t, resp, &snap)
|
||||
var carol *access.UserInfo
|
||||
for i := range snap.Users {
|
||||
if snap.Users[i].Name == "carol" {
|
||||
carol = &snap.Users[i]
|
||||
}
|
||||
}
|
||||
if carol == nil {
|
||||
t.Fatal("carol missing from snapshot")
|
||||
}
|
||||
if carol.EffectiveRole != "auditor" || carol.Roles["team"] != "auditor" {
|
||||
t.Errorf("carol = %+v, want auditor in team", carol)
|
||||
}
|
||||
|
||||
// Create (with parent), rename, and delete a group.
|
||||
assertStatus(t, reqAs(t, srv, http.MethodPost, "/api/v1/admin/groups", "alice",
|
||||
[]byte(`{"name":"eng","parent":"public"}`)), http.StatusCreated)
|
||||
resp = reqAs(t, srv, http.MethodPut, "/api/v1/admin/groups/eng", "alice",
|
||||
[]byte(`{"name":"engineering","members":{"carol":"admin"}}`))
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
readJSON(t, resp, &snap)
|
||||
if !hasGroup(snap, "engineering") || hasGroup(snap, "eng") {
|
||||
t.Errorf("groups after rename = %+v", snap.Groups)
|
||||
}
|
||||
|
||||
assertStatus(t, reqAs(t, srv, http.MethodDelete, "/api/v1/admin/groups/engineering", "alice", nil), http.StatusOK)
|
||||
assertStatus(t, reqAs(t, srv, http.MethodDelete, "/api/v1/admin/groups/nope", "alice", nil), http.StatusNotFound)
|
||||
// The built-in public group cannot be deleted.
|
||||
assertStatus(t, reqAs(t, srv, http.MethodDelete, "/api/v1/admin/groups/public", "alice", nil), http.StatusBadRequest)
|
||||
}
|
||||
|
||||
func hasGroup(s access.AccessSnapshot, name string) bool {
|
||||
for _, g := range s.Groups {
|
||||
if g.Name == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestAdminStats(t *testing.T) {
|
||||
srv, teardown := adminSetup(t)
|
||||
defer teardown()
|
||||
|
||||
resp := reqAs(t, srv, http.MethodGet, "/api/v1/admin/stats", "alice", nil)
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
var stats map[string]any
|
||||
readJSON(t, resp, &stats)
|
||||
for _, k := range []string{"uptimeSeconds", "wsConnections", "observedSignals", "goroutines", "dataSources"} {
|
||||
if _, ok := stats[k]; !ok {
|
||||
t.Errorf("stats missing key %q", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Ensure os is used (blank import guard) ─────────────────────────────────────
|
||||
|
||||
var _ = os.DevNull
|
||||
|
||||
Reference in New Issue
Block a user