Browse Source
Introduce comprehensive tests for policy validation logic, including owner and policy admin scenarios. Update `HandlePolicyConfigUpdate` to differentiate permissions for owners and policy admins, enforcing stricter field restrictions and validation flows.main
8 changed files with 1627 additions and 46 deletions
@ -0,0 +1,525 @@ |
|||||||
|
package policy |
||||||
|
|
||||||
|
import ( |
||||||
|
"encoding/json" |
||||||
|
"fmt" |
||||||
|
"os" |
||||||
|
"path/filepath" |
||||||
|
"sort" |
||||||
|
"sync" |
||||||
|
|
||||||
|
"git.mleku.dev/mleku/nostr/encoders/hex" |
||||||
|
"lol.mleku.dev/log" |
||||||
|
"next.orly.dev/pkg/utils" |
||||||
|
) |
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Policy Composition Types
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// PolicyAdminContribution represents extensions/additions from a policy admin.
|
||||||
|
// Policy admins can extend the base owner policy but cannot modify protected fields
|
||||||
|
// (owners, policy_admins) or reduce owner-granted permissions.
|
||||||
|
type PolicyAdminContribution struct { |
||||||
|
// AdminPubkey is the hex-encoded pubkey of the policy admin who made this contribution
|
||||||
|
AdminPubkey string `json:"admin_pubkey"` |
||||||
|
// CreatedAt is the Unix timestamp when this contribution was created
|
||||||
|
CreatedAt int64 `json:"created_at"` |
||||||
|
// EventID is the Nostr event ID that created this contribution (for audit trail)
|
||||||
|
EventID string `json:"event_id,omitempty"` |
||||||
|
|
||||||
|
// KindWhitelistAdd adds kinds to the whitelist (OR with owner's whitelist)
|
||||||
|
KindWhitelistAdd []int `json:"kind_whitelist_add,omitempty"` |
||||||
|
// KindBlacklistAdd adds kinds to the blacklist (overrides whitelist)
|
||||||
|
KindBlacklistAdd []int `json:"kind_blacklist_add,omitempty"` |
||||||
|
|
||||||
|
// RulesExtend extends existing rules defined by the owner
|
||||||
|
RulesExtend map[int]RuleExtension `json:"rules_extend,omitempty"` |
||||||
|
// RulesAdd adds new rules for kinds not defined by the owner
|
||||||
|
RulesAdd map[int]Rule `json:"rules_add,omitempty"` |
||||||
|
|
||||||
|
// GlobalExtend extends the global rule
|
||||||
|
GlobalExtend *RuleExtension `json:"global_extend,omitempty"` |
||||||
|
} |
||||||
|
|
||||||
|
// RuleExtension defines how a policy admin can extend an existing owner rule.
|
||||||
|
// All fields are additive - they extend, not replace, the owner's configuration.
|
||||||
|
type RuleExtension struct { |
||||||
|
// WriteAllowAdd adds pubkeys to the write allow list
|
||||||
|
WriteAllowAdd []string `json:"write_allow_add,omitempty"` |
||||||
|
// WriteDenyAdd adds pubkeys to the write deny list (overrides allow)
|
||||||
|
WriteDenyAdd []string `json:"write_deny_add,omitempty"` |
||||||
|
// ReadAllowAdd adds pubkeys to the read allow list
|
||||||
|
ReadAllowAdd []string `json:"read_allow_add,omitempty"` |
||||||
|
// ReadDenyAdd adds pubkeys to the read deny list (overrides allow)
|
||||||
|
ReadDenyAdd []string `json:"read_deny_add,omitempty"` |
||||||
|
|
||||||
|
// SizeLimitOverride can only make the limit MORE permissive (larger)
|
||||||
|
SizeLimitOverride *int64 `json:"size_limit_override,omitempty"` |
||||||
|
// ContentLimitOverride can only make the limit MORE permissive (larger)
|
||||||
|
ContentLimitOverride *int64 `json:"content_limit_override,omitempty"` |
||||||
|
// MaxAgeOfEventOverride can only make the limit MORE permissive (older allowed)
|
||||||
|
MaxAgeOfEventOverride *int64 `json:"max_age_of_event_override,omitempty"` |
||||||
|
// MaxAgeEventInFutureOverride can only make the limit MORE permissive (further future allowed)
|
||||||
|
MaxAgeEventInFutureOverride *int64 `json:"max_age_event_in_future_override,omitempty"` |
||||||
|
|
||||||
|
// WriteAllowFollows extends the follow whitelist feature
|
||||||
|
WriteAllowFollows *bool `json:"write_allow_follows,omitempty"` |
||||||
|
// FollowsWhitelistAdminsAdd adds admin pubkeys whose follows are whitelisted
|
||||||
|
FollowsWhitelistAdminsAdd []string `json:"follows_whitelist_admins_add,omitempty"` |
||||||
|
} |
||||||
|
|
||||||
|
// ComposedPolicy manages the base owner policy and policy admin contributions.
|
||||||
|
// It computes an effective merged policy at runtime.
|
||||||
|
type ComposedPolicy struct { |
||||||
|
// OwnerPolicy is the base policy set by owners
|
||||||
|
OwnerPolicy *P |
||||||
|
// Contributions is a map of event ID -> contribution for deduplication
|
||||||
|
Contributions map[string]*PolicyAdminContribution |
||||||
|
// contributionsMx protects the contributions map
|
||||||
|
contributionsMx sync.RWMutex |
||||||
|
// configDir is the directory where policy files are stored
|
||||||
|
configDir string |
||||||
|
} |
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Protected Field Validation
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// ProtectedFields are fields that only owners can modify
|
||||||
|
var ProtectedFields = []string{"owners", "policy_admins"} |
||||||
|
|
||||||
|
// ValidateOwnerPolicy validates a policy update from an owner.
|
||||||
|
// Ensures owners list is non-empty.
|
||||||
|
func ValidateOwnerPolicy(policy *P) error { |
||||||
|
if len(policy.Owners) == 0 { |
||||||
|
return fmt.Errorf("owners list cannot be empty: at least one owner must be defined") |
||||||
|
} |
||||||
|
|
||||||
|
// Validate all owner pubkeys are valid hex
|
||||||
|
for _, owner := range policy.Owners { |
||||||
|
if len(owner) != 64 { |
||||||
|
return fmt.Errorf("invalid owner pubkey length: %q (expected 64 hex characters)", owner) |
||||||
|
} |
||||||
|
if _, err := hex.Dec(owner); err != nil { |
||||||
|
return fmt.Errorf("invalid owner pubkey format: %q: %v", owner, err) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Validate all policy admin pubkeys are valid hex
|
||||||
|
for _, admin := range policy.PolicyAdmins { |
||||||
|
if len(admin) != 64 { |
||||||
|
return fmt.Errorf("invalid policy_admin pubkey length: %q (expected 64 hex characters)", admin) |
||||||
|
} |
||||||
|
if _, err := hex.Dec(admin); err != nil { |
||||||
|
return fmt.Errorf("invalid policy_admin pubkey format: %q: %v", admin, err) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return nil |
||||||
|
} |
||||||
|
|
||||||
|
// ValidatePolicyAdminContribution validates a contribution from a policy admin.
|
||||||
|
// Ensures no protected fields are modified and extensions are valid.
|
||||||
|
func ValidatePolicyAdminContribution( |
||||||
|
ownerPolicy *P, |
||||||
|
contribution *PolicyAdminContribution, |
||||||
|
existingContributions map[string]*PolicyAdminContribution, |
||||||
|
) error { |
||||||
|
// Validate the admin pubkey is valid
|
||||||
|
if len(contribution.AdminPubkey) != 64 { |
||||||
|
return fmt.Errorf("invalid admin pubkey length") |
||||||
|
} |
||||||
|
|
||||||
|
// Validate kind additions don't conflict with owner blacklist
|
||||||
|
// (though PA can add to blacklist to override whitelist)
|
||||||
|
|
||||||
|
// Validate rule extensions
|
||||||
|
for kind, ext := range contribution.RulesExtend { |
||||||
|
ownerRule, exists := ownerPolicy.rules[kind] |
||||||
|
if !exists { |
||||||
|
return fmt.Errorf("cannot extend rule for kind %d: not defined in owner policy (use rules_add instead)", kind) |
||||||
|
} |
||||||
|
|
||||||
|
// Validate size limit overrides are more permissive
|
||||||
|
if ext.SizeLimitOverride != nil && ownerRule.SizeLimit != nil { |
||||||
|
if *ext.SizeLimitOverride < *ownerRule.SizeLimit { |
||||||
|
return fmt.Errorf("size_limit_override for kind %d must be >= owner's limit (%d)", kind, *ownerRule.SizeLimit) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if ext.ContentLimitOverride != nil && ownerRule.ContentLimit != nil { |
||||||
|
if *ext.ContentLimitOverride < *ownerRule.ContentLimit { |
||||||
|
return fmt.Errorf("content_limit_override for kind %d must be >= owner's limit (%d)", kind, *ownerRule.ContentLimit) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if ext.MaxAgeOfEventOverride != nil && ownerRule.MaxAgeOfEvent != nil { |
||||||
|
if *ext.MaxAgeOfEventOverride < *ownerRule.MaxAgeOfEvent { |
||||||
|
return fmt.Errorf("max_age_of_event_override for kind %d must be >= owner's limit (%d)", kind, *ownerRule.MaxAgeOfEvent) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Validate pubkey formats in allow/deny lists
|
||||||
|
for _, pk := range ext.WriteAllowAdd { |
||||||
|
if len(pk) != 64 { |
||||||
|
return fmt.Errorf("invalid pubkey in write_allow_add for kind %d: %q", kind, pk) |
||||||
|
} |
||||||
|
} |
||||||
|
for _, pk := range ext.WriteDenyAdd { |
||||||
|
if len(pk) != 64 { |
||||||
|
return fmt.Errorf("invalid pubkey in write_deny_add for kind %d: %q", kind, pk) |
||||||
|
} |
||||||
|
} |
||||||
|
for _, pk := range ext.ReadAllowAdd { |
||||||
|
if len(pk) != 64 { |
||||||
|
return fmt.Errorf("invalid pubkey in read_allow_add for kind %d: %q", kind, pk) |
||||||
|
} |
||||||
|
} |
||||||
|
for _, pk := range ext.ReadDenyAdd { |
||||||
|
if len(pk) != 64 { |
||||||
|
return fmt.Errorf("invalid pubkey in read_deny_add for kind %d: %q", kind, pk) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Validate rules_add are for kinds not already defined by owner
|
||||||
|
for kind := range contribution.RulesAdd { |
||||||
|
if _, exists := ownerPolicy.rules[kind]; exists { |
||||||
|
return fmt.Errorf("cannot add rule for kind %d: already defined in owner policy (use rules_extend instead)", kind) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return nil |
||||||
|
} |
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Policy Composition Logic
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// NewComposedPolicy creates a new composed policy from an owner policy.
|
||||||
|
func NewComposedPolicy(ownerPolicy *P, configDir string) *ComposedPolicy { |
||||||
|
return &ComposedPolicy{ |
||||||
|
OwnerPolicy: ownerPolicy, |
||||||
|
Contributions: make(map[string]*PolicyAdminContribution), |
||||||
|
configDir: configDir, |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// AddContribution adds a policy admin contribution.
|
||||||
|
// Returns error if validation fails.
|
||||||
|
func (cp *ComposedPolicy) AddContribution(contribution *PolicyAdminContribution) error { |
||||||
|
cp.contributionsMx.Lock() |
||||||
|
defer cp.contributionsMx.Unlock() |
||||||
|
|
||||||
|
// Validate the contribution
|
||||||
|
if err := ValidatePolicyAdminContribution(cp.OwnerPolicy, contribution, cp.Contributions); err != nil { |
||||||
|
return err |
||||||
|
} |
||||||
|
|
||||||
|
// Store the contribution
|
||||||
|
cp.Contributions[contribution.EventID] = contribution |
||||||
|
|
||||||
|
// Persist to disk
|
||||||
|
if err := cp.saveContribution(contribution); err != nil { |
||||||
|
log.W.F("failed to persist contribution: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
return nil |
||||||
|
} |
||||||
|
|
||||||
|
// RemoveContribution removes a policy admin contribution by event ID.
|
||||||
|
func (cp *ComposedPolicy) RemoveContribution(eventID string) { |
||||||
|
cp.contributionsMx.Lock() |
||||||
|
defer cp.contributionsMx.Unlock() |
||||||
|
|
||||||
|
delete(cp.Contributions, eventID) |
||||||
|
|
||||||
|
// Remove from disk
|
||||||
|
if cp.configDir != "" { |
||||||
|
contribPath := filepath.Join(cp.configDir, "policy-contributions", eventID+".json") |
||||||
|
os.Remove(contribPath) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// GetEffectivePolicy computes the merged effective policy.
|
||||||
|
// Composition rules:
|
||||||
|
// - Whitelists are unioned (OR)
|
||||||
|
// - Blacklists are unioned and override whitelists
|
||||||
|
// - Limits use the most permissive value
|
||||||
|
// - Conflicts between PAs: oldest created_at wins (except deny always wins)
|
||||||
|
func (cp *ComposedPolicy) GetEffectivePolicy() *P { |
||||||
|
cp.contributionsMx.RLock() |
||||||
|
defer cp.contributionsMx.RUnlock() |
||||||
|
|
||||||
|
// Clone the owner policy as base
|
||||||
|
effective := cp.cloneOwnerPolicy() |
||||||
|
|
||||||
|
// Sort contributions by created_at (oldest first for conflict resolution)
|
||||||
|
sorted := cp.getSortedContributions() |
||||||
|
|
||||||
|
// Apply each contribution
|
||||||
|
for _, contrib := range sorted { |
||||||
|
cp.applyContribution(effective, contrib) |
||||||
|
} |
||||||
|
|
||||||
|
// Repopulate binary caches
|
||||||
|
effective.Global.populateBinaryCache() |
||||||
|
for kind := range effective.rules { |
||||||
|
rule := effective.rules[kind] |
||||||
|
rule.populateBinaryCache() |
||||||
|
effective.rules[kind] = rule |
||||||
|
} |
||||||
|
|
||||||
|
return effective |
||||||
|
} |
||||||
|
|
||||||
|
// cloneOwnerPolicy creates a deep copy of the owner policy.
|
||||||
|
func (cp *ComposedPolicy) cloneOwnerPolicy() *P { |
||||||
|
// Marshal and unmarshal to create a deep copy
|
||||||
|
data, _ := json.Marshal(cp.OwnerPolicy) |
||||||
|
var cloned P |
||||||
|
json.Unmarshal(data, &cloned) |
||||||
|
|
||||||
|
// Copy the manager reference (not cloned)
|
||||||
|
cloned.manager = cp.OwnerPolicy.manager |
||||||
|
|
||||||
|
return &cloned |
||||||
|
} |
||||||
|
|
||||||
|
// getSortedContributions returns contributions sorted by created_at.
|
||||||
|
func (cp *ComposedPolicy) getSortedContributions() []*PolicyAdminContribution { |
||||||
|
sorted := make([]*PolicyAdminContribution, 0, len(cp.Contributions)) |
||||||
|
for _, contrib := range cp.Contributions { |
||||||
|
sorted = append(sorted, contrib) |
||||||
|
} |
||||||
|
sort.Slice(sorted, func(i, j int) bool { |
||||||
|
return sorted[i].CreatedAt < sorted[j].CreatedAt |
||||||
|
}) |
||||||
|
return sorted |
||||||
|
} |
||||||
|
|
||||||
|
// applyContribution applies a single contribution to the effective policy.
|
||||||
|
func (cp *ComposedPolicy) applyContribution(effective *P, contrib *PolicyAdminContribution) { |
||||||
|
// Apply kind whitelist additions (OR)
|
||||||
|
for _, kind := range contrib.KindWhitelistAdd { |
||||||
|
if !containsInt(effective.Kind.Whitelist, kind) { |
||||||
|
effective.Kind.Whitelist = append(effective.Kind.Whitelist, kind) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Apply kind blacklist additions (OR, overrides whitelist)
|
||||||
|
for _, kind := range contrib.KindBlacklistAdd { |
||||||
|
if !containsInt(effective.Kind.Blacklist, kind) { |
||||||
|
effective.Kind.Blacklist = append(effective.Kind.Blacklist, kind) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Apply rule extensions
|
||||||
|
for kind, ext := range contrib.RulesExtend { |
||||||
|
if rule, exists := effective.rules[kind]; exists { |
||||||
|
cp.applyRuleExtension(&rule, &ext, contrib.CreatedAt) |
||||||
|
effective.rules[kind] = rule |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Apply new rules
|
||||||
|
for kind, rule := range contrib.RulesAdd { |
||||||
|
if _, exists := effective.rules[kind]; !exists { |
||||||
|
if effective.rules == nil { |
||||||
|
effective.rules = make(map[int]Rule) |
||||||
|
} |
||||||
|
effective.rules[kind] = rule |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Apply global rule extension
|
||||||
|
if contrib.GlobalExtend != nil { |
||||||
|
cp.applyRuleExtension(&effective.Global, contrib.GlobalExtend, contrib.CreatedAt) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// applyRuleExtension applies a rule extension to an existing rule.
|
||||||
|
func (cp *ComposedPolicy) applyRuleExtension(rule *Rule, ext *RuleExtension, _ int64) { |
||||||
|
// Add to allow lists (OR)
|
||||||
|
for _, pk := range ext.WriteAllowAdd { |
||||||
|
if !containsString(rule.WriteAllow, pk) { |
||||||
|
rule.WriteAllow = append(rule.WriteAllow, pk) |
||||||
|
} |
||||||
|
} |
||||||
|
for _, pk := range ext.ReadAllowAdd { |
||||||
|
if !containsString(rule.ReadAllow, pk) { |
||||||
|
rule.ReadAllow = append(rule.ReadAllow, pk) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Add to deny lists (OR, overrides allow) - deny always wins
|
||||||
|
for _, pk := range ext.WriteDenyAdd { |
||||||
|
if !containsString(rule.WriteDeny, pk) { |
||||||
|
rule.WriteDeny = append(rule.WriteDeny, pk) |
||||||
|
} |
||||||
|
} |
||||||
|
for _, pk := range ext.ReadDenyAdd { |
||||||
|
if !containsString(rule.ReadDeny, pk) { |
||||||
|
rule.ReadDeny = append(rule.ReadDeny, pk) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Apply limit overrides (most permissive wins)
|
||||||
|
if ext.SizeLimitOverride != nil { |
||||||
|
if rule.SizeLimit == nil || *ext.SizeLimitOverride > *rule.SizeLimit { |
||||||
|
rule.SizeLimit = ext.SizeLimitOverride |
||||||
|
} |
||||||
|
} |
||||||
|
if ext.ContentLimitOverride != nil { |
||||||
|
if rule.ContentLimit == nil || *ext.ContentLimitOverride > *rule.ContentLimit { |
||||||
|
rule.ContentLimit = ext.ContentLimitOverride |
||||||
|
} |
||||||
|
} |
||||||
|
if ext.MaxAgeOfEventOverride != nil { |
||||||
|
if rule.MaxAgeOfEvent == nil || *ext.MaxAgeOfEventOverride > *rule.MaxAgeOfEvent { |
||||||
|
rule.MaxAgeOfEvent = ext.MaxAgeOfEventOverride |
||||||
|
} |
||||||
|
} |
||||||
|
if ext.MaxAgeEventInFutureOverride != nil { |
||||||
|
if rule.MaxAgeEventInFuture == nil || *ext.MaxAgeEventInFutureOverride > *rule.MaxAgeEventInFuture { |
||||||
|
rule.MaxAgeEventInFuture = ext.MaxAgeEventInFutureOverride |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Enable WriteAllowFollows if requested (OR logic)
|
||||||
|
if ext.WriteAllowFollows != nil && *ext.WriteAllowFollows { |
||||||
|
rule.WriteAllowFollows = true |
||||||
|
} |
||||||
|
|
||||||
|
// Add to follows whitelist admins
|
||||||
|
for _, pk := range ext.FollowsWhitelistAdminsAdd { |
||||||
|
if !containsString(rule.FollowsWhitelistAdmins, pk) { |
||||||
|
rule.FollowsWhitelistAdmins = append(rule.FollowsWhitelistAdmins, pk) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Persistence
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// saveContribution persists a contribution to disk.
|
||||||
|
func (cp *ComposedPolicy) saveContribution(contrib *PolicyAdminContribution) error { |
||||||
|
if cp.configDir == "" { |
||||||
|
return nil |
||||||
|
} |
||||||
|
|
||||||
|
contribDir := filepath.Join(cp.configDir, "policy-contributions") |
||||||
|
if err := os.MkdirAll(contribDir, 0755); err != nil { |
||||||
|
return err |
||||||
|
} |
||||||
|
|
||||||
|
contribPath := filepath.Join(contribDir, contrib.EventID+".json") |
||||||
|
data, err := json.MarshalIndent(contrib, "", " ") |
||||||
|
if err != nil { |
||||||
|
return err |
||||||
|
} |
||||||
|
|
||||||
|
return os.WriteFile(contribPath, data, 0644) |
||||||
|
} |
||||||
|
|
||||||
|
// LoadContributions loads all contributions from disk.
|
||||||
|
func (cp *ComposedPolicy) LoadContributions() error { |
||||||
|
if cp.configDir == "" { |
||||||
|
return nil |
||||||
|
} |
||||||
|
|
||||||
|
contribDir := filepath.Join(cp.configDir, "policy-contributions") |
||||||
|
if _, err := os.Stat(contribDir); os.IsNotExist(err) { |
||||||
|
return nil // No contributions yet
|
||||||
|
} |
||||||
|
|
||||||
|
entries, err := os.ReadDir(contribDir) |
||||||
|
if err != nil { |
||||||
|
return err |
||||||
|
} |
||||||
|
|
||||||
|
cp.contributionsMx.Lock() |
||||||
|
defer cp.contributionsMx.Unlock() |
||||||
|
|
||||||
|
for _, entry := range entries { |
||||||
|
if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" { |
||||||
|
continue |
||||||
|
} |
||||||
|
|
||||||
|
contribPath := filepath.Join(contribDir, entry.Name()) |
||||||
|
data, err := os.ReadFile(contribPath) |
||||||
|
if err != nil { |
||||||
|
log.W.F("failed to read contribution %s: %v", entry.Name(), err) |
||||||
|
continue |
||||||
|
} |
||||||
|
|
||||||
|
var contrib PolicyAdminContribution |
||||||
|
if err := json.Unmarshal(data, &contrib); err != nil { |
||||||
|
log.W.F("failed to parse contribution %s: %v", entry.Name(), err) |
||||||
|
continue |
||||||
|
} |
||||||
|
|
||||||
|
// Validate against current owner policy
|
||||||
|
if err := ValidatePolicyAdminContribution(cp.OwnerPolicy, &contrib, cp.Contributions); err != nil { |
||||||
|
log.W.F("contribution %s is no longer valid: %v (skipping)", entry.Name(), err) |
||||||
|
continue |
||||||
|
} |
||||||
|
|
||||||
|
cp.Contributions[contrib.EventID] = &contrib |
||||||
|
} |
||||||
|
|
||||||
|
log.I.F("loaded %d policy admin contributions", len(cp.Contributions)) |
||||||
|
return nil |
||||||
|
} |
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Owner Detection
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// IsOwner checks if the given pubkey is an owner.
|
||||||
|
// The pubkey parameter should be binary ([]byte), not hex-encoded.
|
||||||
|
func (p *P) IsOwner(pubkey []byte) bool { |
||||||
|
if len(pubkey) == 0 { |
||||||
|
return false |
||||||
|
} |
||||||
|
|
||||||
|
p.policyFollowsMx.RLock() |
||||||
|
defer p.policyFollowsMx.RUnlock() |
||||||
|
|
||||||
|
for _, owner := range p.ownersBin { |
||||||
|
if utils.FastEqual(owner, pubkey) { |
||||||
|
return true |
||||||
|
} |
||||||
|
} |
||||||
|
return false |
||||||
|
} |
||||||
|
|
||||||
|
// IsOwnerOrPolicyAdmin checks if the given pubkey is an owner or policy admin.
|
||||||
|
// The pubkey parameter should be binary ([]byte), not hex-encoded.
|
||||||
|
func (p *P) IsOwnerOrPolicyAdmin(pubkey []byte) bool { |
||||||
|
return p.IsOwner(pubkey) || p.IsPolicyAdmin(pubkey) |
||||||
|
} |
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Helper Functions
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
func containsInt(slice []int, val int) bool { |
||||||
|
for _, v := range slice { |
||||||
|
if v == val { |
||||||
|
return true |
||||||
|
} |
||||||
|
} |
||||||
|
return false |
||||||
|
} |
||||||
|
|
||||||
|
func containsString(slice []string, val string) bool { |
||||||
|
for _, v := range slice { |
||||||
|
if v == val { |
||||||
|
return true |
||||||
|
} |
||||||
|
} |
||||||
|
return false |
||||||
|
} |
||||||
@ -0,0 +1,690 @@ |
|||||||
|
package policy |
||||||
|
|
||||||
|
import ( |
||||||
|
"encoding/json" |
||||||
|
"testing" |
||||||
|
) |
||||||
|
|
||||||
|
// TestValidateOwnerPolicyUpdate tests owner-specific validation
|
||||||
|
func TestValidateOwnerPolicyUpdate(t *testing.T) { |
||||||
|
// Create a base policy
|
||||||
|
basePolicy := &P{ |
||||||
|
DefaultPolicy: "allow", |
||||||
|
Owners: []string{"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, |
||||||
|
PolicyAdmins: []string{"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}, |
||||||
|
} |
||||||
|
|
||||||
|
tests := []struct { |
||||||
|
name string |
||||||
|
newPolicy string |
||||||
|
expectError bool |
||||||
|
errorMsg string |
||||||
|
}{ |
||||||
|
{ |
||||||
|
name: "valid owner update with non-empty owners", |
||||||
|
newPolicy: `{ |
||||||
|
"default_policy": "deny", |
||||||
|
"owners": ["cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"], |
||||||
|
"policy_admins": ["dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"] |
||||||
|
}`, |
||||||
|
expectError: false, |
||||||
|
}, |
||||||
|
{ |
||||||
|
name: "invalid - empty owners list", |
||||||
|
newPolicy: `{ |
||||||
|
"default_policy": "deny", |
||||||
|
"owners": [], |
||||||
|
"policy_admins": ["dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"] |
||||||
|
}`, |
||||||
|
expectError: true, |
||||||
|
errorMsg: "owners list cannot be empty", |
||||||
|
}, |
||||||
|
{ |
||||||
|
name: "invalid - missing owners field", |
||||||
|
newPolicy: `{ |
||||||
|
"default_policy": "deny", |
||||||
|
"policy_admins": ["dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"] |
||||||
|
}`, |
||||||
|
expectError: true, |
||||||
|
errorMsg: "owners list cannot be empty", |
||||||
|
}, |
||||||
|
{ |
||||||
|
name: "invalid - bad owner pubkey format", |
||||||
|
newPolicy: `{ |
||||||
|
"default_policy": "deny", |
||||||
|
"owners": ["not-a-valid-pubkey"] |
||||||
|
}`, |
||||||
|
expectError: true, |
||||||
|
errorMsg: "invalid owner pubkey", |
||||||
|
}, |
||||||
|
{ |
||||||
|
name: "valid - owner can add multiple owners", |
||||||
|
newPolicy: `{ |
||||||
|
"default_policy": "deny", |
||||||
|
"owners": [ |
||||||
|
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", |
||||||
|
"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" |
||||||
|
] |
||||||
|
}`, |
||||||
|
expectError: false, |
||||||
|
}, |
||||||
|
} |
||||||
|
|
||||||
|
for _, tt := range tests { |
||||||
|
t.Run(tt.name, func(t *testing.T) { |
||||||
|
err := basePolicy.ValidateOwnerPolicyUpdate([]byte(tt.newPolicy)) |
||||||
|
if tt.expectError { |
||||||
|
if err == nil { |
||||||
|
t.Errorf("expected error containing %q, got nil", tt.errorMsg) |
||||||
|
} else if tt.errorMsg != "" && !containsSubstring(err.Error(), tt.errorMsg) { |
||||||
|
t.Errorf("expected error containing %q, got %q", tt.errorMsg, err.Error()) |
||||||
|
} |
||||||
|
} else { |
||||||
|
if err != nil { |
||||||
|
t.Errorf("unexpected error: %v", err) |
||||||
|
} |
||||||
|
} |
||||||
|
}) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// TestValidatePolicyAdminUpdate tests policy admin validation
|
||||||
|
func TestValidatePolicyAdminUpdate(t *testing.T) { |
||||||
|
// Create a base policy with known owners and admins
|
||||||
|
ownerPubkey := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" |
||||||
|
adminPubkey := "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" |
||||||
|
allowedPubkey := "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" |
||||||
|
|
||||||
|
baseJSON := `{ |
||||||
|
"default_policy": "allow", |
||||||
|
"owners": ["` + ownerPubkey + `"], |
||||||
|
"policy_admins": ["` + adminPubkey + `"], |
||||||
|
"kind": { |
||||||
|
"whitelist": [1, 3, 7] |
||||||
|
}, |
||||||
|
"rules": { |
||||||
|
"1": { |
||||||
|
"description": "Text notes", |
||||||
|
"write_allow": ["` + allowedPubkey + `"], |
||||||
|
"size_limit": 10000 |
||||||
|
} |
||||||
|
} |
||||||
|
}` |
||||||
|
|
||||||
|
basePolicy := &P{} |
||||||
|
if err := json.Unmarshal([]byte(baseJSON), basePolicy); err != nil { |
||||||
|
t.Fatalf("failed to create base policy: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
adminPubkeyBin := make([]byte, 32) |
||||||
|
for i := range adminPubkeyBin { |
||||||
|
adminPubkeyBin[i] = 0xbb |
||||||
|
} |
||||||
|
|
||||||
|
tests := []struct { |
||||||
|
name string |
||||||
|
newPolicy string |
||||||
|
expectError bool |
||||||
|
errorMsg string |
||||||
|
}{ |
||||||
|
{ |
||||||
|
name: "valid - policy admin can extend write_allow", |
||||||
|
newPolicy: `{ |
||||||
|
"default_policy": "allow", |
||||||
|
"owners": ["` + ownerPubkey + `"], |
||||||
|
"policy_admins": ["` + adminPubkey + `"], |
||||||
|
"kind": { |
||||||
|
"whitelist": [1, 3, 7] |
||||||
|
}, |
||||||
|
"rules": { |
||||||
|
"1": { |
||||||
|
"description": "Text notes", |
||||||
|
"write_allow": ["` + allowedPubkey + `", "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"], |
||||||
|
"size_limit": 10000 |
||||||
|
} |
||||||
|
} |
||||||
|
}`, |
||||||
|
expectError: false, |
||||||
|
}, |
||||||
|
{ |
||||||
|
name: "valid - policy admin can add to kind whitelist", |
||||||
|
newPolicy: `{ |
||||||
|
"default_policy": "allow", |
||||||
|
"owners": ["` + ownerPubkey + `"], |
||||||
|
"policy_admins": ["` + adminPubkey + `"], |
||||||
|
"kind": { |
||||||
|
"whitelist": [1, 3, 7, 30023] |
||||||
|
}, |
||||||
|
"rules": { |
||||||
|
"1": { |
||||||
|
"description": "Text notes", |
||||||
|
"write_allow": ["` + allowedPubkey + `"], |
||||||
|
"size_limit": 10000 |
||||||
|
} |
||||||
|
} |
||||||
|
}`, |
||||||
|
expectError: false, |
||||||
|
}, |
||||||
|
{ |
||||||
|
name: "valid - policy admin can increase size limit", |
||||||
|
newPolicy: `{ |
||||||
|
"default_policy": "allow", |
||||||
|
"owners": ["` + ownerPubkey + `"], |
||||||
|
"policy_admins": ["` + adminPubkey + `"], |
||||||
|
"kind": { |
||||||
|
"whitelist": [1, 3, 7] |
||||||
|
}, |
||||||
|
"rules": { |
||||||
|
"1": { |
||||||
|
"description": "Text notes", |
||||||
|
"write_allow": ["` + allowedPubkey + `"], |
||||||
|
"size_limit": 20000 |
||||||
|
} |
||||||
|
} |
||||||
|
}`, |
||||||
|
expectError: false, |
||||||
|
}, |
||||||
|
{ |
||||||
|
name: "invalid - policy admin cannot modify owners", |
||||||
|
newPolicy: `{ |
||||||
|
"default_policy": "allow", |
||||||
|
"owners": ["dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"], |
||||||
|
"policy_admins": ["` + adminPubkey + `"], |
||||||
|
"kind": { |
||||||
|
"whitelist": [1, 3, 7] |
||||||
|
}, |
||||||
|
"rules": { |
||||||
|
"1": { |
||||||
|
"description": "Text notes", |
||||||
|
"write_allow": ["` + allowedPubkey + `"], |
||||||
|
"size_limit": 10000 |
||||||
|
} |
||||||
|
} |
||||||
|
}`, |
||||||
|
expectError: true, |
||||||
|
errorMsg: "cannot modify the 'owners' field", |
||||||
|
}, |
||||||
|
{ |
||||||
|
name: "invalid - policy admin cannot modify policy_admins", |
||||||
|
newPolicy: `{ |
||||||
|
"default_policy": "allow", |
||||||
|
"owners": ["` + ownerPubkey + `"], |
||||||
|
"policy_admins": ["dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"], |
||||||
|
"kind": { |
||||||
|
"whitelist": [1, 3, 7] |
||||||
|
}, |
||||||
|
"rules": { |
||||||
|
"1": { |
||||||
|
"description": "Text notes", |
||||||
|
"write_allow": ["` + allowedPubkey + `"], |
||||||
|
"size_limit": 10000 |
||||||
|
} |
||||||
|
} |
||||||
|
}`, |
||||||
|
expectError: true, |
||||||
|
errorMsg: "cannot modify the 'policy_admins' field", |
||||||
|
}, |
||||||
|
{ |
||||||
|
name: "invalid - policy admin cannot remove from kind whitelist", |
||||||
|
newPolicy: `{ |
||||||
|
"default_policy": "allow", |
||||||
|
"owners": ["` + ownerPubkey + `"], |
||||||
|
"policy_admins": ["` + adminPubkey + `"], |
||||||
|
"kind": { |
||||||
|
"whitelist": [1, 3] |
||||||
|
}, |
||||||
|
"rules": { |
||||||
|
"1": { |
||||||
|
"description": "Text notes", |
||||||
|
"write_allow": ["` + allowedPubkey + `"], |
||||||
|
"size_limit": 10000 |
||||||
|
} |
||||||
|
} |
||||||
|
}`, |
||||||
|
expectError: true, |
||||||
|
errorMsg: "cannot remove kind 7 from whitelist", |
||||||
|
}, |
||||||
|
{ |
||||||
|
name: "invalid - policy admin cannot remove from write_allow", |
||||||
|
newPolicy: `{ |
||||||
|
"default_policy": "allow", |
||||||
|
"owners": ["` + ownerPubkey + `"], |
||||||
|
"policy_admins": ["` + adminPubkey + `"], |
||||||
|
"kind": { |
||||||
|
"whitelist": [1, 3, 7] |
||||||
|
}, |
||||||
|
"rules": { |
||||||
|
"1": { |
||||||
|
"description": "Text notes", |
||||||
|
"write_allow": [], |
||||||
|
"size_limit": 10000 |
||||||
|
} |
||||||
|
} |
||||||
|
}`, |
||||||
|
expectError: true, |
||||||
|
errorMsg: "cannot remove pubkey", |
||||||
|
}, |
||||||
|
{ |
||||||
|
name: "invalid - policy admin cannot reduce size limit", |
||||||
|
newPolicy: `{ |
||||||
|
"default_policy": "allow", |
||||||
|
"owners": ["` + ownerPubkey + `"], |
||||||
|
"policy_admins": ["` + adminPubkey + `"], |
||||||
|
"kind": { |
||||||
|
"whitelist": [1, 3, 7] |
||||||
|
}, |
||||||
|
"rules": { |
||||||
|
"1": { |
||||||
|
"description": "Text notes", |
||||||
|
"write_allow": ["` + allowedPubkey + `"], |
||||||
|
"size_limit": 5000 |
||||||
|
} |
||||||
|
} |
||||||
|
}`, |
||||||
|
expectError: true, |
||||||
|
errorMsg: "cannot reduce size_limit", |
||||||
|
}, |
||||||
|
{ |
||||||
|
name: "invalid - policy admin cannot remove rule", |
||||||
|
newPolicy: `{ |
||||||
|
"default_policy": "allow", |
||||||
|
"owners": ["` + ownerPubkey + `"], |
||||||
|
"policy_admins": ["` + adminPubkey + `"], |
||||||
|
"kind": { |
||||||
|
"whitelist": [1, 3, 7] |
||||||
|
}, |
||||||
|
"rules": {} |
||||||
|
}`, |
||||||
|
expectError: true, |
||||||
|
errorMsg: "cannot remove rule for kind 1", |
||||||
|
}, |
||||||
|
{ |
||||||
|
name: "valid - policy admin can add blacklist entries for non-admin users", |
||||||
|
newPolicy: `{ |
||||||
|
"default_policy": "allow", |
||||||
|
"owners": ["` + ownerPubkey + `"], |
||||||
|
"policy_admins": ["` + adminPubkey + `"], |
||||||
|
"kind": { |
||||||
|
"whitelist": [1, 3, 7], |
||||||
|
"blacklist": [4] |
||||||
|
}, |
||||||
|
"rules": { |
||||||
|
"1": { |
||||||
|
"description": "Text notes", |
||||||
|
"write_allow": ["` + allowedPubkey + `"], |
||||||
|
"write_deny": ["eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"], |
||||||
|
"size_limit": 10000 |
||||||
|
} |
||||||
|
} |
||||||
|
}`, |
||||||
|
expectError: false, |
||||||
|
}, |
||||||
|
{ |
||||||
|
name: "invalid - policy admin cannot blacklist owner in write_deny", |
||||||
|
newPolicy: `{ |
||||||
|
"default_policy": "allow", |
||||||
|
"owners": ["` + ownerPubkey + `"], |
||||||
|
"policy_admins": ["` + adminPubkey + `"], |
||||||
|
"kind": { |
||||||
|
"whitelist": [1, 3, 7] |
||||||
|
}, |
||||||
|
"rules": { |
||||||
|
"1": { |
||||||
|
"description": "Text notes", |
||||||
|
"write_allow": ["` + allowedPubkey + `"], |
||||||
|
"write_deny": ["` + ownerPubkey + `"], |
||||||
|
"size_limit": 10000 |
||||||
|
} |
||||||
|
} |
||||||
|
}`, |
||||||
|
expectError: true, |
||||||
|
errorMsg: "cannot blacklist owner", |
||||||
|
}, |
||||||
|
{ |
||||||
|
name: "invalid - policy admin cannot blacklist other policy admin", |
||||||
|
newPolicy: `{ |
||||||
|
"default_policy": "allow", |
||||||
|
"owners": ["` + ownerPubkey + `"], |
||||||
|
"policy_admins": ["` + adminPubkey + `"], |
||||||
|
"kind": { |
||||||
|
"whitelist": [1, 3, 7] |
||||||
|
}, |
||||||
|
"rules": { |
||||||
|
"1": { |
||||||
|
"description": "Text notes", |
||||||
|
"write_allow": ["` + allowedPubkey + `"], |
||||||
|
"write_deny": ["` + adminPubkey + `"], |
||||||
|
"size_limit": 10000 |
||||||
|
} |
||||||
|
} |
||||||
|
}`, |
||||||
|
expectError: true, |
||||||
|
errorMsg: "cannot blacklist policy admin", |
||||||
|
}, |
||||||
|
{ |
||||||
|
name: "valid - policy admin can blacklist whitelisted non-admin user", |
||||||
|
newPolicy: `{ |
||||||
|
"default_policy": "allow", |
||||||
|
"owners": ["` + ownerPubkey + `"], |
||||||
|
"policy_admins": ["` + adminPubkey + `"], |
||||||
|
"kind": { |
||||||
|
"whitelist": [1, 3, 7] |
||||||
|
}, |
||||||
|
"rules": { |
||||||
|
"1": { |
||||||
|
"description": "Text notes", |
||||||
|
"write_allow": ["` + allowedPubkey + `"], |
||||||
|
"write_deny": ["` + allowedPubkey + `"], |
||||||
|
"size_limit": 10000 |
||||||
|
} |
||||||
|
} |
||||||
|
}`, |
||||||
|
expectError: false, |
||||||
|
}, |
||||||
|
} |
||||||
|
|
||||||
|
for _, tt := range tests { |
||||||
|
t.Run(tt.name, func(t *testing.T) { |
||||||
|
err := basePolicy.ValidatePolicyAdminUpdate([]byte(tt.newPolicy), adminPubkeyBin) |
||||||
|
if tt.expectError { |
||||||
|
if err == nil { |
||||||
|
t.Errorf("expected error containing %q, got nil", tt.errorMsg) |
||||||
|
} else if tt.errorMsg != "" && !containsSubstring(err.Error(), tt.errorMsg) { |
||||||
|
t.Errorf("expected error containing %q, got %q", tt.errorMsg, err.Error()) |
||||||
|
} |
||||||
|
} else { |
||||||
|
if err != nil { |
||||||
|
t.Errorf("unexpected error: %v", err) |
||||||
|
} |
||||||
|
} |
||||||
|
}) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// TestIsOwner tests the IsOwner method
|
||||||
|
func TestIsOwner(t *testing.T) { |
||||||
|
ownerPubkey := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" |
||||||
|
nonOwnerPubkey := "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" |
||||||
|
|
||||||
|
_ = nonOwnerPubkey // Silence unused variable warning
|
||||||
|
|
||||||
|
policyJSON := `{ |
||||||
|
"default_policy": "allow", |
||||||
|
"owners": ["` + ownerPubkey + `"] |
||||||
|
}` |
||||||
|
|
||||||
|
policy, err := New([]byte(policyJSON)) |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("failed to create policy: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
// Create binary pubkeys
|
||||||
|
ownerBin := make([]byte, 32) |
||||||
|
for i := range ownerBin { |
||||||
|
ownerBin[i] = 0xaa |
||||||
|
} |
||||||
|
|
||||||
|
nonOwnerBin := make([]byte, 32) |
||||||
|
for i := range nonOwnerBin { |
||||||
|
nonOwnerBin[i] = 0xbb |
||||||
|
} |
||||||
|
|
||||||
|
tests := []struct { |
||||||
|
name string |
||||||
|
pubkey []byte |
||||||
|
expected bool |
||||||
|
}{ |
||||||
|
{ |
||||||
|
name: "owner is recognized", |
||||||
|
pubkey: ownerBin, |
||||||
|
expected: true, |
||||||
|
}, |
||||||
|
{ |
||||||
|
name: "non-owner is not recognized", |
||||||
|
pubkey: nonOwnerBin, |
||||||
|
expected: false, |
||||||
|
}, |
||||||
|
{ |
||||||
|
name: "nil pubkey returns false", |
||||||
|
pubkey: nil, |
||||||
|
expected: false, |
||||||
|
}, |
||||||
|
{ |
||||||
|
name: "empty pubkey returns false", |
||||||
|
pubkey: []byte{}, |
||||||
|
expected: false, |
||||||
|
}, |
||||||
|
} |
||||||
|
|
||||||
|
for _, tt := range tests { |
||||||
|
t.Run(tt.name, func(t *testing.T) { |
||||||
|
result := policy.IsOwner(tt.pubkey) |
||||||
|
if result != tt.expected { |
||||||
|
t.Errorf("expected %v, got %v", tt.expected, result) |
||||||
|
} |
||||||
|
}) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// TestStringSliceEqual tests the helper function
|
||||||
|
func TestStringSliceEqual(t *testing.T) { |
||||||
|
tests := []struct { |
||||||
|
name string |
||||||
|
a []string |
||||||
|
b []string |
||||||
|
expected bool |
||||||
|
}{ |
||||||
|
{ |
||||||
|
name: "equal slices same order", |
||||||
|
a: []string{"a", "b", "c"}, |
||||||
|
b: []string{"a", "b", "c"}, |
||||||
|
expected: true, |
||||||
|
}, |
||||||
|
{ |
||||||
|
name: "equal slices different order", |
||||||
|
a: []string{"a", "b", "c"}, |
||||||
|
b: []string{"c", "a", "b"}, |
||||||
|
expected: true, |
||||||
|
}, |
||||||
|
{ |
||||||
|
name: "different lengths", |
||||||
|
a: []string{"a", "b"}, |
||||||
|
b: []string{"a", "b", "c"}, |
||||||
|
expected: false, |
||||||
|
}, |
||||||
|
{ |
||||||
|
name: "different contents", |
||||||
|
a: []string{"a", "b", "c"}, |
||||||
|
b: []string{"a", "b", "d"}, |
||||||
|
expected: false, |
||||||
|
}, |
||||||
|
{ |
||||||
|
name: "empty slices", |
||||||
|
a: []string{}, |
||||||
|
b: []string{}, |
||||||
|
expected: true, |
||||||
|
}, |
||||||
|
{ |
||||||
|
name: "nil slices", |
||||||
|
a: nil, |
||||||
|
b: nil, |
||||||
|
expected: true, |
||||||
|
}, |
||||||
|
{ |
||||||
|
name: "nil vs empty", |
||||||
|
a: nil, |
||||||
|
b: []string{}, |
||||||
|
expected: true, |
||||||
|
}, |
||||||
|
{ |
||||||
|
name: "duplicates in both", |
||||||
|
a: []string{"a", "a", "b"}, |
||||||
|
b: []string{"a", "b", "a"}, |
||||||
|
expected: true, |
||||||
|
}, |
||||||
|
} |
||||||
|
|
||||||
|
for _, tt := range tests { |
||||||
|
t.Run(tt.name, func(t *testing.T) { |
||||||
|
result := stringSliceEqual(tt.a, tt.b) |
||||||
|
if result != tt.expected { |
||||||
|
t.Errorf("expected %v, got %v", tt.expected, result) |
||||||
|
} |
||||||
|
}) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// TestPolicyAdminContributionValidation tests the contribution validation
|
||||||
|
func TestPolicyAdminContributionValidation(t *testing.T) { |
||||||
|
ownerPubkey := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" |
||||||
|
adminPubkey := "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" |
||||||
|
|
||||||
|
ownerPolicy := &P{ |
||||||
|
DefaultPolicy: "allow", |
||||||
|
Owners: []string{ownerPubkey}, |
||||||
|
PolicyAdmins: []string{adminPubkey}, |
||||||
|
Kind: Kinds{ |
||||||
|
Whitelist: []int{1, 3, 7}, |
||||||
|
}, |
||||||
|
rules: map[int]Rule{ |
||||||
|
1: { |
||||||
|
Description: "Text notes", |
||||||
|
SizeLimit: ptr(int64(10000)), |
||||||
|
}, |
||||||
|
}, |
||||||
|
} |
||||||
|
|
||||||
|
tests := []struct { |
||||||
|
name string |
||||||
|
contribution *PolicyAdminContribution |
||||||
|
expectError bool |
||||||
|
errorMsg string |
||||||
|
}{ |
||||||
|
{ |
||||||
|
name: "valid - add kinds to whitelist", |
||||||
|
contribution: &PolicyAdminContribution{ |
||||||
|
AdminPubkey: adminPubkey, |
||||||
|
CreatedAt: 1234567890, |
||||||
|
EventID: "event123", |
||||||
|
KindWhitelistAdd: []int{30023}, |
||||||
|
}, |
||||||
|
expectError: false, |
||||||
|
}, |
||||||
|
{ |
||||||
|
name: "valid - add to blacklist", |
||||||
|
contribution: &PolicyAdminContribution{ |
||||||
|
AdminPubkey: adminPubkey, |
||||||
|
CreatedAt: 1234567890, |
||||||
|
EventID: "event123", |
||||||
|
KindBlacklistAdd: []int{4}, |
||||||
|
}, |
||||||
|
expectError: false, |
||||||
|
}, |
||||||
|
{ |
||||||
|
name: "valid - extend existing rule with larger limit", |
||||||
|
contribution: &PolicyAdminContribution{ |
||||||
|
AdminPubkey: adminPubkey, |
||||||
|
CreatedAt: 1234567890, |
||||||
|
EventID: "event123", |
||||||
|
RulesExtend: map[int]RuleExtension{ |
||||||
|
1: { |
||||||
|
SizeLimitOverride: ptr(int64(20000)), |
||||||
|
}, |
||||||
|
}, |
||||||
|
}, |
||||||
|
expectError: false, |
||||||
|
}, |
||||||
|
{ |
||||||
|
name: "invalid - extend non-existent rule", |
||||||
|
contribution: &PolicyAdminContribution{ |
||||||
|
AdminPubkey: adminPubkey, |
||||||
|
CreatedAt: 1234567890, |
||||||
|
EventID: "event123", |
||||||
|
RulesExtend: map[int]RuleExtension{ |
||||||
|
999: { |
||||||
|
SizeLimitOverride: ptr(int64(20000)), |
||||||
|
}, |
||||||
|
}, |
||||||
|
}, |
||||||
|
expectError: true, |
||||||
|
errorMsg: "cannot extend rule for kind 999", |
||||||
|
}, |
||||||
|
{ |
||||||
|
name: "invalid - size limit override smaller than owner's", |
||||||
|
contribution: &PolicyAdminContribution{ |
||||||
|
AdminPubkey: adminPubkey, |
||||||
|
CreatedAt: 1234567890, |
||||||
|
EventID: "event123", |
||||||
|
RulesExtend: map[int]RuleExtension{ |
||||||
|
1: { |
||||||
|
SizeLimitOverride: ptr(int64(5000)), |
||||||
|
}, |
||||||
|
}, |
||||||
|
}, |
||||||
|
expectError: true, |
||||||
|
errorMsg: "size_limit_override for kind 1 must be >=", |
||||||
|
}, |
||||||
|
{ |
||||||
|
name: "valid - add new rule for undefined kind", |
||||||
|
contribution: &PolicyAdminContribution{ |
||||||
|
AdminPubkey: adminPubkey, |
||||||
|
CreatedAt: 1234567890, |
||||||
|
EventID: "event123", |
||||||
|
RulesAdd: map[int]Rule{ |
||||||
|
30023: { |
||||||
|
Description: "Long-form content", |
||||||
|
SizeLimit: ptr(int64(100000)), |
||||||
|
}, |
||||||
|
}, |
||||||
|
}, |
||||||
|
expectError: false, |
||||||
|
}, |
||||||
|
{ |
||||||
|
name: "invalid - add rule for already-defined kind", |
||||||
|
contribution: &PolicyAdminContribution{ |
||||||
|
AdminPubkey: adminPubkey, |
||||||
|
CreatedAt: 1234567890, |
||||||
|
EventID: "event123", |
||||||
|
RulesAdd: map[int]Rule{ |
||||||
|
1: { |
||||||
|
Description: "Trying to override", |
||||||
|
}, |
||||||
|
}, |
||||||
|
}, |
||||||
|
expectError: true, |
||||||
|
errorMsg: "cannot add rule for kind 1: already defined", |
||||||
|
}, |
||||||
|
{ |
||||||
|
name: "invalid - bad pubkey length in extension", |
||||||
|
contribution: &PolicyAdminContribution{ |
||||||
|
AdminPubkey: "short", |
||||||
|
CreatedAt: 1234567890, |
||||||
|
EventID: "event123", |
||||||
|
}, |
||||||
|
expectError: true, |
||||||
|
errorMsg: "invalid admin pubkey length", |
||||||
|
}, |
||||||
|
} |
||||||
|
|
||||||
|
for _, tt := range tests { |
||||||
|
t.Run(tt.name, func(t *testing.T) { |
||||||
|
err := ValidatePolicyAdminContribution(ownerPolicy, tt.contribution, nil) |
||||||
|
if tt.expectError { |
||||||
|
if err == nil { |
||||||
|
t.Errorf("expected error containing %q, got nil", tt.errorMsg) |
||||||
|
} else if tt.errorMsg != "" && !containsSubstring(err.Error(), tt.errorMsg) { |
||||||
|
t.Errorf("expected error containing %q, got %q", tt.errorMsg, err.Error()) |
||||||
|
} |
||||||
|
} else { |
||||||
|
if err != nil { |
||||||
|
t.Errorf("unexpected error: %v", err) |
||||||
|
} |
||||||
|
} |
||||||
|
}) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Helper function for generic pointer
|
||||||
|
func ptr[T any](v T) *T { |
||||||
|
return &v |
||||||
|
} |
||||||
Loading…
Reference in new issue