Introduced default security settings with stricter access control, including policies requiring owner/admin privileges by default. Added multiple pre-configured policy recipes, custom validator support, and extended documentation for security, configurations, and use cases.
508 lines
14 KiB
Go
508 lines
14 KiB
Go
package policy
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/adrg/xdg"
|
|
)
|
|
|
|
// setupHotreloadTestPolicy creates a policy manager with a temporary config file for hotreload tests.
|
|
func setupHotreloadTestPolicy(t *testing.T, appName string) (*P, func()) {
|
|
t.Helper()
|
|
|
|
configDir := filepath.Join(xdg.ConfigHome, appName)
|
|
if err := os.MkdirAll(configDir, 0755); err != nil {
|
|
t.Fatalf("Failed to create config dir: %v", err)
|
|
}
|
|
|
|
configPath := filepath.Join(configDir, "policy.json")
|
|
defaultPolicy := []byte(`{"default_policy": "allow"}`)
|
|
if err := os.WriteFile(configPath, defaultPolicy, 0644); err != nil {
|
|
t.Fatalf("Failed to write policy file: %v", err)
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
|
|
policy := NewWithManager(ctx, appName, true)
|
|
if policy == nil {
|
|
cancel()
|
|
os.RemoveAll(configDir)
|
|
t.Fatal("Failed to create policy manager")
|
|
}
|
|
|
|
cleanup := func() {
|
|
cancel()
|
|
os.RemoveAll(configDir)
|
|
}
|
|
|
|
return policy, cleanup
|
|
}
|
|
|
|
// TestValidateJSON tests the ValidateJSON method with various inputs
|
|
func TestValidateJSON(t *testing.T) {
|
|
policy, cleanup := setupHotreloadTestPolicy(t, "test-validate-json")
|
|
defer cleanup()
|
|
|
|
tests := []struct {
|
|
name string
|
|
json []byte
|
|
expectError bool
|
|
errorSubstr string
|
|
}{
|
|
{
|
|
name: "valid empty policy",
|
|
json: []byte(`{}`),
|
|
expectError: false,
|
|
},
|
|
{
|
|
name: "valid complete policy",
|
|
json: []byte(`{
|
|
"kind": {"whitelist": [1, 3, 7]},
|
|
"global": {"size_limit": 65536},
|
|
"rules": {
|
|
"1": {"description": "Short text notes", "content_limit": 8192}
|
|
},
|
|
"default_policy": "allow",
|
|
"policy_admins": ["0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"],
|
|
"policy_follow_whitelist_enabled": true
|
|
}`),
|
|
expectError: false,
|
|
},
|
|
{
|
|
name: "invalid JSON syntax",
|
|
json: []byte(`{"invalid": json}`),
|
|
expectError: true,
|
|
errorSubstr: "invalid character",
|
|
},
|
|
{
|
|
name: "invalid JSON - missing closing brace",
|
|
json: []byte(`{"kind": {"whitelist": [1]}`),
|
|
expectError: true,
|
|
},
|
|
{
|
|
name: "invalid policy_admins - wrong length",
|
|
json: []byte(`{
|
|
"policy_admins": ["not-64-chars"]
|
|
}`),
|
|
expectError: true,
|
|
errorSubstr: "invalid policy_admin pubkey",
|
|
},
|
|
{
|
|
name: "invalid policy_admins - non-hex characters",
|
|
json: []byte(`{
|
|
"policy_admins": ["zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"]
|
|
}`),
|
|
expectError: true,
|
|
errorSubstr: "invalid policy_admin pubkey",
|
|
},
|
|
{
|
|
name: "valid policy_admins - multiple admins",
|
|
json: []byte(`{
|
|
"policy_admins": [
|
|
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
|
"fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210"
|
|
]
|
|
}`),
|
|
expectError: false,
|
|
},
|
|
{
|
|
name: "invalid tag_validation regex",
|
|
json: []byte(`{
|
|
"rules": {
|
|
"30023": {
|
|
"tag_validation": {
|
|
"d": "[invalid(regex"
|
|
}
|
|
}
|
|
}
|
|
}`),
|
|
expectError: true,
|
|
errorSubstr: "invalid regex",
|
|
},
|
|
{
|
|
name: "valid tag_validation regex",
|
|
json: []byte(`{
|
|
"rules": {
|
|
"30023": {
|
|
"tag_validation": {
|
|
"d": "^[a-z0-9-]{1,64}$",
|
|
"t": "^[a-z0-9-]{1,32}$"
|
|
}
|
|
}
|
|
}
|
|
}`),
|
|
expectError: false,
|
|
},
|
|
{
|
|
name: "invalid default_policy",
|
|
json: []byte(`{
|
|
"default_policy": "invalid"
|
|
}`),
|
|
expectError: true,
|
|
errorSubstr: "default_policy",
|
|
},
|
|
{
|
|
name: "valid default_policy allow",
|
|
json: []byte(`{
|
|
"default_policy": "allow"
|
|
}`),
|
|
expectError: false,
|
|
},
|
|
{
|
|
name: "valid default_policy deny",
|
|
json: []byte(`{
|
|
"default_policy": "deny"
|
|
}`),
|
|
expectError: false,
|
|
},
|
|
{
|
|
name: "valid owners - single owner",
|
|
json: []byte(`{
|
|
"owners": ["0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"]
|
|
}`),
|
|
expectError: false,
|
|
},
|
|
{
|
|
name: "valid owners - multiple owners",
|
|
json: []byte(`{
|
|
"owners": [
|
|
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
|
"fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210"
|
|
]
|
|
}`),
|
|
expectError: false,
|
|
},
|
|
{
|
|
name: "invalid owners - wrong length",
|
|
json: []byte(`{
|
|
"owners": ["not-64-chars"]
|
|
}`),
|
|
expectError: true,
|
|
errorSubstr: "invalid owner pubkey",
|
|
},
|
|
{
|
|
name: "invalid owners - non-hex characters",
|
|
json: []byte(`{
|
|
"owners": ["zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"]
|
|
}`),
|
|
expectError: true,
|
|
errorSubstr: "invalid owner pubkey",
|
|
},
|
|
{
|
|
name: "valid policy with both owners and policy_admins",
|
|
json: []byte(`{
|
|
"owners": ["0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"],
|
|
"policy_admins": ["fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210"],
|
|
"policy_follow_whitelist_enabled": true
|
|
}`),
|
|
expectError: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
err := policy.ValidateJSON(tt.json)
|
|
if tt.expectError {
|
|
if err == nil {
|
|
t.Errorf("Expected error but got none")
|
|
return
|
|
}
|
|
if tt.errorSubstr != "" && !containsSubstring(err.Error(), tt.errorSubstr) {
|
|
t.Errorf("Expected error containing %q, got: %v", tt.errorSubstr, err)
|
|
}
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Errorf("Unexpected error: %v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestReload tests the Reload method
|
|
func TestReload(t *testing.T) {
|
|
policy, cleanup := setupHotreloadTestPolicy(t, "test-reload")
|
|
defer cleanup()
|
|
|
|
// Create temp directory for policy files
|
|
tmpDir := t.TempDir()
|
|
configPath := filepath.Join(tmpDir, "policy.json")
|
|
|
|
tests := []struct {
|
|
name string
|
|
initialJSON []byte
|
|
reloadJSON []byte
|
|
expectError bool
|
|
checkAfter func(t *testing.T, p *P)
|
|
}{
|
|
{
|
|
name: "reload with valid policy",
|
|
initialJSON: []byte(`{"default_policy": "allow"}`),
|
|
reloadJSON: []byte(`{
|
|
"default_policy": "deny",
|
|
"kind": {"whitelist": [1, 3]},
|
|
"policy_admins": ["0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"]
|
|
}`),
|
|
expectError: false,
|
|
checkAfter: func(t *testing.T, p *P) {
|
|
if p.DefaultPolicy != "deny" {
|
|
t.Errorf("Expected default_policy to be 'deny', got %q", p.DefaultPolicy)
|
|
}
|
|
if len(p.Kind.Whitelist) != 2 {
|
|
t.Errorf("Expected 2 whitelisted kinds, got %d", len(p.Kind.Whitelist))
|
|
}
|
|
if len(p.PolicyAdmins) != 1 {
|
|
t.Errorf("Expected 1 policy admin, got %d", len(p.PolicyAdmins))
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "reload with invalid JSON fails without changes",
|
|
initialJSON: []byte(`{"default_policy": "allow"}`),
|
|
reloadJSON: []byte(`{"invalid json`),
|
|
expectError: true,
|
|
checkAfter: func(t *testing.T, p *P) {
|
|
// Policy should remain unchanged
|
|
if p.DefaultPolicy != "allow" {
|
|
t.Errorf("Expected default_policy to remain 'allow', got %q", p.DefaultPolicy)
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "reload with invalid admin pubkey fails without changes",
|
|
initialJSON: []byte(`{"default_policy": "allow"}`),
|
|
reloadJSON: []byte(`{
|
|
"default_policy": "deny",
|
|
"policy_admins": ["invalid-pubkey"]
|
|
}`),
|
|
expectError: true,
|
|
checkAfter: func(t *testing.T, p *P) {
|
|
// Policy should remain unchanged
|
|
if p.DefaultPolicy != "allow" {
|
|
t.Errorf("Expected default_policy to remain 'allow', got %q", p.DefaultPolicy)
|
|
}
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
// Initialize policy with initial JSON
|
|
if tt.initialJSON != nil {
|
|
if err := policy.Reload(tt.initialJSON, configPath); err != nil {
|
|
t.Fatalf("Failed to set initial policy: %v", err)
|
|
}
|
|
}
|
|
|
|
// Attempt reload
|
|
err := policy.Reload(tt.reloadJSON, configPath)
|
|
if tt.expectError {
|
|
if err == nil {
|
|
t.Errorf("Expected error but got none")
|
|
}
|
|
} else {
|
|
if err != nil {
|
|
t.Errorf("Unexpected error: %v", err)
|
|
}
|
|
}
|
|
|
|
// Run post-reload checks
|
|
if tt.checkAfter != nil {
|
|
tt.checkAfter(t, policy)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestSaveToFile tests atomic file writing
|
|
func TestSaveToFile(t *testing.T) {
|
|
policy, cleanup := setupHotreloadTestPolicy(t, "test-save-file")
|
|
defer cleanup()
|
|
|
|
tmpDir := t.TempDir()
|
|
configPath := filepath.Join(tmpDir, "policy.json")
|
|
|
|
// Load a policy
|
|
policyJSON := []byte(`{
|
|
"default_policy": "allow",
|
|
"kind": {"whitelist": [1, 3, 7]},
|
|
"policy_admins": ["0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"]
|
|
}`)
|
|
|
|
if err := policy.Reload(policyJSON, configPath); err != nil {
|
|
t.Fatalf("Failed to reload policy: %v", err)
|
|
}
|
|
|
|
// Verify file was saved
|
|
if _, err := os.Stat(configPath); os.IsNotExist(err) {
|
|
t.Errorf("Policy file was not created at %s", configPath)
|
|
}
|
|
|
|
// Read and verify contents
|
|
data, err := os.ReadFile(configPath)
|
|
if err != nil {
|
|
t.Fatalf("Failed to read policy file: %v", err)
|
|
}
|
|
|
|
if len(data) == 0 {
|
|
t.Error("Policy file is empty")
|
|
}
|
|
|
|
// Verify it's valid JSON
|
|
var parsed map[string]interface{}
|
|
if err := json.Unmarshal(data, &parsed); err != nil {
|
|
t.Errorf("Policy file contains invalid JSON: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestPauseResume tests the Pause and Resume methods
|
|
func TestPauseResume(t *testing.T) {
|
|
policy, cleanup := setupHotreloadTestPolicy(t, "test-pause-resume")
|
|
defer cleanup()
|
|
|
|
// Test Pause
|
|
if err := policy.Pause(); err != nil {
|
|
t.Errorf("Pause failed: %v", err)
|
|
}
|
|
|
|
// Test Resume
|
|
if err := policy.Resume(); err != nil {
|
|
t.Errorf("Resume failed: %v", err)
|
|
}
|
|
|
|
// Test multiple pause/resume cycles
|
|
for i := 0; i < 3; i++ {
|
|
if err := policy.Pause(); err != nil {
|
|
t.Errorf("Pause %d failed: %v", i, err)
|
|
}
|
|
if err := policy.Resume(); err != nil {
|
|
t.Errorf("Resume %d failed: %v", i, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestReloadPreservesExistingOnFailure verifies that failed reloads don't corrupt state
|
|
func TestReloadPreservesExistingOnFailure(t *testing.T) {
|
|
policy, cleanup := setupHotreloadTestPolicy(t, "test-reload-preserve")
|
|
defer cleanup()
|
|
|
|
tmpDir := t.TempDir()
|
|
configPath := filepath.Join(tmpDir, "policy.json")
|
|
|
|
// Set up initial valid policy
|
|
initialJSON := []byte(`{
|
|
"default_policy": "allow",
|
|
"kind": {"whitelist": [1, 3, 7]},
|
|
"policy_admins": ["0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"],
|
|
"policy_follow_whitelist_enabled": true
|
|
}`)
|
|
|
|
if err := policy.Reload(initialJSON, configPath); err != nil {
|
|
t.Fatalf("Failed to set initial policy: %v", err)
|
|
}
|
|
|
|
// Store initial state
|
|
initialDefaultPolicy := policy.DefaultPolicy
|
|
initialKindWhitelist := len(policy.Kind.Whitelist)
|
|
initialAdminCount := len(policy.PolicyAdmins)
|
|
initialFollowEnabled := policy.PolicyFollowWhitelistEnabled
|
|
|
|
// Attempt to reload with invalid JSON
|
|
invalidJSON := []byte(`{"policy_admins": ["invalid"]}`)
|
|
err := policy.Reload(invalidJSON, configPath)
|
|
if err == nil {
|
|
t.Fatal("Expected error for invalid policy_admins but got none")
|
|
}
|
|
|
|
// Verify state is preserved
|
|
if policy.DefaultPolicy != initialDefaultPolicy {
|
|
t.Errorf("DefaultPolicy changed from %q to %q after failed reload",
|
|
initialDefaultPolicy, policy.DefaultPolicy)
|
|
}
|
|
if len(policy.Kind.Whitelist) != initialKindWhitelist {
|
|
t.Errorf("Kind.Whitelist length changed from %d to %d after failed reload",
|
|
initialKindWhitelist, len(policy.Kind.Whitelist))
|
|
}
|
|
if len(policy.PolicyAdmins) != initialAdminCount {
|
|
t.Errorf("PolicyAdmins length changed from %d to %d after failed reload",
|
|
initialAdminCount, len(policy.PolicyAdmins))
|
|
}
|
|
if policy.PolicyFollowWhitelistEnabled != initialFollowEnabled {
|
|
t.Errorf("PolicyFollowWhitelistEnabled changed from %v to %v after failed reload",
|
|
initialFollowEnabled, policy.PolicyFollowWhitelistEnabled)
|
|
}
|
|
}
|
|
|
|
// containsSubstring checks if a string contains a substring (case-insensitive)
|
|
func containsSubstring(s, substr string) bool {
|
|
return strings.Contains(strings.ToLower(s), strings.ToLower(substr))
|
|
}
|
|
|
|
// TestGetOwnersBin tests the GetOwnersBin method for policy-defined owners
|
|
func TestGetOwnersBin(t *testing.T) {
|
|
policy, cleanup := setupHotreloadTestPolicy(t, "test-get-owners-bin")
|
|
defer cleanup()
|
|
|
|
tmpDir := t.TempDir()
|
|
configPath := filepath.Join(tmpDir, "policy.json")
|
|
|
|
// Test 1: Policy with no owners
|
|
emptyJSON := []byte(`{"default_policy": "allow"}`)
|
|
if err := policy.Reload(emptyJSON, configPath); err != nil {
|
|
t.Fatalf("Failed to reload policy: %v", err)
|
|
}
|
|
|
|
owners := policy.GetOwnersBin()
|
|
if len(owners) != 0 {
|
|
t.Errorf("Expected 0 owners, got %d", len(owners))
|
|
}
|
|
|
|
// Test 2: Policy with owners
|
|
ownerHex := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
|
withOwnersJSON := []byte(`{
|
|
"default_policy": "allow",
|
|
"owners": ["` + ownerHex + `"]
|
|
}`)
|
|
if err := policy.Reload(withOwnersJSON, configPath); err != nil {
|
|
t.Fatalf("Failed to reload policy with owners: %v", err)
|
|
}
|
|
|
|
owners = policy.GetOwnersBin()
|
|
if len(owners) != 1 {
|
|
t.Errorf("Expected 1 owner, got %d", len(owners))
|
|
}
|
|
if len(owners) > 0 && len(owners[0]) != 32 {
|
|
t.Errorf("Expected owner binary to be 32 bytes, got %d", len(owners[0]))
|
|
}
|
|
|
|
// Test 3: GetOwners returns hex strings
|
|
hexOwners := policy.GetOwners()
|
|
if len(hexOwners) != 1 {
|
|
t.Errorf("Expected 1 hex owner, got %d", len(hexOwners))
|
|
}
|
|
if len(hexOwners) > 0 && hexOwners[0] != ownerHex {
|
|
t.Errorf("Expected owner %q, got %q", ownerHex, hexOwners[0])
|
|
}
|
|
|
|
// Test 4: Policy with multiple owners
|
|
owner2Hex := "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210"
|
|
multiOwnersJSON := []byte(`{
|
|
"default_policy": "allow",
|
|
"owners": ["` + ownerHex + `", "` + owner2Hex + `"]
|
|
}`)
|
|
if err := policy.Reload(multiOwnersJSON, configPath); err != nil {
|
|
t.Fatalf("Failed to reload policy with multiple owners: %v", err)
|
|
}
|
|
|
|
owners = policy.GetOwnersBin()
|
|
if len(owners) != 2 {
|
|
t.Errorf("Expected 2 owners, got %d", len(owners))
|
|
}
|
|
}
|