package client_test import ( "encoding/hex" "strings" "testing" "github.com/enmanuel/unibus/pkg/client" "github.com/enmanuel/unibus/pkg/membership" ) // findUserInfo returns the row with the given signing key (case-insensitive). func findUserInfo(users []client.UserInfo, signPub string) (client.UserInfo, bool) { want := strings.ToLower(signPub) for _, u := range users { if strings.ToLower(u.SignPub) == want { return u, true } } return client.UserInfo{}, false } // TestClientUsersAdminAPI drives the admin user-management API through the real // pkg/client methods against an in-process membershipd under enforce: an admin // client adds a user, lists it, revokes it, and sees the status flip — and a // non-admin client is denied. This is the path the admin panel uses, so it locks // the client/server contract the panel depends on. func TestClientUsersAdminAPI(t *testing.T) { h := newHarnessMode(t, membership.AuthEnforce) waitHealth(t, h.ctrlURL) admin, err := client.New(h.natsURL, h.ctrlURL, mustIdentity(t)) if err != nil { t.Fatalf("connect admin: %v", err) } defer admin.Close() registerClient(t, h, admin, "admin", membership.RoleAdmin) member, err := client.New(h.natsURL, h.ctrlURL, mustIdentity(t)) if err != nil { t.Fatalf("connect member: %v", err) } defer member.Close() registerClient(t, h, member, "member", membership.RoleMember) // A brand-new identity the admin will register over HTTP. carol := mustIdentity(t) carolPub := hex.EncodeToString(carol.SignPub) // Admin adds carol as a member. if err := admin.AddUser(carolPub, "carol", membership.RoleMember); err != nil { t.Fatalf("admin AddUser: %v", err) } // Admin lists: carol present and active. users, err := admin.ListUsers() if err != nil { t.Fatalf("admin ListUsers: %v", err) } row, ok := findUserInfo(users, carolPub) if !ok { t.Fatalf("carol missing from list after add: %+v", users) } if row.Status != membership.StatusActive || row.Role != membership.RoleMember { t.Fatalf("carol row wrong after add: %+v", row) } // Re-adding the same key is a conflict surfaced as an error (no silent upsert). if err := admin.AddUser(carolPub, "carol-again", membership.RoleAdmin); err == nil { t.Fatalf("re-adding carol should error (409), got nil") } // Admin revokes carol; list shows the status flip (no hard delete). if err := admin.RevokeUser(carolPub); err != nil { t.Fatalf("admin RevokeUser: %v", err) } users, err = admin.ListUsers() if err != nil { t.Fatalf("admin ListUsers after revoke: %v", err) } row, ok = findUserInfo(users, carolPub) if !ok { t.Fatalf("carol vanished after revoke (should be a status flip): %+v", users) } if row.Status != membership.StatusRevoked { t.Fatalf("carol should be revoked, got status %q", row.Status) } // A non-admin (member) is denied on every user-management method. if _, err := member.ListUsers(); err == nil { t.Fatalf("non-admin ListUsers should error (403), got nil") } if err := member.AddUser(carolPub, "x", membership.RoleMember); err == nil { t.Fatalf("non-admin AddUser should error (403), got nil") } if err := member.RevokeUser(carolPub); err == nil { t.Fatalf("non-admin RevokeUser should error (403), got nil") } }