hotreload_test.go raw

   1  package policy
   2  
   3  import (
   4  	"context"
   5  	"encoding/json"
   6  	"os"
   7  	"path/filepath"
   8  	"strings"
   9  	"testing"
  10  	"time"
  11  
  12  	"github.com/adrg/xdg"
  13  )
  14  
  15  // setupHotreloadTestPolicy creates a policy manager with a temporary config file for hotreload tests.
  16  func setupHotreloadTestPolicy(t *testing.T, appName string) (*P, func()) {
  17  	t.Helper()
  18  
  19  	configDir := filepath.Join(xdg.ConfigHome, appName)
  20  	if err := os.MkdirAll(configDir, 0755); err != nil {
  21  		t.Fatalf("Failed to create config dir: %v", err)
  22  	}
  23  
  24  	configPath := filepath.Join(configDir, "policy.json")
  25  	defaultPolicy := []byte(`{"default_policy": "allow"}`)
  26  	if err := os.WriteFile(configPath, defaultPolicy, 0644); err != nil {
  27  		t.Fatalf("Failed to write policy file: %v", err)
  28  	}
  29  
  30  	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
  31  
  32  	policy := NewWithManager(ctx, appName, true, "")
  33  	if policy == nil {
  34  		cancel()
  35  		os.RemoveAll(configDir)
  36  		t.Fatal("Failed to create policy manager")
  37  	}
  38  
  39  	cleanup := func() {
  40  		cancel()
  41  		os.RemoveAll(configDir)
  42  	}
  43  
  44  	return policy, cleanup
  45  }
  46  
  47  // TestValidateJSON tests the ValidateJSON method with various inputs
  48  func TestValidateJSON(t *testing.T) {
  49  	policy, cleanup := setupHotreloadTestPolicy(t, "test-validate-json")
  50  	defer cleanup()
  51  
  52  	tests := []struct {
  53  		name        string
  54  		json        []byte
  55  		expectError bool
  56  		errorSubstr string
  57  	}{
  58  		{
  59  			name:        "valid empty policy",
  60  			json:        []byte(`{}`),
  61  			expectError: false,
  62  		},
  63  		{
  64  			name: "valid complete policy",
  65  			json: []byte(`{
  66  				"kind": {"whitelist": [1, 3, 7]},
  67  				"global": {"size_limit": 65536},
  68  				"rules": {
  69  					"1": {"description": "Short text notes", "content_limit": 8192}
  70  				},
  71  				"default_policy": "allow",
  72  				"policy_admins": ["0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"],
  73  				"policy_follow_whitelist_enabled": true
  74  			}`),
  75  			expectError: false,
  76  		},
  77  		{
  78  			name:        "invalid JSON syntax",
  79  			json:        []byte(`{"invalid": json}`),
  80  			expectError: true,
  81  			errorSubstr: "invalid character",
  82  		},
  83  		{
  84  			name:        "invalid JSON - missing closing brace",
  85  			json:        []byte(`{"kind": {"whitelist": [1]}`),
  86  			expectError: true,
  87  		},
  88  		{
  89  			name: "invalid policy_admins - wrong length",
  90  			json: []byte(`{
  91  				"policy_admins": ["not-64-chars"]
  92  			}`),
  93  			expectError: true,
  94  			errorSubstr: "invalid policy_admin pubkey",
  95  		},
  96  		{
  97  			name: "invalid policy_admins - non-hex characters",
  98  			json: []byte(`{
  99  				"policy_admins": ["zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"]
 100  			}`),
 101  			expectError: true,
 102  			errorSubstr: "invalid policy_admin pubkey",
 103  		},
 104  		{
 105  			name: "valid policy_admins - multiple admins",
 106  			json: []byte(`{
 107  				"policy_admins": [
 108  					"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
 109  					"fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210"
 110  				]
 111  			}`),
 112  			expectError: false,
 113  		},
 114  		{
 115  			name: "invalid tag_validation regex",
 116  			json: []byte(`{
 117  				"rules": {
 118  					"30023": {
 119  						"tag_validation": {
 120  							"d": "[invalid(regex"
 121  						}
 122  					}
 123  				}
 124  			}`),
 125  			expectError: true,
 126  			errorSubstr: "invalid regex",
 127  		},
 128  		{
 129  			name: "valid tag_validation regex",
 130  			json: []byte(`{
 131  				"rules": {
 132  					"30023": {
 133  						"tag_validation": {
 134  							"d": "^[a-z0-9-]{1,64}$",
 135  							"t": "^[a-z0-9-]{1,32}$"
 136  						}
 137  					}
 138  				}
 139  			}`),
 140  			expectError: false,
 141  		},
 142  		{
 143  			name: "invalid default_policy",
 144  			json: []byte(`{
 145  				"default_policy": "invalid"
 146  			}`),
 147  			expectError: true,
 148  			errorSubstr: "default_policy",
 149  		},
 150  		{
 151  			name: "valid default_policy allow",
 152  			json: []byte(`{
 153  				"default_policy": "allow"
 154  			}`),
 155  			expectError: false,
 156  		},
 157  		{
 158  			name: "valid default_policy deny",
 159  			json: []byte(`{
 160  				"default_policy": "deny"
 161  			}`),
 162  			expectError: false,
 163  		},
 164  		{
 165  			name: "valid owners - single owner",
 166  			json: []byte(`{
 167  				"owners": ["0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"]
 168  			}`),
 169  			expectError: false,
 170  		},
 171  		{
 172  			name: "valid owners - multiple owners",
 173  			json: []byte(`{
 174  				"owners": [
 175  					"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
 176  					"fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210"
 177  				]
 178  			}`),
 179  			expectError: false,
 180  		},
 181  		{
 182  			name: "invalid owners - wrong length",
 183  			json: []byte(`{
 184  				"owners": ["not-64-chars"]
 185  			}`),
 186  			expectError: true,
 187  			errorSubstr: "invalid owner pubkey",
 188  		},
 189  		{
 190  			name: "invalid owners - non-hex characters",
 191  			json: []byte(`{
 192  				"owners": ["zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"]
 193  			}`),
 194  			expectError: true,
 195  			errorSubstr: "invalid owner pubkey",
 196  		},
 197  		{
 198  			name: "valid policy with both owners and policy_admins",
 199  			json: []byte(`{
 200  				"owners": ["0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"],
 201  				"policy_admins": ["fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210"],
 202  				"policy_follow_whitelist_enabled": true
 203  			}`),
 204  			expectError: false,
 205  		},
 206  	}
 207  
 208  	for _, tt := range tests {
 209  		t.Run(tt.name, func(t *testing.T) {
 210  			err := policy.ValidateJSON(tt.json)
 211  			if tt.expectError {
 212  				if err == nil {
 213  					t.Errorf("Expected error but got none")
 214  					return
 215  				}
 216  				if tt.errorSubstr != "" && !containsSubstring(err.Error(), tt.errorSubstr) {
 217  					t.Errorf("Expected error containing %q, got: %v", tt.errorSubstr, err)
 218  				}
 219  				return
 220  			}
 221  			if err != nil {
 222  				t.Errorf("Unexpected error: %v", err)
 223  			}
 224  		})
 225  	}
 226  }
 227  
 228  // TestReload tests the Reload method
 229  func TestReload(t *testing.T) {
 230  	policy, cleanup := setupHotreloadTestPolicy(t, "test-reload")
 231  	defer cleanup()
 232  
 233  	// Create temp directory for policy files
 234  	tmpDir := t.TempDir()
 235  	configPath := filepath.Join(tmpDir, "policy.json")
 236  
 237  	tests := []struct {
 238  		name         string
 239  		initialJSON  []byte
 240  		reloadJSON   []byte
 241  		expectError  bool
 242  		checkAfter   func(t *testing.T, p *P)
 243  	}{
 244  		{
 245  			name:        "reload with valid policy",
 246  			initialJSON: []byte(`{"default_policy": "allow"}`),
 247  			reloadJSON: []byte(`{
 248  				"default_policy": "deny",
 249  				"kind": {"whitelist": [1, 3]},
 250  				"policy_admins": ["0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"]
 251  			}`),
 252  			expectError: false,
 253  			checkAfter: func(t *testing.T, p *P) {
 254  				if p.DefaultPolicy != "deny" {
 255  					t.Errorf("Expected default_policy to be 'deny', got %q", p.DefaultPolicy)
 256  				}
 257  				if len(p.Kind.Whitelist) != 2 {
 258  					t.Errorf("Expected 2 whitelisted kinds, got %d", len(p.Kind.Whitelist))
 259  				}
 260  				if len(p.PolicyAdmins) != 1 {
 261  					t.Errorf("Expected 1 policy admin, got %d", len(p.PolicyAdmins))
 262  				}
 263  			},
 264  		},
 265  		{
 266  			name:        "reload with invalid JSON fails without changes",
 267  			initialJSON: []byte(`{"default_policy": "allow"}`),
 268  			reloadJSON:  []byte(`{"invalid json`),
 269  			expectError: true,
 270  			checkAfter: func(t *testing.T, p *P) {
 271  				// Policy should remain unchanged
 272  				if p.DefaultPolicy != "allow" {
 273  					t.Errorf("Expected default_policy to remain 'allow', got %q", p.DefaultPolicy)
 274  				}
 275  			},
 276  		},
 277  		{
 278  			name:        "reload with invalid admin pubkey fails without changes",
 279  			initialJSON: []byte(`{"default_policy": "allow"}`),
 280  			reloadJSON: []byte(`{
 281  				"default_policy": "deny",
 282  				"policy_admins": ["invalid-pubkey"]
 283  			}`),
 284  			expectError: true,
 285  			checkAfter: func(t *testing.T, p *P) {
 286  				// Policy should remain unchanged
 287  				if p.DefaultPolicy != "allow" {
 288  					t.Errorf("Expected default_policy to remain 'allow', got %q", p.DefaultPolicy)
 289  				}
 290  			},
 291  		},
 292  	}
 293  
 294  	for _, tt := range tests {
 295  		t.Run(tt.name, func(t *testing.T) {
 296  			// Initialize policy with initial JSON
 297  			if tt.initialJSON != nil {
 298  				if err := policy.Reload(tt.initialJSON, configPath); err != nil {
 299  					t.Fatalf("Failed to set initial policy: %v", err)
 300  				}
 301  			}
 302  
 303  			// Attempt reload
 304  			err := policy.Reload(tt.reloadJSON, configPath)
 305  			if tt.expectError {
 306  				if err == nil {
 307  					t.Errorf("Expected error but got none")
 308  				}
 309  			} else {
 310  				if err != nil {
 311  					t.Errorf("Unexpected error: %v", err)
 312  				}
 313  			}
 314  
 315  			// Run post-reload checks
 316  			if tt.checkAfter != nil {
 317  				tt.checkAfter(t, policy)
 318  			}
 319  		})
 320  	}
 321  }
 322  
 323  // TestSaveToFile tests atomic file writing
 324  func TestSaveToFile(t *testing.T) {
 325  	policy, cleanup := setupHotreloadTestPolicy(t, "test-save-file")
 326  	defer cleanup()
 327  
 328  	tmpDir := t.TempDir()
 329  	configPath := filepath.Join(tmpDir, "policy.json")
 330  
 331  	// Load a policy
 332  	policyJSON := []byte(`{
 333  		"default_policy": "allow",
 334  		"kind": {"whitelist": [1, 3, 7]},
 335  		"policy_admins": ["0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"]
 336  	}`)
 337  
 338  	if err := policy.Reload(policyJSON, configPath); err != nil {
 339  		t.Fatalf("Failed to reload policy: %v", err)
 340  	}
 341  
 342  	// Verify file was saved
 343  	if _, err := os.Stat(configPath); os.IsNotExist(err) {
 344  		t.Errorf("Policy file was not created at %s", configPath)
 345  	}
 346  
 347  	// Read and verify contents
 348  	data, err := os.ReadFile(configPath)
 349  	if err != nil {
 350  		t.Fatalf("Failed to read policy file: %v", err)
 351  	}
 352  
 353  	if len(data) == 0 {
 354  		t.Error("Policy file is empty")
 355  	}
 356  
 357  	// Verify it's valid JSON
 358  	var parsed map[string]interface{}
 359  	if err := json.Unmarshal(data, &parsed); err != nil {
 360  		t.Errorf("Policy file contains invalid JSON: %v", err)
 361  	}
 362  }
 363  
 364  // TestPauseResume tests the Pause and Resume methods
 365  func TestPauseResume(t *testing.T) {
 366  	policy, cleanup := setupHotreloadTestPolicy(t, "test-pause-resume")
 367  	defer cleanup()
 368  
 369  	// Test Pause
 370  	if err := policy.Pause(); err != nil {
 371  		t.Errorf("Pause failed: %v", err)
 372  	}
 373  
 374  	// Test Resume
 375  	if err := policy.Resume(); err != nil {
 376  		t.Errorf("Resume failed: %v", err)
 377  	}
 378  
 379  	// Test multiple pause/resume cycles
 380  	for i := 0; i < 3; i++ {
 381  		if err := policy.Pause(); err != nil {
 382  			t.Errorf("Pause %d failed: %v", i, err)
 383  		}
 384  		if err := policy.Resume(); err != nil {
 385  			t.Errorf("Resume %d failed: %v", i, err)
 386  		}
 387  	}
 388  }
 389  
 390  // TestReloadPreservesExistingOnFailure verifies that failed reloads don't corrupt state
 391  func TestReloadPreservesExistingOnFailure(t *testing.T) {
 392  	policy, cleanup := setupHotreloadTestPolicy(t, "test-reload-preserve")
 393  	defer cleanup()
 394  
 395  	tmpDir := t.TempDir()
 396  	configPath := filepath.Join(tmpDir, "policy.json")
 397  
 398  	// Set up initial valid policy
 399  	initialJSON := []byte(`{
 400  		"default_policy": "allow",
 401  		"kind": {"whitelist": [1, 3, 7]},
 402  		"policy_admins": ["0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"],
 403  		"policy_follow_whitelist_enabled": true
 404  	}`)
 405  
 406  	if err := policy.Reload(initialJSON, configPath); err != nil {
 407  		t.Fatalf("Failed to set initial policy: %v", err)
 408  	}
 409  
 410  	// Store initial state
 411  	initialDefaultPolicy := policy.DefaultPolicy
 412  	initialKindWhitelist := len(policy.Kind.Whitelist)
 413  	initialAdminCount := len(policy.PolicyAdmins)
 414  	initialFollowEnabled := policy.PolicyFollowWhitelistEnabled
 415  
 416  	// Attempt to reload with invalid JSON
 417  	invalidJSON := []byte(`{"policy_admins": ["invalid"]}`)
 418  	err := policy.Reload(invalidJSON, configPath)
 419  	if err == nil {
 420  		t.Fatal("Expected error for invalid policy_admins but got none")
 421  	}
 422  
 423  	// Verify state is preserved
 424  	if policy.DefaultPolicy != initialDefaultPolicy {
 425  		t.Errorf("DefaultPolicy changed from %q to %q after failed reload",
 426  			initialDefaultPolicy, policy.DefaultPolicy)
 427  	}
 428  	if len(policy.Kind.Whitelist) != initialKindWhitelist {
 429  		t.Errorf("Kind.Whitelist length changed from %d to %d after failed reload",
 430  			initialKindWhitelist, len(policy.Kind.Whitelist))
 431  	}
 432  	if len(policy.PolicyAdmins) != initialAdminCount {
 433  		t.Errorf("PolicyAdmins length changed from %d to %d after failed reload",
 434  			initialAdminCount, len(policy.PolicyAdmins))
 435  	}
 436  	if policy.PolicyFollowWhitelistEnabled != initialFollowEnabled {
 437  		t.Errorf("PolicyFollowWhitelistEnabled changed from %v to %v after failed reload",
 438  			initialFollowEnabled, policy.PolicyFollowWhitelistEnabled)
 439  	}
 440  }
 441  
 442  // containsSubstring checks if a string contains a substring (case-insensitive)
 443  func containsSubstring(s, substr string) bool {
 444  	return strings.Contains(strings.ToLower(s), strings.ToLower(substr))
 445  }
 446  
 447  // TestGetOwnersBin tests the GetOwnersBin method for policy-defined owners
 448  func TestGetOwnersBin(t *testing.T) {
 449  	policy, cleanup := setupHotreloadTestPolicy(t, "test-get-owners-bin")
 450  	defer cleanup()
 451  
 452  	tmpDir := t.TempDir()
 453  	configPath := filepath.Join(tmpDir, "policy.json")
 454  
 455  	// Test 1: Policy with no owners
 456  	emptyJSON := []byte(`{"default_policy": "allow"}`)
 457  	if err := policy.Reload(emptyJSON, configPath); err != nil {
 458  		t.Fatalf("Failed to reload policy: %v", err)
 459  	}
 460  
 461  	owners := policy.GetOwnersBin()
 462  	if len(owners) != 0 {
 463  		t.Errorf("Expected 0 owners, got %d", len(owners))
 464  	}
 465  
 466  	// Test 2: Policy with owners
 467  	ownerHex := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
 468  	withOwnersJSON := []byte(`{
 469  		"default_policy": "allow",
 470  		"owners": ["` + ownerHex + `"]
 471  	}`)
 472  	if err := policy.Reload(withOwnersJSON, configPath); err != nil {
 473  		t.Fatalf("Failed to reload policy with owners: %v", err)
 474  	}
 475  
 476  	owners = policy.GetOwnersBin()
 477  	if len(owners) != 1 {
 478  		t.Errorf("Expected 1 owner, got %d", len(owners))
 479  	}
 480  	if len(owners) > 0 && len(owners[0]) != 32 {
 481  		t.Errorf("Expected owner binary to be 32 bytes, got %d", len(owners[0]))
 482  	}
 483  
 484  	// Test 3: GetOwners returns hex strings
 485  	hexOwners := policy.GetOwners()
 486  	if len(hexOwners) != 1 {
 487  		t.Errorf("Expected 1 hex owner, got %d", len(hexOwners))
 488  	}
 489  	if len(hexOwners) > 0 && hexOwners[0] != ownerHex {
 490  		t.Errorf("Expected owner %q, got %q", ownerHex, hexOwners[0])
 491  	}
 492  
 493  	// Test 4: Policy with multiple owners
 494  	owner2Hex := "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210"
 495  	multiOwnersJSON := []byte(`{
 496  		"default_policy": "allow",
 497  		"owners": ["` + ownerHex + `", "` + owner2Hex + `"]
 498  	}`)
 499  	if err := policy.Reload(multiOwnersJSON, configPath); err != nil {
 500  		t.Fatalf("Failed to reload policy with multiple owners: %v", err)
 501  	}
 502  
 503  	owners = policy.GetOwnersBin()
 504  	if len(owners) != 2 {
 505  		t.Errorf("Expected 2 owners, got %d", len(owners))
 506  	}
 507  }
 508