mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-03 04:38:48 +00:00
Initialise the real persistent auth stores for the guide lifecycle and prove user removal revokes its session and CSRF token. Clarify that an empty role list clears built-in assignments too; no runtime authority changes. Contract-Neutral: Documentation and test-fixture correction only; session revocation, role assignment and authorization runtime contracts are unchanged. Change-source: pulse-maintainer
191 lines
8.7 KiB
Go
191 lines
8.7 KiB
Go
package api
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
|
"github.com/rcourtman/pulse-go-rewrite/pkg/auth"
|
|
)
|
|
|
|
// Read what the guide actually sends, rather than maintaining a second client.
|
|
// Authentication, licensing and CSRF are tested by the existing route tests;
|
|
// these documentation tests exercise the real handlers and persistent values.
|
|
func adminDocBodies(t *testing.T, name, heading string) [][]byte {
|
|
t.Helper()
|
|
contents, err := os.ReadFile(filepath.Join("..", "..", "docs", name+".md"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, section, found := strings.Cut(string(contents), "### "+heading+"\n")
|
|
if !found {
|
|
t.Fatalf("missing %s heading %q", name, heading)
|
|
}
|
|
if next := strings.Index(section, "\n##"); next >= 0 {
|
|
section = section[:next]
|
|
}
|
|
// Both shell heredocs and API schema blocks contain standalone JSON.
|
|
re := regexp.MustCompile("(?s)(?:<<'JSON'\\n|```json\\n)(.*?)(?:\\nJSON|\\n```)")
|
|
var bodies [][]byte
|
|
for _, match := range re.FindAllStringSubmatch(section, -1) {
|
|
if !json.Valid([]byte(match[1])) {
|
|
t.Fatalf("invalid JSON in %s/%s", name, heading)
|
|
}
|
|
bodies = append(bodies, []byte(match[1]))
|
|
}
|
|
if len(bodies) == 0 {
|
|
t.Fatalf("missing request JSON in %s/%s", name, heading)
|
|
}
|
|
return bodies
|
|
}
|
|
|
|
func TestAdminDocsCustomRoleLifecycle(t *testing.T) {
|
|
dataPath := t.TempDir()
|
|
resetPersistentAuthStoresForTests()
|
|
t.Cleanup(resetPersistentAuthStoresForTests)
|
|
InitPersistentAuthStores(dataPath)
|
|
const fixtureSession = "admin-docs-jane-session-fixture"
|
|
GetSessionStore().CreateSession(fixtureSession, time.Hour, "fixture", "127.0.0.1", "jane")
|
|
fixtureCSRF := GetCSRFStore().GenerateCSRFToken(fixtureSession)
|
|
manager, err := auth.NewFileManager(dataPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
previous := auth.GetManager()
|
|
auth.SetManager(manager)
|
|
t.Cleanup(func() { auth.SetManager(previous) })
|
|
h := NewRBACHandlers(&config.Config{AuthUser: "alice"})
|
|
|
|
// Exact-base guide control: creating/updating the built-in operator fails.
|
|
legacy := httptest.NewRecorder()
|
|
h.HandleRoles(legacy, withUser(httptest.NewRequest(http.MethodPost, "/api/admin/roles",
|
|
strings.NewReader(`{"id":"operator","name":"Operator"}`)), "alice"))
|
|
if legacy.Code != http.StatusInternalServerError {
|
|
t.Fatalf("legacy built-in mutation was not rejected: %d", legacy.Code)
|
|
}
|
|
|
|
assignments := adminDocBodies(t, "RBAC", "Setting Roles for a User")
|
|
if len(assignments) != 2 {
|
|
t.Fatalf("want assign and clear examples, got %d", len(assignments))
|
|
}
|
|
steps := []struct {
|
|
method, path string
|
|
body []byte
|
|
handler http.HandlerFunc
|
|
status int
|
|
}{
|
|
{http.MethodPost, "/api/admin/roles", adminDocBodies(t, "RBAC", "Creating a Role")[0], h.HandleRoles, http.StatusOK},
|
|
{http.MethodPut, "/api/admin/roles/alert-manager", adminDocBodies(t, "RBAC", "Updating a Role")[0], h.HandleRoles, http.StatusOK},
|
|
{http.MethodPut, "/api/admin/users/jane/roles", assignments[0], h.HandleUserRoleActions, http.StatusNoContent},
|
|
{http.MethodPut, "/api/admin/users/jane/roles", assignments[1], h.HandleUserRoleActions, http.StatusNoContent},
|
|
{http.MethodDelete, "/api/admin/users/jane", nil, h.HandleUserRoleActions, http.StatusNoContent},
|
|
{http.MethodDelete, "/api/admin/roles/alert-manager", nil, h.HandleRoles, http.StatusNoContent},
|
|
}
|
|
for _, step := range steps {
|
|
rec := httptest.NewRecorder()
|
|
step.handler(rec, withUser(httptest.NewRequest(step.method, step.path, bytes.NewReader(step.body)), "alice"))
|
|
if rec.Code != step.status {
|
|
t.Fatalf("documented %s %s: got %d: %s", step.method, step.path, rec.Code, rec.Body.String())
|
|
}
|
|
if step.method == http.MethodPut && step.path == "/api/admin/roles/alert-manager" {
|
|
role, exists := manager.GetRole("alert-manager")
|
|
if !exists || role.IsBuiltIn || len(role.Permissions) != 4 {
|
|
t.Fatalf("custom role update did not persist its permissions: %+v", role)
|
|
}
|
|
}
|
|
if step.method == http.MethodPut && strings.HasSuffix(step.path, "/jane/roles") {
|
|
assignment, exists := manager.GetUserAssignment("jane")
|
|
var want struct {
|
|
RoleIDs []string `json:"roleIds"`
|
|
}
|
|
if err := json.Unmarshal(step.body, &want); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !exists || strings.Join(assignment.RoleIDs, ",") != strings.Join(want.RoleIDs, ",") {
|
|
t.Fatalf("assignment did not persist the complete role list: %+v", assignment)
|
|
}
|
|
}
|
|
if step.method == http.MethodDelete && step.path == "/api/admin/users/jane" {
|
|
if _, exists := manager.GetUserAssignment("jane"); exists {
|
|
t.Fatal("user removal retained the role assignment")
|
|
}
|
|
if GetSessionStore().GetSession(fixtureSession) != nil || GetCSRFStore().ValidateCSRFToken(fixtureSession, fixtureCSRF) {
|
|
t.Fatal("user removal retained the session or its CSRF token")
|
|
}
|
|
}
|
|
}
|
|
if _, exists := manager.GetRole("alert-manager"); exists {
|
|
t.Fatal("custom role deletion did not persist")
|
|
}
|
|
if role, exists := manager.GetRole("operator"); !exists || !role.IsBuiltIn {
|
|
t.Fatal("examples altered the built-in role")
|
|
}
|
|
}
|
|
|
|
func TestAdminDocsOrganizationSchemasAndAcceptance(t *testing.T) {
|
|
t.Setenv("PULSE_DEV", "true")
|
|
wasEnabled := IsMultiTenantEnabled()
|
|
SetMultiTenantEnabled(true)
|
|
t.Cleanup(func() { SetMultiTenantEnabled(wasEnabled) })
|
|
persistence := config.NewMultiTenantPersistence(t.TempDir())
|
|
h := NewOrgHandlers(persistence, nil)
|
|
call := func(method, path, user, orgID string, body []byte, handler http.HandlerFunc) *httptest.ResponseRecorder {
|
|
req := withUser(httptest.NewRequest(method, path, bytes.NewReader(body)), user)
|
|
req.SetPathValue("id", orgID)
|
|
rec := httptest.NewRecorder()
|
|
handler(rec, req)
|
|
return rec
|
|
}
|
|
check := func(rec *httptest.ResponseRecorder, status int) {
|
|
t.Helper()
|
|
if rec.Code != status {
|
|
t.Fatalf("documented request: got %d, want %d: %s", rec.Code, status, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
// Exact-base guide controls: wrong creation fields and member PATCH fail.
|
|
check(call(http.MethodPost, "/api/orgs", "alice", "", []byte(`{"name":"Production Datacenter","description":"EU production infrastructure"}`), h.HandleCreateOrg), http.StatusBadRequest)
|
|
check(call(http.MethodPatch, "/api/orgs/production-datacenter/members/user-id", "alice", "production-datacenter", []byte(`{"role":"admin"}`), h.HandleInviteMember), http.StatusMethodNotAllowed)
|
|
|
|
creation := adminDocBodies(t, "MULTI_TENANT", "Creating an Organization")[0]
|
|
check(call(http.MethodPost, "/api/orgs", "alice", "", creation, h.HandleCreateOrg), http.StatusCreated)
|
|
check(call(http.MethodPost, "/api/orgs", "bob", "", []byte(`{"id":"other-org-id","displayName":"Other estate"}`), h.HandleCreateOrg), http.StatusCreated)
|
|
members := adminDocBodies(t, "MULTI_TENANT", "Managing Members")
|
|
if len(members) != 2 {
|
|
t.Fatalf("want invitation and existing-member update, got %d", len(members))
|
|
}
|
|
invitation := call(http.MethodPost, "/api/orgs/production-datacenter/members", "alice", "production-datacenter", members[0], h.HandleInviteMember)
|
|
check(invitation, http.StatusAccepted)
|
|
var invited organizationAccessMutationResponse
|
|
if err := json.Unmarshal(invitation.Body.Bytes(), &invited); err != nil || invited.Kind != "invitation" || invited.Member != nil {
|
|
t.Fatalf("new member was not pending: %s", invitation.Body.String())
|
|
}
|
|
acceptInvitationForTest(t, h, "production-datacenter", "user-id")
|
|
check(call(http.MethodPost, "/api/orgs/production-datacenter/members", "alice", "production-datacenter", members[1], h.HandleInviteMember), http.StatusOK)
|
|
org, err := persistence.LoadOrganization("production-datacenter")
|
|
if err != nil || organizationRoleForUser(org, "user-id") != models.OrgRoleAdmin {
|
|
t.Fatalf("documented role update did not persist: %v", err)
|
|
}
|
|
|
|
check(call(http.MethodPost, "/api/orgs/production-datacenter/shares", "alice", "production-datacenter", []byte(`{"targetOrgId":"other-org-id","resourceType":"host","resourceId":"resource-id","role":"viewer"}`), h.HandleCreateShare), http.StatusBadRequest)
|
|
shareBody := adminDocBodies(t, "MULTI_TENANT", "Sharing Resources")[0]
|
|
created := call(http.MethodPost, "/api/orgs/production-datacenter/shares", "alice", "production-datacenter", shareBody, h.HandleCreateShare)
|
|
check(created, http.StatusCreated)
|
|
var share models.OrganizationShare
|
|
if err := json.Unmarshal(created.Body.Bytes(), &share); err != nil || share.Status != models.OrganizationShareStatusPending || share.AccessRole != models.OrgRoleViewer {
|
|
t.Fatalf("documented share grant was not pending/viewer: %s", created.Body.String())
|
|
}
|
|
accepted := acceptIncomingShareForTest(t, h, "other-org-id", "bob", share.ID)
|
|
if accepted.Status != models.OrganizationShareStatusAccepted {
|
|
t.Fatal("share acceptance did not persist")
|
|
}
|
|
}
|