fix: replace hallucinated upgrade URLs with correct pulserelay.pro

Previous LLM sessions incorrectly inserted fake URLs (pulse.sh/pro and
yourpulse.io/pro) for the Pro upgrade links. Neither domain exists.

Replaced all 34 instances with the correct URL: https://pulserelay.pro/

Fixes #1077
This commit is contained in:
rcourtman 2026-01-10 21:39:39 +00:00
parent d6554a0d87
commit 55f5f071ed
20 changed files with 558 additions and 34 deletions

View file

@ -1167,7 +1167,7 @@ export const AIChat: Component<AIChatProps> = (props) => {
AI Patrol monitors your infrastructure 24/7 and surfaces issues before they become outages.
</p>
<a
href={patrolStatus()?.upgrade_url || 'https://pulse.sh/pro'}
href={patrolStatus()?.upgrade_url || 'https://pulserelay.pro/'}
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1.5 mt-2.5 text-xs font-bold text-purple-600 dark:text-purple-400 hover:text-purple-700 dark:hover:text-purple-300 transition-colors group/link"

View file

@ -215,7 +215,7 @@ export const KubernetesClusters: Component<KubernetesClustersProps> = (props) =>
const kubernetesAiEnabled = createMemo(() => licenseFeatures()?.features?.kubernetes_ai === true);
const aiConfigured = createMemo(() => aiSettings()?.configured === true);
const upgradeUrl = createMemo(() => licenseFeatures()?.upgrade_url || 'https://pulse.sh/pro');
const upgradeUrl = createMemo(() => licenseFeatures()?.upgrade_url || 'https://pulserelay.pro/');
const clustersForAnalysis = createMemo(() => props.clusters ?? []);

View file

@ -1186,7 +1186,7 @@ export const AISettings: Component = () => {
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2">
<a
class="text-indigo-600 dark:text-indigo-400 font-medium hover:underline"
href="https://pulse.sh/pro"
href="https://pulserelay.pro/"
target="_blank"
rel="noreferrer"
>
@ -1275,7 +1275,7 @@ export const AISettings: Component = () => {
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
<a
class="text-indigo-600 dark:text-indigo-400 font-medium hover:underline"
href="https://pulse.sh/pro"
href="https://pulserelay.pro/"
target="_blank"
rel="noreferrer"
>
@ -1317,7 +1317,7 @@ export const AISettings: Component = () => {
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2">
<a
class="text-indigo-600 dark:text-indigo-400 font-medium hover:underline"
href="https://pulse.sh/pro"
href="https://pulserelay.pro/"
target="_blank"
rel="noreferrer"
>

View file

@ -260,7 +260,7 @@ export const AgentProfilesPanel: Component = () => {
logging levels, and reporting intervals from a central location.
</p>
<a
href="https://www.yourpulse.io/pro"
href="https://pulserelay.pro/"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-2 rounded-lg bg-gradient-to-r from-amber-500 to-orange-500 px-4 py-2 text-sm font-medium text-white transition-all hover:from-amber-600 hover:to-orange-600"

View file

@ -509,7 +509,7 @@ export default function AuditLogPanel() {
</p>
</div>
<a
href="https://pulse.sh/pro"
href="https://pulserelay.pro/"
target="_blank"
class="px-5 py-2.5 text-sm font-semibold bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors"
>

View file

@ -289,7 +289,7 @@ export const OIDCPanel: Component<Props> = (props) => {
</p>
</div>
<a
href="https://pulse.sh/pro"
href="https://pulserelay.pro/"
target="_blank"
class="px-5 py-2.5 text-sm font-semibold bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors"
>

View file

@ -5,7 +5,7 @@ import { notificationStore } from '@/stores/notifications';
import { LicenseAPI, type LicenseStatus } from '@/api/license';
import RefreshCw from 'lucide-solid/icons/refresh-cw';
const PULSE_PRO_URL = 'https://pulse.sh/pro';
const PULSE_PRO_URL = 'https://pulserelay.pro/';
const TIER_LABELS: Record<string, string> = {
free: 'Free',

View file

@ -157,7 +157,7 @@ export const RolesPanel: Component = () => {
</p>
</div>
<a
href="https://pulse.sh/pro"
href="https://pulserelay.pro/"
target="_blank"
rel="noopener noreferrer"
class="px-5 py-2.5 text-sm font-semibold bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors"

View file

@ -139,7 +139,7 @@ export const UserAssignmentsPanel: Component = () => {
</p>
</div>
<a
href="https://pulse.sh/pro"
href="https://pulserelay.pro/"
target="_blank"
rel="noopener noreferrer"
class="px-5 py-2.5 text-sm font-semibold bg-teal-600 text-white rounded-lg hover:bg-teal-700 transition-colors"

View file

@ -3,7 +3,7 @@ import { AIAPI } from '@/api/ai';
import { logger } from '@/utils/logger';
import type { RemediationRecord, RemediationStats } from '@/types/aiIntelligence';
const DEFAULT_UPGRADE_URL = 'https://pulse.sh/pro';
const DEFAULT_UPGRADE_URL = 'https://pulserelay.pro/';
export const AIImpactTimelinePanel: Component<{ hours?: number; showWhenEmpty?: boolean }> = (props) => {
const [remediations, setRemediations] = createSignal<RemediationRecord[]>([]);

View file

@ -14,7 +14,7 @@ export const AIInsightsPanel: Component<{ resourceId?: string; showWhenEmpty?: b
const [expanded, setExpanded] = createSignal(false);
const [locked, setLocked] = createSignal(false);
const [lockedCount, setLockedCount] = createSignal(0);
const [upgradeUrl, setUpgradeUrl] = createSignal('https://pulse.sh/pro');
const [upgradeUrl, setUpgradeUrl] = createSignal('https://pulserelay.pro/');
const [error, setError] = createSignal('');
const showWhenEmpty = () => Boolean(props.showWhenEmpty);
@ -28,7 +28,7 @@ export const AIInsightsPanel: Component<{ resourceId?: string; showWhenEmpty?: b
]);
const licenseLocked = Boolean(predResp.license_required || corrResp.license_required);
setLocked(licenseLocked);
setUpgradeUrl(predResp.upgrade_url || corrResp.upgrade_url || 'https://pulse.sh/pro');
setUpgradeUrl(predResp.upgrade_url || corrResp.upgrade_url || 'https://pulserelay.pro/');
if (licenseLocked) {
const predCount = predResp.count || 0;
const corrCount = corrResp.count || 0;

View file

@ -3,7 +3,7 @@ import { AIAPI } from '@/api/ai';
import { logger } from '@/utils/logger';
import type { FailurePrediction, InfrastructureChange, RemediationRecord, RemediationStats, AnomalyReport } from '@/types/aiIntelligence';
const DEFAULT_UPGRADE_URL = 'https://pulse.sh/pro';
const DEFAULT_UPGRADE_URL = 'https://pulserelay.pro/';
interface InsightRow {
id: string;

View file

@ -3,7 +3,7 @@ import { AIAPI } from '@/api/ai';
import { logger } from '@/utils/logger';
import type { InfrastructureChange } from '@/types/aiIntelligence';
const DEFAULT_UPGRADE_URL = 'https://pulse.sh/pro';
const DEFAULT_UPGRADE_URL = 'https://pulserelay.pro/';
export const AIRecentChangesPanel: Component<{ hours?: number; showWhenEmpty?: boolean }> = (props) => {
const [changes, setChanges] = createSignal<InfrastructureChange[]>([]);

View file

@ -2283,7 +2283,7 @@ function OverviewTab(props: {
if (!status) return false;
return !status.features?.['ai_alerts'];
});
const aiAlertsUpgradeURL = createMemo(() => licenseFeatures()?.upgrade_url || 'https://pulse.sh/pro');
const aiAlertsUpgradeURL = createMemo(() => licenseFeatures()?.upgrade_url || 'https://pulserelay.pro/');
// Live streaming state for running patrol
const [expandedLiveStream, setExpandedLiveStream] = createSignal(false);
// Track streaming blocks for sequential display (like AI chat)
@ -2296,7 +2296,7 @@ function OverviewTab(props: {
const [currentThinking, setCurrentThinking] = createSignal('');
let liveStreamUnsubscribe: (() => void) | null = null;
const patrolRequiresLicense = createMemo(() => patrolStatus()?.license_required === true);
const patrolUpgradeURL = createMemo(() => patrolStatus()?.upgrade_url || 'https://pulse.sh/pro');
const patrolUpgradeURL = createMemo(() => patrolStatus()?.upgrade_url || 'https://pulserelay.pro/');
const patrolLicenseNote = createMemo(() => {
if (!patrolRequiresLicense()) return '';
const status = patrolStatus()?.license_status;

View file

@ -385,7 +385,7 @@ func (h *AISettingsHandler) HandleUpdateAISettings(w http.ResponseWriter, r *htt
"error": "license_required",
"message": "AI Auto-Fix requires Pulse Pro",
"feature": ai.FeatureAIAutoFix,
"upgrade_url": "https://pulse.sh/pro",
"upgrade_url": "https://pulserelay.pro/",
})
return
}
@ -401,7 +401,7 @@ func (h *AISettingsHandler) HandleUpdateAISettings(w http.ResponseWriter, r *htt
"error": "license_required",
"message": "Proactive Thresholds requires Pulse Pro",
"feature": ai.FeatureAIPatrol,
"upgrade_url": "https://pulse.sh/pro",
"upgrade_url": "https://pulserelay.pro/",
})
return
}
@ -421,7 +421,7 @@ func (h *AISettingsHandler) HandleUpdateAISettings(w http.ResponseWriter, r *htt
"error": "license_required",
"message": "Autonomous Mode requires Pulse Pro",
"feature": ai.FeatureAIAutoFix,
"upgrade_url": "https://pulse.sh/pro",
"upgrade_url": "https://pulserelay.pro/",
})
return
}
@ -502,7 +502,7 @@ func (h *AISettingsHandler) HandleUpdateAISettings(w http.ResponseWriter, r *htt
"error": "license_required",
"message": "Background AI Patrol requires Pulse Pro",
"feature": ai.FeatureAIPatrol,
"upgrade_url": "https://pulse.sh/pro",
"upgrade_url": "https://pulserelay.pro/",
})
return
}
@ -522,7 +522,7 @@ func (h *AISettingsHandler) HandleUpdateAISettings(w http.ResponseWriter, r *htt
"error": "license_required",
"message": "Background AI Patrol requires Pulse Pro",
"feature": ai.FeatureAIPatrol,
"upgrade_url": "https://pulse.sh/pro",
"upgrade_url": "https://pulserelay.pro/",
})
return
}
@ -555,7 +555,7 @@ func (h *AISettingsHandler) HandleUpdateAISettings(w http.ResponseWriter, r *htt
"error": "license_required",
"message": "AI Alert Analysis requires Pulse Pro",
"feature": ai.FeatureAIAlerts,
"upgrade_url": "https://pulse.sh/pro",
"upgrade_url": "https://pulserelay.pro/",
})
return
}
@ -892,7 +892,7 @@ func (h *AISettingsHandler) HandleExecute(w http.ResponseWriter, r *http.Request
"error": "license_required",
"message": "AI Auto-Fix requires Pulse Pro",
"feature": ai.FeatureAIAutoFix,
"upgrade_url": "https://pulse.sh/pro",
"upgrade_url": "https://pulserelay.pro/",
})
return
}
@ -904,7 +904,7 @@ func (h *AISettingsHandler) HandleExecute(w http.ResponseWriter, r *http.Request
"error": "license_required",
"message": "AI Patrol reasoning requires Pulse Pro",
"feature": ai.FeatureAIPatrol,
"upgrade_url": "https://pulse.sh/pro",
"upgrade_url": "https://pulserelay.pro/",
})
return
}
@ -1078,7 +1078,7 @@ func (h *AISettingsHandler) HandleExecuteStream(w http.ResponseWriter, r *http.R
"error": "license_required",
"message": "AI Auto-Fix requires Pulse Pro",
"feature": ai.FeatureAIAutoFix,
"upgrade_url": "https://pulse.sh/pro",
"upgrade_url": "https://pulserelay.pro/",
})
return
}
@ -1090,7 +1090,7 @@ func (h *AISettingsHandler) HandleExecuteStream(w http.ResponseWriter, r *http.R
"error": "license_required",
"message": "AI Patrol reasoning requires Pulse Pro",
"feature": ai.FeatureAIPatrol,
"upgrade_url": "https://pulse.sh/pro",
"upgrade_url": "https://pulserelay.pro/",
})
return
}
@ -1312,7 +1312,7 @@ func (h *AISettingsHandler) HandleRunCommand(w http.ResponseWriter, r *http.Requ
"error": "license_required",
"message": "AI Auto-Fix requires Pulse Pro",
"feature": ai.FeatureAIAutoFix,
"upgrade_url": "https://pulse.sh/pro",
"upgrade_url": "https://pulserelay.pro/",
})
return
}
@ -2298,7 +2298,7 @@ func (h *AISettingsHandler) HandleGetPatrolStatus(w http.ResponseWriter, r *http
LicenseStatus: licenseStatus,
}
if !hasPatrolFeature {
response.UpgradeURL = "https://pulse.sh/pro"
response.UpgradeURL = "https://pulserelay.pro/"
}
response.Summary.Critical = summary.Critical
response.Summary.Warning = summary.Warning

View file

@ -11,7 +11,7 @@ import (
"github.com/rs/zerolog/log"
)
const aiIntelligenceUpgradeURL = "https://pulse.sh/pro"
const aiIntelligenceUpgradeURL = "https://pulserelay.pro/"
// HandleGetPatterns returns detected failure patterns (GET /api/ai/intelligence/patterns)
func (h *AISettingsHandler) HandleGetPatterns(w http.ResponseWriter, r *http.Request) {

View file

@ -102,7 +102,7 @@ func (h *LicenseHandlers) HandleLicenseFeatures(w http.ResponseWriter, r *http.R
license.FeatureAuditLogging: h.service.HasFeature(license.FeatureAuditLogging),
license.FeatureAdvancedReporting: h.service.HasFeature(license.FeatureAdvancedReporting),
},
UpgradeURL: "https://pulse.sh/pro",
UpgradeURL: "https://pulserelay.pro/",
}
w.Header().Set("Content-Type", "application/json")
@ -228,7 +228,7 @@ func RequireLicenseFeature(service *license.Service, feature string, next http.H
"error": "license_required",
"message": err.Error(),
"feature": feature,
"upgrade_url": "https://pulse.sh/pro",
"upgrade_url": "https://pulserelay.pro/",
})
return
}

View file

@ -1330,7 +1330,7 @@ func (r *Router) setupRoutes() {
"error": "license_required",
"message": err.Error(),
"feature": license.FeatureAIPatrol,
"upgrade_url": "https://pulse.sh/pro",
"upgrade_url": "https://pulserelay.pro/",
})
return
}

350
pkg/auth/rbac_manager.go Normal file
View file

@ -0,0 +1,350 @@
package auth
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"time"
)
// FileManager implements the Manager interface with file-based persistence.
type FileManager struct {
mu sync.RWMutex
dataDir string
roles map[string]Role
assignments map[string]UserRoleAssignment
}
// NewFileManager creates a new file-based RBAC manager.
func NewFileManager(dataDir string) (*FileManager, error) {
m := &FileManager{
dataDir: dataDir,
roles: make(map[string]Role),
assignments: make(map[string]UserRoleAssignment),
}
// Initialize built-in roles
m.initBuiltInRoles()
// Load persisted data
if err := m.load(); err != nil {
// Non-fatal - just start fresh if no data exists
if !os.IsNotExist(err) {
return nil, fmt.Errorf("failed to load RBAC data: %w", err)
}
}
return m, nil
}
func (m *FileManager) initBuiltInRoles() {
now := time.Now()
builtInRoles := []Role{
{
ID: RoleAdmin,
Name: "Administrator",
Description: "Full administrative access to all features",
Permissions: []Permission{
{Action: "admin", Resource: "*"},
},
IsBuiltIn: true,
CreatedAt: now,
UpdatedAt: now,
},
{
ID: RoleOperator,
Name: "Operator",
Description: "Can manage nodes and perform operational tasks",
Permissions: []Permission{
{Action: "read", Resource: "*"},
{Action: "write", Resource: "nodes"},
{Action: "write", Resource: "vms"},
{Action: "write", Resource: "containers"},
{Action: "write", Resource: "alerts"},
},
IsBuiltIn: true,
CreatedAt: now,
UpdatedAt: now,
},
{
ID: RoleViewer,
Name: "Viewer",
Description: "Read-only access to monitoring data",
Permissions: []Permission{
{Action: "read", Resource: "*"},
},
IsBuiltIn: true,
CreatedAt: now,
UpdatedAt: now,
},
{
ID: RoleAuditor,
Name: "Auditor",
Description: "Access to audit logs and compliance data",
Permissions: []Permission{
{Action: "read", Resource: "audit_logs"},
{Action: "read", Resource: "nodes"},
{Action: "read", Resource: "alerts"},
{Action: "read", Resource: "compliance"},
},
IsBuiltIn: true,
CreatedAt: now,
UpdatedAt: now,
},
}
for _, role := range builtInRoles {
m.roles[role.ID] = role
}
}
func (m *FileManager) rolesFile() string {
return filepath.Join(m.dataDir, "rbac_roles.json")
}
func (m *FileManager) assignmentsFile() string {
return filepath.Join(m.dataDir, "rbac_assignments.json")
}
func (m *FileManager) load() error {
// Load custom roles (built-in roles are always initialized)
if data, err := os.ReadFile(m.rolesFile()); err == nil {
var roles []Role
if err := json.Unmarshal(data, &roles); err != nil {
return err
}
for _, role := range roles {
if !role.IsBuiltIn {
m.roles[role.ID] = role
}
}
}
// Load assignments
if data, err := os.ReadFile(m.assignmentsFile()); err == nil {
var assignments []UserRoleAssignment
if err := json.Unmarshal(data, &assignments); err != nil {
return err
}
for _, a := range assignments {
m.assignments[a.Username] = a
}
}
return nil
}
func (m *FileManager) saveRoles() error {
roles := make([]Role, 0, len(m.roles))
for _, role := range m.roles {
roles = append(roles, role)
}
data, err := json.MarshalIndent(roles, "", " ")
if err != nil {
return err
}
return os.WriteFile(m.rolesFile(), data, 0600)
}
func (m *FileManager) saveAssignments() error {
assignments := make([]UserRoleAssignment, 0, len(m.assignments))
for _, a := range m.assignments {
assignments = append(assignments, a)
}
data, err := json.MarshalIndent(assignments, "", " ")
if err != nil {
return err
}
return os.WriteFile(m.assignmentsFile(), data, 0600)
}
// GetRoles returns all roles.
func (m *FileManager) GetRoles() []Role {
m.mu.RLock()
defer m.mu.RUnlock()
roles := make([]Role, 0, len(m.roles))
for _, role := range m.roles {
roles = append(roles, role)
}
return roles
}
// GetRole returns a role by ID.
func (m *FileManager) GetRole(id string) (Role, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
role, ok := m.roles[id]
return role, ok
}
// SaveRole creates or updates a role.
func (m *FileManager) SaveRole(role Role) error {
m.mu.Lock()
defer m.mu.Unlock()
// Cannot modify built-in roles
if existing, ok := m.roles[role.ID]; ok && existing.IsBuiltIn {
return fmt.Errorf("cannot modify built-in role: %s", role.ID)
}
role.UpdatedAt = time.Now()
if role.CreatedAt.IsZero() {
role.CreatedAt = role.UpdatedAt
}
m.roles[role.ID] = role
return m.saveRoles()
}
// DeleteRole removes a role by ID.
func (m *FileManager) DeleteRole(id string) error {
m.mu.Lock()
defer m.mu.Unlock()
role, ok := m.roles[id]
if !ok {
return nil // Already deleted
}
if role.IsBuiltIn {
return fmt.Errorf("cannot delete built-in role: %s", id)
}
delete(m.roles, id)
return m.saveRoles()
}
// GetUserAssignments returns all user role assignments.
func (m *FileManager) GetUserAssignments() []UserRoleAssignment {
m.mu.RLock()
defer m.mu.RUnlock()
assignments := make([]UserRoleAssignment, 0, len(m.assignments))
for _, a := range m.assignments {
assignments = append(assignments, a)
}
return assignments
}
// GetUserAssignment returns the role assignment for a user.
func (m *FileManager) GetUserAssignment(username string) (UserRoleAssignment, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
a, ok := m.assignments[username]
return a, ok
}
// AssignRole adds a role to a user.
func (m *FileManager) AssignRole(username string, roleID string) error {
m.mu.Lock()
defer m.mu.Unlock()
// Verify role exists
if _, ok := m.roles[roleID]; !ok {
return fmt.Errorf("role not found: %s", roleID)
}
a, ok := m.assignments[username]
if !ok {
a = UserRoleAssignment{
Username: username,
RoleIDs: []string{},
}
}
// Check if already assigned
for _, id := range a.RoleIDs {
if id == roleID {
return nil // Already assigned
}
}
a.RoleIDs = append(a.RoleIDs, roleID)
a.UpdatedAt = time.Now()
m.assignments[username] = a
return m.saveAssignments()
}
// UpdateUserRoles replaces all roles for a user.
func (m *FileManager) UpdateUserRoles(username string, roleIDs []string) error {
m.mu.Lock()
defer m.mu.Unlock()
// Verify all roles exist
for _, roleID := range roleIDs {
if _, ok := m.roles[roleID]; !ok {
return fmt.Errorf("role not found: %s", roleID)
}
}
m.assignments[username] = UserRoleAssignment{
Username: username,
RoleIDs: roleIDs,
UpdatedAt: time.Now(),
}
return m.saveAssignments()
}
// RemoveRole removes a role from a user.
func (m *FileManager) RemoveRole(username string, roleID string) error {
m.mu.Lock()
defer m.mu.Unlock()
a, ok := m.assignments[username]
if !ok {
return nil // No assignment exists
}
newRoles := make([]string, 0, len(a.RoleIDs))
for _, id := range a.RoleIDs {
if id != roleID {
newRoles = append(newRoles, id)
}
}
a.RoleIDs = newRoles
a.UpdatedAt = time.Now()
m.assignments[username] = a
return m.saveAssignments()
}
// GetUserPermissions returns the effective permissions for a user.
func (m *FileManager) GetUserPermissions(username string) []Permission {
m.mu.RLock()
defer m.mu.RUnlock()
a, ok := m.assignments[username]
if !ok {
return nil
}
// Collect unique permissions from all assigned roles
permMap := make(map[string]Permission)
for _, roleID := range a.RoleIDs {
if role, ok := m.roles[roleID]; ok {
for _, perm := range role.Permissions {
key := perm.Action + ":" + perm.Resource
permMap[key] = perm
}
}
}
perms := make([]Permission, 0, len(permMap))
for _, perm := range permMap {
perms = append(perms, perm)
}
return perms
}

View file

@ -0,0 +1,174 @@
package auth
import (
"os"
"testing"
)
func TestFileManager(t *testing.T) {
// Create temp directory for tests
tmpDir, err := os.MkdirTemp("", "rbac-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
m, err := NewFileManager(tmpDir)
if err != nil {
t.Fatalf("Failed to create FileManager: %v", err)
}
t.Run("Built-in roles exist", func(t *testing.T) {
roles := m.GetRoles()
if len(roles) < 4 {
t.Errorf("Expected at least 4 built-in roles, got %d", len(roles))
}
// Check admin role exists
admin, ok := m.GetRole(RoleAdmin)
if !ok {
t.Error("Admin role not found")
}
if !admin.IsBuiltIn {
t.Error("Admin role should be built-in")
}
})
t.Run("Cannot delete built-in role", func(t *testing.T) {
err := m.DeleteRole(RoleAdmin)
if err == nil {
t.Error("Expected error when deleting built-in role")
}
})
t.Run("Cannot modify built-in role", func(t *testing.T) {
admin, _ := m.GetRole(RoleAdmin)
admin.Name = "Modified Admin"
err := m.SaveRole(admin)
if err == nil {
t.Error("Expected error when modifying built-in role")
}
})
t.Run("Create custom role", func(t *testing.T) {
customRole := Role{
ID: "custom",
Name: "Custom Role",
Description: "A custom test role",
Permissions: []Permission{{Action: "read", Resource: "nodes"}},
}
if err := m.SaveRole(customRole); err != nil {
t.Errorf("Failed to save custom role: %v", err)
}
retrieved, ok := m.GetRole("custom")
if !ok {
t.Error("Custom role not found after save")
}
if retrieved.Name != "Custom Role" {
t.Errorf("Expected name 'Custom Role', got '%s'", retrieved.Name)
}
})
t.Run("Delete custom role", func(t *testing.T) {
if err := m.DeleteRole("custom"); err != nil {
t.Errorf("Failed to delete custom role: %v", err)
}
_, ok := m.GetRole("custom")
if ok {
t.Error("Custom role should not exist after delete")
}
})
t.Run("Assign role to user", func(t *testing.T) {
if err := m.AssignRole("testuser", RoleViewer); err != nil {
t.Errorf("Failed to assign role: %v", err)
}
assignment, ok := m.GetUserAssignment("testuser")
if !ok {
t.Error("User assignment not found")
}
if len(assignment.RoleIDs) != 1 || assignment.RoleIDs[0] != RoleViewer {
t.Errorf("Expected viewer role, got %v", assignment.RoleIDs)
}
})
t.Run("Get user permissions", func(t *testing.T) {
perms := m.GetUserPermissions("testuser")
if len(perms) == 0 {
t.Error("Expected permissions for user with viewer role")
}
hasRead := false
for _, p := range perms {
if p.Action == "read" {
hasRead = true
break
}
}
if !hasRead {
t.Error("Viewer role should have read permissions")
}
})
t.Run("Update user roles", func(t *testing.T) {
if err := m.UpdateUserRoles("testuser", []string{RoleAdmin, RoleOperator}); err != nil {
t.Errorf("Failed to update user roles: %v", err)
}
assignment, _ := m.GetUserAssignment("testuser")
if len(assignment.RoleIDs) != 2 {
t.Errorf("Expected 2 roles, got %d", len(assignment.RoleIDs))
}
})
t.Run("Remove role from user", func(t *testing.T) {
if err := m.RemoveRole("testuser", RoleOperator); err != nil {
t.Errorf("Failed to remove role: %v", err)
}
assignment, _ := m.GetUserAssignment("testuser")
if len(assignment.RoleIDs) != 1 {
t.Errorf("Expected 1 role after removal, got %d", len(assignment.RoleIDs))
}
})
t.Run("Persistence across reload", func(t *testing.T) {
// Save a custom role
customRole := Role{
ID: "persistent",
Name: "Persistent Role",
Description: "Should survive reload",
Permissions: []Permission{{Action: "write", Resource: "alerts"}},
}
if err := m.SaveRole(customRole); err != nil {
t.Fatalf("Failed to save role: %v", err)
}
// Create new manager with same data dir
m2, err := NewFileManager(tmpDir)
if err != nil {
t.Fatalf("Failed to create second FileManager: %v", err)
}
// Check custom role persisted
retrieved, ok := m2.GetRole("persistent")
if !ok {
t.Error("Custom role should persist across reload")
}
if retrieved.Name != "Persistent Role" {
t.Errorf("Expected 'Persistent Role', got '%s'", retrieved.Name)
}
// Check user assignment persisted
assignment, ok := m2.GetUserAssignment("testuser")
if !ok {
t.Error("User assignment should persist across reload")
}
if len(assignment.RoleIDs) == 0 {
t.Error("User should still have roles after reload")
}
})
}