19 changed files with 5057 additions and 9 deletions
@ -0,0 +1,442 @@
@@ -0,0 +1,442 @@
|
||||
# Implementation Plan: Directory Spider (Issue #7) |
||||
|
||||
## Overview |
||||
|
||||
Add a new "directory spider" that discovers relays by crawling kind 10002 (relay list) events, expanding outward in hops from whitelisted users, and then fetches essential metadata events (kinds 0, 3, 10000, 10002) from the discovered network. |
||||
|
||||
**Key Characteristics:** |
||||
- Runs once per day (configurable) |
||||
- Single-threaded, serial operations to minimize load |
||||
- 3-hop relay discovery from whitelisted users |
||||
- Fetches: kind 0 (profile), 3 (follow list), 10000 (mute list), 10002 (relay list) |
||||
|
||||
--- |
||||
|
||||
## Architecture |
||||
|
||||
### New Package Structure |
||||
|
||||
``` |
||||
pkg/spider/ |
||||
├── spider.go # Existing follows spider |
||||
├── directory.go # NEW: Directory spider implementation |
||||
├── directory_test.go # NEW: Tests |
||||
└── common.go # NEW: Shared utilities (extract from spider.go) |
||||
``` |
||||
|
||||
### Core Components |
||||
|
||||
```go |
||||
// DirectorySpider manages the daily relay discovery and metadata sync |
||||
type DirectorySpider struct { |
||||
ctx context.Context |
||||
cancel context.CancelFunc |
||||
db *database.D |
||||
pub publisher.I |
||||
|
||||
// Configuration |
||||
interval time.Duration // Default: 24h |
||||
maxHops int // Default: 3 |
||||
|
||||
// State |
||||
running atomic.Bool |
||||
lastRun time.Time |
||||
|
||||
// Relay discovery |
||||
discoveredRelays map[string]int // URL -> hop distance |
||||
processedRelays map[string]bool // Already fetched from |
||||
|
||||
// Callbacks for integration |
||||
getSeedPubkeys func() [][]byte // Whitelisted users (from ACL) |
||||
} |
||||
``` |
||||
|
||||
--- |
||||
|
||||
## Implementation Phases |
||||
|
||||
### Phase 1: Core Directory Spider Structure |
||||
|
||||
**File:** `pkg/spider/directory.go` |
||||
|
||||
1. **Create DirectorySpider struct** with: |
||||
- Context management for cancellation |
||||
- Database and publisher references |
||||
- Configuration (interval, max hops) |
||||
- State tracking (discovered relays, processed relays) |
||||
|
||||
2. **Constructor:** `NewDirectorySpider(ctx, db, pub, interval, maxHops)` |
||||
- Initialize maps and state |
||||
- Set defaults (24h interval, 3 hops) |
||||
|
||||
3. **Lifecycle methods:** |
||||
- `Start()` - Launch main goroutine |
||||
- `Stop()` - Cancel context and wait for shutdown |
||||
- `TriggerNow()` - Force immediate run (for testing/admin) |
||||
|
||||
### Phase 2: Relay Discovery (3-Hop Expansion) |
||||
|
||||
**Algorithm:** |
||||
|
||||
``` |
||||
Round 1: Get relay lists from whitelisted users |
||||
- Query local DB for kind 10002 events from seed pubkeys |
||||
- Extract relay URLs from "r" tags |
||||
- Mark as hop 0 relays |
||||
|
||||
Round 2-4 (3 iterations): |
||||
- For each relay at current hop level (in serial): |
||||
1. Connect to relay |
||||
2. Query for ALL kind 10002 events (limit: 5000) |
||||
3. Extract new relay URLs |
||||
4. Mark as hop N+1 relays |
||||
5. Close connection |
||||
6. Sleep briefly between relays (rate limiting) |
||||
``` |
||||
|
||||
**Key Methods:** |
||||
|
||||
```go |
||||
// discoverRelays performs the 3-hop relay expansion |
||||
func (ds *DirectorySpider) discoverRelays(ctx context.Context) error |
||||
|
||||
// fetchRelayListsFromRelay connects to a relay and fetches kind 10002 events |
||||
func (ds *DirectorySpider) fetchRelayListsFromRelay(ctx context.Context, relayURL string) ([]*event.T, error) |
||||
|
||||
// extractRelaysFromEvents parses kind 10002 events and extracts relay URLs |
||||
func (ds *DirectorySpider) extractRelaysFromEvents(events []*event.T) []string |
||||
``` |
||||
|
||||
### Phase 3: Metadata Fetching |
||||
|
||||
After relay discovery, fetch essential metadata from all discovered relays: |
||||
|
||||
**Kinds to fetch:** |
||||
- Kind 0: Profile metadata (replaceable) |
||||
- Kind 3: Follow lists (replaceable) |
||||
- Kind 10000: Mute lists (replaceable) |
||||
- Kind 10002: Relay lists (already have many, but get latest) |
||||
|
||||
**Fetch Strategy:** |
||||
|
||||
```go |
||||
// fetchMetadataFromRelays iterates through discovered relays serially |
||||
func (ds *DirectorySpider) fetchMetadataFromRelays(ctx context.Context) error { |
||||
for relayURL := range ds.discoveredRelays { |
||||
// Skip if already processed |
||||
if ds.processedRelays[relayURL] { |
||||
continue |
||||
} |
||||
|
||||
// Fetch each kind type |
||||
for _, k := range []int{0, 3, 10000, 10002} { |
||||
events, err := ds.fetchKindFromRelay(ctx, relayURL, k) |
||||
// Store events... |
||||
} |
||||
|
||||
ds.processedRelays[relayURL] = true |
||||
|
||||
// Rate limiting sleep |
||||
time.Sleep(500 * time.Millisecond) |
||||
} |
||||
} |
||||
``` |
||||
|
||||
**Query Filters:** |
||||
- For replaceable events (0, 3, 10000, 10002): No time filter, let relay return latest |
||||
- Limit per query: 1000-5000 events |
||||
- Use pagination if relay supports it |
||||
|
||||
### Phase 4: WebSocket Client for Fetching |
||||
|
||||
**Reuse existing patterns from spider.go:** |
||||
|
||||
```go |
||||
// fetchFromRelay handles connection, query, and cleanup |
||||
func (ds *DirectorySpider) fetchFromRelay(ctx context.Context, relayURL string, f *filter.F) ([]*event.T, error) { |
||||
// Create timeout context (30 seconds per relay) |
||||
ctx, cancel := context.WithTimeout(ctx, 30*time.Second) |
||||
defer cancel() |
||||
|
||||
// Connect using ws.Client (from pkg/protocol/ws) |
||||
client, err := ws.NewClient(ctx, relayURL) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
defer client.Close() |
||||
|
||||
// Subscribe with filter |
||||
sub, err := client.Subscribe(ctx, f) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
// Collect events until EOSE or timeout |
||||
var events []*event.T |
||||
for ev := range sub.Events { |
||||
events = append(events, ev) |
||||
} |
||||
|
||||
return events, nil |
||||
} |
||||
``` |
||||
|
||||
### Phase 5: Event Storage |
||||
|
||||
**Storage Strategy:** |
||||
|
||||
```go |
||||
func (ds *DirectorySpider) storeEvents(ctx context.Context, events []*event.T) (saved, duplicates int) { |
||||
for _, ev := range events { |
||||
_, err := ds.db.SaveEvent(ctx, ev) |
||||
if err != nil { |
||||
if errors.Is(err, database.ErrDuplicate) { |
||||
duplicates++ |
||||
continue |
||||
} |
||||
// Log other errors but continue |
||||
log.W.F("failed to save event %s: %v", ev.ID.String(), err) |
||||
continue |
||||
} |
||||
saved++ |
||||
|
||||
// Publish to active subscribers |
||||
ds.pub.Deliver(ev) |
||||
} |
||||
return |
||||
} |
||||
``` |
||||
|
||||
### Phase 6: Main Loop |
||||
|
||||
```go |
||||
func (ds *DirectorySpider) mainLoop() { |
||||
// Calculate time until next run |
||||
ticker := time.NewTicker(ds.interval) |
||||
defer ticker.Stop() |
||||
|
||||
// Run immediately on start |
||||
ds.runOnce() |
||||
|
||||
for { |
||||
select { |
||||
case <-ds.ctx.Done(): |
||||
return |
||||
case <-ticker.C: |
||||
ds.runOnce() |
||||
} |
||||
} |
||||
} |
||||
|
||||
func (ds *DirectorySpider) runOnce() { |
||||
if !ds.running.CompareAndSwap(false, true) { |
||||
log.I.F("directory spider already running, skipping") |
||||
return |
||||
} |
||||
defer ds.running.Store(false) |
||||
|
||||
log.I.F("starting directory spider run") |
||||
start := time.Now() |
||||
|
||||
// Reset state |
||||
ds.discoveredRelays = make(map[string]int) |
||||
ds.processedRelays = make(map[string]bool) |
||||
|
||||
// Phase 1: Discover relays via 3-hop expansion |
||||
if err := ds.discoverRelays(ds.ctx); err != nil { |
||||
log.E.F("relay discovery failed: %v", err) |
||||
return |
||||
} |
||||
log.I.F("discovered %d relays", len(ds.discoveredRelays)) |
||||
|
||||
// Phase 2: Fetch metadata from all relays |
||||
if err := ds.fetchMetadataFromRelays(ds.ctx); err != nil { |
||||
log.E.F("metadata fetch failed: %v", err) |
||||
return |
||||
} |
||||
|
||||
ds.lastRun = time.Now() |
||||
log.I.F("directory spider completed in %v", time.Since(start)) |
||||
} |
||||
``` |
||||
|
||||
### Phase 7: Configuration |
||||
|
||||
**New environment variables:** |
||||
|
||||
```go |
||||
// In app/config/config.go |
||||
DirectorySpiderEnabled bool `env:"ORLY_DIRECTORY_SPIDER" default:"false" usage:"enable directory spider for metadata sync"` |
||||
DirectorySpiderInterval time.Duration `env:"ORLY_DIRECTORY_SPIDER_INTERVAL" default:"24h" usage:"how often to run directory spider"` |
||||
DirectorySpiderMaxHops int `env:"ORLY_DIRECTORY_SPIDER_HOPS" default:"3" usage:"maximum hops for relay discovery"` |
||||
``` |
||||
|
||||
### Phase 8: Integration with app/main.go |
||||
|
||||
```go |
||||
// After existing spider initialization |
||||
if badgerDB, ok := db.(*database.D); ok && cfg.DirectorySpiderEnabled { |
||||
l.directorySpider, err = spider.NewDirectorySpider( |
||||
ctx, |
||||
badgerDB, |
||||
l.publishers, |
||||
cfg.DirectorySpiderInterval, |
||||
cfg.DirectorySpiderMaxHops, |
||||
) |
||||
if err != nil { |
||||
return nil, fmt.Errorf("failed to create directory spider: %w", err) |
||||
} |
||||
|
||||
// Set callback to get seed pubkeys from ACL |
||||
l.directorySpider.SetSeedCallback(func() [][]byte { |
||||
// Get whitelisted users from all ACLs |
||||
var pubkeys [][]byte |
||||
for _, aclInstance := range acl.Registry.ACL { |
||||
if follows, ok := aclInstance.(*acl.Follows); ok { |
||||
pubkeys = append(pubkeys, follows.GetFollowedPubkeys()...) |
||||
} |
||||
} |
||||
return pubkeys |
||||
}) |
||||
|
||||
l.directorySpider.Start() |
||||
} |
||||
``` |
||||
|
||||
--- |
||||
|
||||
## Self-Relay Detection |
||||
|
||||
Reuse the existing `isSelfRelay()` pattern from spider.go: |
||||
|
||||
```go |
||||
func (ds *DirectorySpider) isSelfRelay(relayURL string) bool { |
||||
// Use NIP-11 to get relay pubkey |
||||
// Compare against our relay identity pubkey |
||||
// Cache results to avoid repeated requests |
||||
} |
||||
``` |
||||
|
||||
--- |
||||
|
||||
## Error Handling & Resilience |
||||
|
||||
1. **Connection Timeouts:** 30 seconds per relay |
||||
2. **Query Timeouts:** 60 seconds per query |
||||
3. **Graceful Degradation:** Continue to next relay on failure |
||||
4. **Rate Limiting:** 500ms sleep between relays |
||||
5. **Memory Limits:** Process events in batches of 1000 |
||||
6. **Context Cancellation:** Check at each step for shutdown |
||||
|
||||
--- |
||||
|
||||
## Testing Strategy |
||||
|
||||
### Unit Tests |
||||
|
||||
```go |
||||
// pkg/spider/directory_test.go |
||||
|
||||
func TestExtractRelaysFromEvents(t *testing.T) |
||||
func TestDiscoveryHopTracking(t *testing.T) |
||||
func TestSelfRelayFiltering(t *testing.T) |
||||
``` |
||||
|
||||
### Integration Tests |
||||
|
||||
```go |
||||
func TestDirectorySpiderE2E(t *testing.T) { |
||||
// Start test relay |
||||
// Populate with kind 10002 events |
||||
// Run directory spider |
||||
// Verify events fetched and stored |
||||
} |
||||
``` |
||||
|
||||
--- |
||||
|
||||
## Logging |
||||
|
||||
Use existing `lol.mleku.dev` logging patterns: |
||||
|
||||
```go |
||||
log.I.F("directory spider: starting relay discovery") |
||||
log.D.F("directory spider: hop %d, discovered %d new relays", hop, count) |
||||
log.W.F("directory spider: failed to connect to %s: %v", url, err) |
||||
log.E.F("directory spider: critical error: %v", err) |
||||
``` |
||||
|
||||
--- |
||||
|
||||
## Implementation Order |
||||
|
||||
1. **Phase 1:** Core struct and lifecycle (1-2 hours) |
||||
2. **Phase 2:** Relay discovery with hop expansion (2-3 hours) |
||||
3. **Phase 3:** Metadata fetching (1-2 hours) |
||||
4. **Phase 4:** WebSocket client integration (1 hour) |
||||
5. **Phase 5:** Event storage (30 min) |
||||
6. **Phase 6:** Main loop and scheduling (1 hour) |
||||
7. **Phase 7:** Configuration (30 min) |
||||
8. **Phase 8:** Integration with main.go (30 min) |
||||
9. **Testing:** Unit and integration tests (2-3 hours) |
||||
|
||||
**Total Estimate:** 10-14 hours |
||||
|
||||
--- |
||||
|
||||
## Future Enhancements (Out of Scope) |
||||
|
||||
- Web UI status page for directory spider |
||||
- Metrics/stats collection (relays discovered, events fetched) |
||||
- Configurable kind list to fetch |
||||
- Priority ordering of relays (closer hops first) |
||||
- Persistent relay discovery cache between runs |
||||
|
||||
--- |
||||
|
||||
## Dependencies |
||||
|
||||
**Existing packages to use:** |
||||
- `pkg/protocol/ws` - WebSocket client |
||||
- `pkg/database` - Event storage |
||||
- `pkg/encoders/filter` - Query filter construction |
||||
- `pkg/acl` - Get whitelisted users |
||||
- `pkg/sync` - NIP-11 cache for self-detection (if needed) |
||||
|
||||
**No new external dependencies required.** |
||||
|
||||
--- |
||||
|
||||
## Follow-up Items (Post-Implementation) |
||||
|
||||
### TODO: Verify Connection Behavior is Not Overly Aggressive |
||||
|
||||
**Issue:** The current implementation creates a **new WebSocket connection for each kind query** when fetching metadata. For each relay, this means: |
||||
1. Connect → fetch kind 0 → disconnect |
||||
2. Connect → fetch kind 3 → disconnect |
||||
3. Connect → fetch kind 10000 → disconnect |
||||
4. Connect → fetch kind 10002 → disconnect |
||||
|
||||
This could be seen as aggressive by remote relays and may trigger rate limiting or IP bans. |
||||
|
||||
**Verification needed:** |
||||
- [ ] Monitor logs with `ORLY_LOG_LEVEL=debug` to see per-kind fetch results |
||||
- [ ] Check if relays are returning events for all 4 kinds or just kind 0 |
||||
- [ ] Look for WARNING logs about connection failures or rate limiting |
||||
- [ ] Verify the 500ms delay between relays is sufficient |
||||
|
||||
**Potential optimization (if needed):** |
||||
- Refactor `fetchMetadataFromRelays()` to use a single connection per relay |
||||
- Fetch all 4 kinds using multiple subscriptions on one connection |
||||
- Example pattern: |
||||
```go |
||||
client, err := ws.RelayConnect(ctx, relayURL) |
||||
defer client.Close() |
||||
|
||||
for _, k := range kindsToFetch { |
||||
events, _ := fetchKindOnConnection(client, k) |
||||
// ... |
||||
} |
||||
``` |
||||
|
||||
**Priority:** Medium - only optimize if monitoring shows issues with the current approach |
||||
@ -0,0 +1,312 @@
@@ -0,0 +1,312 @@
|
||||
package app |
||||
|
||||
import ( |
||||
"bytes" |
||||
"fmt" |
||||
"path/filepath" |
||||
|
||||
"github.com/adrg/xdg" |
||||
"lol.mleku.dev/chk" |
||||
"lol.mleku.dev/log" |
||||
"git.mleku.dev/mleku/nostr/encoders/event" |
||||
"git.mleku.dev/mleku/nostr/encoders/filter" |
||||
"git.mleku.dev/mleku/nostr/encoders/hex" |
||||
"git.mleku.dev/mleku/nostr/encoders/kind" |
||||
"git.mleku.dev/mleku/nostr/encoders/tag" |
||||
) |
||||
|
||||
// HandlePolicyConfigUpdate processes kind 12345 policy configuration events.
|
||||
// Only policy admins can update policy configuration.
|
||||
//
|
||||
// Process flow:
|
||||
// 1. Verify sender is policy admin (from current policy.policy_admins list)
|
||||
// 2. Parse and validate JSON FIRST (before making any changes)
|
||||
// 3. Pause ALL message processing (lock mutex)
|
||||
// 4. Reload policy (pause policy engine, update, save, resume)
|
||||
// 5. Resume message processing (unlock mutex)
|
||||
//
|
||||
// The message processing mutex is already released by the caller (HandleEvent),
|
||||
// so we acquire it ourselves for the critical section.
|
||||
func (l *Listener) HandlePolicyConfigUpdate(ev *event.E) error { |
||||
log.I.F("received policy config update from pubkey: %s", hex.Enc(ev.Pubkey)) |
||||
|
||||
// 1. Verify sender is policy admin (from current policy.policy_admins list)
|
||||
if l.policyManager == nil { |
||||
return fmt.Errorf("policy system is not enabled") |
||||
} |
||||
|
||||
isAdmin := l.policyManager.IsPolicyAdmin(ev.Pubkey) |
||||
if !isAdmin { |
||||
log.W.F("policy config update rejected: pubkey %s is not a policy admin", hex.Enc(ev.Pubkey)) |
||||
return fmt.Errorf("only policy administrators can update policy configuration") |
||||
} |
||||
|
||||
log.I.F("policy admin verified: %s", hex.Enc(ev.Pubkey)) |
||||
|
||||
// 2. Parse and validate JSON FIRST (before making any changes)
|
||||
policyJSON := []byte(ev.Content) |
||||
if err := l.policyManager.ValidateJSON(policyJSON); chk.E(err) { |
||||
log.E.F("policy config update validation failed: %v", err) |
||||
return fmt.Errorf("invalid policy configuration: %v", err) |
||||
} |
||||
|
||||
log.I.F("policy config validation passed") |
||||
|
||||
// Get config path for saving
|
||||
configPath := filepath.Join(xdg.ConfigHome, l.Config.AppName, "policy.json") |
||||
|
||||
// 3. Pause ALL message processing (lock mutex)
|
||||
// Note: We need to release the RLock first (which caller holds), then acquire exclusive Lock
|
||||
// Actually, the HandleMessage already released the lock after calling HandleEvent
|
||||
// So we can directly acquire the exclusive lock
|
||||
log.I.F("pausing message processing for policy update") |
||||
l.Server.PauseMessageProcessing() |
||||
defer l.Server.ResumeMessageProcessing() |
||||
|
||||
// 4. Reload policy (this will pause policy engine, update, save, and resume)
|
||||
log.I.F("applying policy configuration update") |
||||
if err := l.policyManager.Reload(policyJSON, configPath); chk.E(err) { |
||||
log.E.F("policy config update failed: %v", err) |
||||
return fmt.Errorf("failed to apply policy configuration: %v", err) |
||||
} |
||||
|
||||
log.I.F("policy configuration updated successfully by admin: %s", hex.Enc(ev.Pubkey)) |
||||
|
||||
// 5. Message processing mutex will be unlocked by defer
|
||||
return nil |
||||
} |
||||
|
||||
// HandlePolicyAdminFollowListUpdate processes kind 3 follow list events from policy admins.
|
||||
// When a policy admin updates their follow list, we immediately refresh the policy follows cache.
|
||||
//
|
||||
// Process flow:
|
||||
// 1. Check if sender is a policy admin
|
||||
// 2. If yes, extract p-tags from the follow list
|
||||
// 3. Pause message processing
|
||||
// 4. Aggregate all policy admin follows and update cache
|
||||
// 5. Resume message processing
|
||||
func (l *Listener) HandlePolicyAdminFollowListUpdate(ev *event.E) error { |
||||
// Only process if policy system is enabled
|
||||
if l.policyManager == nil || !l.policyManager.IsEnabled() { |
||||
return nil // Not an error, just ignore
|
||||
} |
||||
|
||||
// Check if sender is a policy admin
|
||||
if !l.policyManager.IsPolicyAdmin(ev.Pubkey) { |
||||
return nil // Not a policy admin, ignore
|
||||
} |
||||
|
||||
log.I.F("policy admin %s updated their follow list, refreshing policy follows", hex.Enc(ev.Pubkey)) |
||||
|
||||
// Extract p-tags from this follow list event
|
||||
newFollows := extractFollowsFromEvent(ev) |
||||
|
||||
// Pause message processing for atomic update
|
||||
log.D.F("pausing message processing for follow list update") |
||||
l.Server.PauseMessageProcessing() |
||||
defer l.Server.ResumeMessageProcessing() |
||||
|
||||
// Get all current follows from database for all policy admins
|
||||
// For now, we'll merge the new follows with existing ones
|
||||
// A more complete implementation would re-fetch all admin follows from DB
|
||||
allFollows, err := l.fetchAllPolicyAdminFollows() |
||||
if err != nil { |
||||
log.W.F("failed to fetch all policy admin follows: %v, using new follows only", err) |
||||
allFollows = newFollows |
||||
} else { |
||||
// Merge with the new follows (deduplicated)
|
||||
allFollows = mergeFollows(allFollows, newFollows) |
||||
} |
||||
|
||||
// Update the policy follows cache
|
||||
l.policyManager.UpdatePolicyFollows(allFollows) |
||||
|
||||
log.I.F("policy follows cache updated with %d total pubkeys", len(allFollows)) |
||||
return nil |
||||
} |
||||
|
||||
// extractFollowsFromEvent extracts p-tag pubkeys from a kind 3 follow list event.
|
||||
// Returns binary pubkeys.
|
||||
func extractFollowsFromEvent(ev *event.E) [][]byte { |
||||
var follows [][]byte |
||||
|
||||
pTags := ev.Tags.GetAll([]byte("p")) |
||||
for _, pTag := range pTags { |
||||
// ValueHex() handles both binary and hex storage formats automatically
|
||||
pt, err := hex.Dec(string(pTag.ValueHex())) |
||||
if err != nil { |
||||
continue |
||||
} |
||||
follows = append(follows, pt) |
||||
} |
||||
|
||||
return follows |
||||
} |
||||
|
||||
// fetchAllPolicyAdminFollows fetches kind 3 events for all policy admins from the database
|
||||
// and aggregates their follows.
|
||||
func (l *Listener) fetchAllPolicyAdminFollows() ([][]byte, error) { |
||||
var allFollows [][]byte |
||||
seen := make(map[string]bool) |
||||
|
||||
// Get policy admin pubkeys
|
||||
admins := l.policyManager.GetPolicyAdminsBin() |
||||
if len(admins) == 0 { |
||||
return nil, fmt.Errorf("no policy admins configured") |
||||
} |
||||
|
||||
// For each admin, query their latest kind 3 event
|
||||
for _, adminPubkey := range admins { |
||||
// Build proper filter for kind 3 from this admin
|
||||
f := filter.New() |
||||
f.Authors = tag.NewFromAny(adminPubkey) |
||||
f.Kinds = kind.NewS(kind.FollowList) |
||||
limit := uint(1) |
||||
f.Limit = &limit |
||||
|
||||
// Query the database for kind 3 events from this admin
|
||||
events, err := l.DB.QueryEvents(l.ctx, f) |
||||
if err != nil { |
||||
log.W.F("failed to query follows for admin %s: %v", hex.Enc(adminPubkey), err) |
||||
continue |
||||
} |
||||
|
||||
// events is []*event.E - iterate over the slice
|
||||
for _, ev := range events { |
||||
// Extract p-tags from this follow list
|
||||
follows := extractFollowsFromEvent(ev) |
||||
for _, follow := range follows { |
||||
key := string(follow) |
||||
if !seen[key] { |
||||
seen[key] = true |
||||
allFollows = append(allFollows, follow) |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
return allFollows, nil |
||||
} |
||||
|
||||
// mergeFollows merges two follow lists, removing duplicates.
|
||||
func mergeFollows(existing, newFollows [][]byte) [][]byte { |
||||
seen := make(map[string]bool) |
||||
var result [][]byte |
||||
|
||||
for _, f := range existing { |
||||
key := string(f) |
||||
if !seen[key] { |
||||
seen[key] = true |
||||
result = append(result, f) |
||||
} |
||||
} |
||||
|
||||
for _, f := range newFollows { |
||||
key := string(f) |
||||
if !seen[key] { |
||||
seen[key] = true |
||||
result = append(result, f) |
||||
} |
||||
} |
||||
|
||||
return result |
||||
} |
||||
|
||||
// IsPolicyConfigEvent returns true if the event is a policy configuration event (kind 12345)
|
||||
func IsPolicyConfigEvent(ev *event.E) bool { |
||||
return ev.Kind == kind.PolicyConfig.K |
||||
} |
||||
|
||||
// IsPolicyAdminFollowListEvent returns true if this is a follow list event from a policy admin.
|
||||
// Used to detect when we need to refresh the policy follows cache.
|
||||
func (l *Listener) IsPolicyAdminFollowListEvent(ev *event.E) bool { |
||||
// Must be kind 3 (follow list)
|
||||
if ev.Kind != kind.FollowList.K { |
||||
return false |
||||
} |
||||
|
||||
// Policy system must be enabled
|
||||
if l.policyManager == nil || !l.policyManager.IsEnabled() { |
||||
return false |
||||
} |
||||
|
||||
// Sender must be a policy admin
|
||||
return l.policyManager.IsPolicyAdmin(ev.Pubkey) |
||||
} |
||||
|
||||
// isPolicyAdmin checks if a pubkey is in the list of policy admins
|
||||
func isPolicyAdmin(pubkey []byte, admins [][]byte) bool { |
||||
for _, admin := range admins { |
||||
if bytes.Equal(pubkey, admin) { |
||||
return true |
||||
} |
||||
} |
||||
return false |
||||
} |
||||
|
||||
// InitializePolicyFollows loads the follow lists of all policy admins at startup.
|
||||
// This should be called after the policy manager is initialized but before
|
||||
// the relay starts accepting connections.
|
||||
// It's a method on Server so it can be called from main.go during initialization.
|
||||
func (s *Server) InitializePolicyFollows() error { |
||||
// Skip if policy system is not enabled
|
||||
if s.policyManager == nil || !s.policyManager.IsEnabled() { |
||||
log.D.F("policy system not enabled, skipping follow list initialization") |
||||
return nil |
||||
} |
||||
|
||||
// Skip if PolicyFollowWhitelistEnabled is false
|
||||
if !s.policyManager.IsPolicyFollowWhitelistEnabled() { |
||||
log.D.F("policy follow whitelist not enabled, skipping follow list initialization") |
||||
return nil |
||||
} |
||||
|
||||
log.I.F("initializing policy follows from database") |
||||
|
||||
// Get policy admin pubkeys
|
||||
admins := s.policyManager.GetPolicyAdminsBin() |
||||
if len(admins) == 0 { |
||||
log.W.F("no policy admins configured, skipping follow list initialization") |
||||
return nil |
||||
} |
||||
|
||||
var allFollows [][]byte |
||||
seen := make(map[string]bool) |
||||
|
||||
// For each admin, query their latest kind 3 event
|
||||
for _, adminPubkey := range admins { |
||||
// Build proper filter for kind 3 from this admin
|
||||
f := filter.New() |
||||
f.Authors = tag.NewFromAny(adminPubkey) |
||||
f.Kinds = kind.NewS(kind.FollowList) |
||||
limit := uint(1) |
||||
f.Limit = &limit |
||||
|
||||
// Query the database for kind 3 events from this admin
|
||||
events, err := s.DB.QueryEvents(s.Ctx, f) |
||||
if err != nil { |
||||
log.W.F("failed to query follows for admin %s: %v", hex.Enc(adminPubkey), err) |
||||
continue |
||||
} |
||||
|
||||
// Extract p-tags from each follow list event
|
||||
for _, ev := range events { |
||||
follows := extractFollowsFromEvent(ev) |
||||
for _, follow := range follows { |
||||
key := string(follow) |
||||
if !seen[key] { |
||||
seen[key] = true |
||||
allFollows = append(allFollows, follow) |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
// Update the policy follows cache
|
||||
s.policyManager.UpdatePolicyFollows(allFollows) |
||||
|
||||
log.I.F("policy follows initialized with %d pubkeys from %d admin(s)", |
||||
len(allFollows), len(admins)) |
||||
|
||||
return nil |
||||
} |
||||
@ -0,0 +1,469 @@
@@ -0,0 +1,469 @@
|
||||
package app |
||||
|
||||
import ( |
||||
"context" |
||||
"os" |
||||
"path/filepath" |
||||
"sync" |
||||
"testing" |
||||
"time" |
||||
|
||||
"github.com/adrg/xdg" |
||||
"git.mleku.dev/mleku/nostr/encoders/event" |
||||
"git.mleku.dev/mleku/nostr/encoders/hex" |
||||
"git.mleku.dev/mleku/nostr/encoders/kind" |
||||
"git.mleku.dev/mleku/nostr/encoders/tag" |
||||
"git.mleku.dev/mleku/nostr/interfaces/signer/p8k" |
||||
"next.orly.dev/app/config" |
||||
"next.orly.dev/pkg/acl" |
||||
"next.orly.dev/pkg/database" |
||||
"next.orly.dev/pkg/policy" |
||||
"next.orly.dev/pkg/protocol/publish" |
||||
) |
||||
|
||||
// setupPolicyTestListener creates a test listener with policy system enabled
|
||||
func setupPolicyTestListener(t *testing.T, policyAdminHex string) (*Listener, *database.D, func()) { |
||||
tempDir, err := os.MkdirTemp("", "policy_handler_test_*") |
||||
if err != nil { |
||||
t.Fatalf("failed to create temp dir: %v", err) |
||||
} |
||||
|
||||
// Use a unique app name per test to avoid conflicts
|
||||
appName := "test-policy-" + filepath.Base(tempDir) |
||||
|
||||
// Create the XDG config directory and default policy file BEFORE creating the policy manager
|
||||
configDir := filepath.Join(xdg.ConfigHome, appName) |
||||
if err := os.MkdirAll(configDir, 0755); err != nil { |
||||
os.RemoveAll(tempDir) |
||||
t.Fatalf("failed to create config dir: %v", err) |
||||
} |
||||
|
||||
// Create initial policy file with admin if provided
|
||||
var initialPolicy []byte |
||||
if policyAdminHex != "" { |
||||
initialPolicy = []byte(`{ |
||||
"default_policy": "allow", |
||||
"policy_admins": ["` + policyAdminHex + `"], |
||||
"policy_follow_whitelist_enabled": true |
||||
}`) |
||||
} else { |
||||
initialPolicy = []byte(`{"default_policy": "allow"}`) |
||||
} |
||||
policyPath := filepath.Join(configDir, "policy.json") |
||||
if err := os.WriteFile(policyPath, initialPolicy, 0644); err != nil { |
||||
os.RemoveAll(tempDir) |
||||
os.RemoveAll(configDir) |
||||
t.Fatalf("failed to write policy file: %v", err) |
||||
} |
||||
|
||||
ctx, cancel := context.WithCancel(context.Background()) |
||||
db, err := database.New(ctx, cancel, tempDir, "info") |
||||
if err != nil { |
||||
os.RemoveAll(tempDir) |
||||
os.RemoveAll(configDir) |
||||
t.Fatalf("failed to open database: %v", err) |
||||
} |
||||
|
||||
cfg := &config.C{ |
||||
PolicyEnabled: true, |
||||
RelayURL: "wss://test.relay", |
||||
Listen: "localhost", |
||||
Port: 3334, |
||||
ACLMode: "none", |
||||
AppName: appName, |
||||
} |
||||
|
||||
// Create policy manager - now config file exists at XDG path
|
||||
policyManager := policy.NewWithManager(ctx, cfg.AppName, cfg.PolicyEnabled) |
||||
|
||||
server := &Server{ |
||||
Ctx: ctx, |
||||
Config: cfg, |
||||
DB: db, |
||||
publishers: publish.New(NewPublisher(ctx)), |
||||
policyManager: policyManager, |
||||
cfg: cfg, |
||||
db: db, |
||||
messagePauseMutex: sync.RWMutex{}, |
||||
} |
||||
|
||||
// Configure ACL registry
|
||||
acl.Registry.Active.Store(cfg.ACLMode) |
||||
if err = acl.Registry.Configure(cfg, db, ctx); err != nil { |
||||
db.Close() |
||||
os.RemoveAll(tempDir) |
||||
os.RemoveAll(configDir) |
||||
t.Fatalf("failed to configure ACL: %v", err) |
||||
} |
||||
|
||||
listener := &Listener{ |
||||
Server: server, |
||||
ctx: ctx, |
||||
writeChan: make(chan publish.WriteRequest, 100), |
||||
writeDone: make(chan struct{}), |
||||
messageQueue: make(chan messageRequest, 100), |
||||
processingDone: make(chan struct{}), |
||||
subscriptions: make(map[string]context.CancelFunc), |
||||
} |
||||
|
||||
// Start write worker and message processor
|
||||
go listener.writeWorker() |
||||
go listener.messageProcessor() |
||||
|
||||
cleanup := func() { |
||||
close(listener.writeChan) |
||||
<-listener.writeDone |
||||
close(listener.messageQueue) |
||||
<-listener.processingDone |
||||
db.Close() |
||||
os.RemoveAll(tempDir) |
||||
os.RemoveAll(configDir) |
||||
} |
||||
|
||||
return listener, db, cleanup |
||||
} |
||||
|
||||
// createPolicyConfigEvent creates a kind 12345 policy config event
|
||||
func createPolicyConfigEvent(t *testing.T, signer *p8k.Signer, policyJSON string) *event.E { |
||||
ev := event.New() |
||||
ev.CreatedAt = time.Now().Unix() |
||||
ev.Kind = kind.PolicyConfig.K |
||||
ev.Content = []byte(policyJSON) |
||||
ev.Tags = tag.NewS() |
||||
|
||||
if err := ev.Sign(signer); err != nil { |
||||
t.Fatalf("Failed to sign event: %v", err) |
||||
} |
||||
|
||||
return ev |
||||
} |
||||
|
||||
// TestHandlePolicyConfigUpdate_ValidAdmin tests policy update from valid admin
|
||||
func TestHandlePolicyConfigUpdate_ValidAdmin(t *testing.T) { |
||||
// Create admin signer
|
||||
adminSigner := p8k.MustNew() |
||||
if err := adminSigner.Generate(); err != nil { |
||||
t.Fatalf("Failed to generate admin keypair: %v", err) |
||||
} |
||||
adminHex := hex.Enc(adminSigner.Pub()) |
||||
|
||||
listener, _, cleanup := setupPolicyTestListener(t, adminHex) |
||||
defer cleanup() |
||||
|
||||
// Create valid policy update event
|
||||
newPolicyJSON := `{ |
||||
"default_policy": "deny", |
||||
"policy_admins": ["` + adminHex + `"], |
||||
"kind": {"whitelist": [1, 3, 7]} |
||||
}` |
||||
|
||||
ev := createPolicyConfigEvent(t, adminSigner, newPolicyJSON) |
||||
|
||||
// Handle the event
|
||||
err := listener.HandlePolicyConfigUpdate(ev) |
||||
if err != nil { |
||||
t.Errorf("Expected success but got error: %v", err) |
||||
} |
||||
|
||||
// Verify policy was updated
|
||||
if listener.policyManager.DefaultPolicy != "deny" { |
||||
t.Errorf("Policy was not updated, default_policy = %q, expected 'deny'", |
||||
listener.policyManager.DefaultPolicy) |
||||
} |
||||
} |
||||
|
||||
// TestHandlePolicyConfigUpdate_NonAdmin tests policy update rejection from non-admin
|
||||
func TestHandlePolicyConfigUpdate_NonAdmin(t *testing.T) { |
||||
// Create admin signer
|
||||
adminSigner := p8k.MustNew() |
||||
if err := adminSigner.Generate(); err != nil { |
||||
t.Fatalf("Failed to generate admin keypair: %v", err) |
||||
} |
||||
adminHex := hex.Enc(adminSigner.Pub()) |
||||
|
||||
// Create non-admin signer
|
||||
nonAdminSigner := p8k.MustNew() |
||||
if err := nonAdminSigner.Generate(); err != nil { |
||||
t.Fatalf("Failed to generate non-admin keypair: %v", err) |
||||
} |
||||
|
||||
listener, _, cleanup := setupPolicyTestListener(t, adminHex) |
||||
defer cleanup() |
||||
|
||||
// Create policy update event from non-admin
|
||||
newPolicyJSON := `{"default_policy": "deny"}` |
||||
ev := createPolicyConfigEvent(t, nonAdminSigner, newPolicyJSON) |
||||
|
||||
// Handle the event - should be rejected
|
||||
err := listener.HandlePolicyConfigUpdate(ev) |
||||
if err == nil { |
||||
t.Error("Expected error for non-admin update but got none") |
||||
} |
||||
|
||||
// Verify policy was NOT updated
|
||||
if listener.policyManager.DefaultPolicy != "allow" { |
||||
t.Error("Policy should not have been updated by non-admin") |
||||
} |
||||
} |
||||
|
||||
// TestHandlePolicyConfigUpdate_InvalidJSON tests rejection of invalid JSON
|
||||
func TestHandlePolicyConfigUpdate_InvalidJSON(t *testing.T) { |
||||
adminSigner := p8k.MustNew() |
||||
if err := adminSigner.Generate(); err != nil { |
||||
t.Fatalf("Failed to generate admin keypair: %v", err) |
||||
} |
||||
adminHex := hex.Enc(adminSigner.Pub()) |
||||
|
||||
listener, _, cleanup := setupPolicyTestListener(t, adminHex) |
||||
defer cleanup() |
||||
|
||||
// Create event with invalid JSON
|
||||
ev := createPolicyConfigEvent(t, adminSigner, `{"invalid json`) |
||||
|
||||
err := listener.HandlePolicyConfigUpdate(ev) |
||||
if err == nil { |
||||
t.Error("Expected error for invalid JSON but got none") |
||||
} |
||||
|
||||
// Policy should remain unchanged
|
||||
if listener.policyManager.DefaultPolicy != "allow" { |
||||
t.Error("Policy should not have been updated with invalid JSON") |
||||
} |
||||
} |
||||
|
||||
// TestHandlePolicyConfigUpdate_InvalidPubkey tests rejection of invalid admin pubkeys
|
||||
func TestHandlePolicyConfigUpdate_InvalidPubkey(t *testing.T) { |
||||
adminSigner := p8k.MustNew() |
||||
if err := adminSigner.Generate(); err != nil { |
||||
t.Fatalf("Failed to generate admin keypair: %v", err) |
||||
} |
||||
adminHex := hex.Enc(adminSigner.Pub()) |
||||
|
||||
listener, _, cleanup := setupPolicyTestListener(t, adminHex) |
||||
defer cleanup() |
||||
|
||||
// Try to update with invalid admin pubkey
|
||||
invalidPolicyJSON := `{ |
||||
"default_policy": "deny", |
||||
"policy_admins": ["not-a-valid-pubkey"] |
||||
}` |
||||
ev := createPolicyConfigEvent(t, adminSigner, invalidPolicyJSON) |
||||
|
||||
err := listener.HandlePolicyConfigUpdate(ev) |
||||
if err == nil { |
||||
t.Error("Expected error for invalid admin pubkey but got none") |
||||
} |
||||
|
||||
// Policy should remain unchanged
|
||||
if listener.policyManager.DefaultPolicy != "allow" { |
||||
t.Error("Policy should not have been updated with invalid admin pubkey") |
||||
} |
||||
} |
||||
|
||||
// TestHandlePolicyConfigUpdate_AdminCannotRemoveSelf tests that admin can update policy
|
||||
func TestHandlePolicyConfigUpdate_AdminCanUpdateAdminList(t *testing.T) { |
||||
adminSigner := p8k.MustNew() |
||||
if err := adminSigner.Generate(); err != nil { |
||||
t.Fatalf("Failed to generate admin keypair: %v", err) |
||||
} |
||||
adminHex := hex.Enc(adminSigner.Pub()) |
||||
|
||||
// Create second admin
|
||||
admin2Hex := "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210" |
||||
|
||||
listener, _, cleanup := setupPolicyTestListener(t, adminHex) |
||||
defer cleanup() |
||||
|
||||
// Update policy to add second admin
|
||||
newPolicyJSON := `{ |
||||
"default_policy": "allow", |
||||
"policy_admins": ["` + adminHex + `", "` + admin2Hex + `"] |
||||
}` |
||||
ev := createPolicyConfigEvent(t, adminSigner, newPolicyJSON) |
||||
|
||||
err := listener.HandlePolicyConfigUpdate(ev) |
||||
if err != nil { |
||||
t.Errorf("Expected success but got error: %v", err) |
||||
} |
||||
|
||||
// Verify both admins are now in the list
|
||||
admin2Bin, _ := hex.Dec(admin2Hex) |
||||
if !listener.policyManager.IsPolicyAdmin(admin2Bin) { |
||||
t.Error("Second admin should have been added to admin list") |
||||
} |
||||
} |
||||
|
||||
// TestHandlePolicyAdminFollowListUpdate tests follow list update from admin
|
||||
func TestHandlePolicyAdminFollowListUpdate(t *testing.T) { |
||||
adminSigner := p8k.MustNew() |
||||
if err := adminSigner.Generate(); err != nil { |
||||
t.Fatalf("Failed to generate admin keypair: %v", err) |
||||
} |
||||
adminHex := hex.Enc(adminSigner.Pub()) |
||||
|
||||
listener, db, cleanup := setupPolicyTestListener(t, adminHex) |
||||
defer cleanup() |
||||
|
||||
// Create a kind 3 follow list event from admin
|
||||
ev := event.New() |
||||
ev.CreatedAt = time.Now().Unix() |
||||
ev.Kind = kind.FollowList.K |
||||
ev.Content = []byte("") |
||||
ev.Tags = tag.NewS() |
||||
|
||||
// Add some follows
|
||||
follow1Hex := "1111111111111111111111111111111111111111111111111111111111111111" |
||||
follow2Hex := "2222222222222222222222222222222222222222222222222222222222222222" |
||||
ev.Tags.Append(tag.NewFromAny("p", follow1Hex)) |
||||
ev.Tags.Append(tag.NewFromAny("p", follow2Hex)) |
||||
|
||||
if err := ev.Sign(adminSigner); err != nil { |
||||
t.Fatalf("Failed to sign event: %v", err) |
||||
} |
||||
|
||||
// Save the event to database first
|
||||
if _, err := db.SaveEvent(listener.ctx, ev); err != nil { |
||||
t.Fatalf("Failed to save follow list event: %v", err) |
||||
} |
||||
|
||||
// Handle the follow list update
|
||||
err := listener.HandlePolicyAdminFollowListUpdate(ev) |
||||
if err != nil { |
||||
t.Errorf("Expected success but got error: %v", err) |
||||
} |
||||
|
||||
// Verify follows were added
|
||||
follow1Bin, _ := hex.Dec(follow1Hex) |
||||
follow2Bin, _ := hex.Dec(follow2Hex) |
||||
|
||||
if !listener.policyManager.IsPolicyFollow(follow1Bin) { |
||||
t.Error("Follow 1 should have been added to policy follows") |
||||
} |
||||
if !listener.policyManager.IsPolicyFollow(follow2Bin) { |
||||
t.Error("Follow 2 should have been added to policy follows") |
||||
} |
||||
} |
||||
|
||||
// TestIsPolicyAdminFollowListEvent tests detection of admin follow list events
|
||||
func TestIsPolicyAdminFollowListEvent(t *testing.T) { |
||||
adminSigner := p8k.MustNew() |
||||
if err := adminSigner.Generate(); err != nil { |
||||
t.Fatalf("Failed to generate admin keypair: %v", err) |
||||
} |
||||
adminHex := hex.Enc(adminSigner.Pub()) |
||||
|
||||
nonAdminSigner := p8k.MustNew() |
||||
if err := nonAdminSigner.Generate(); err != nil { |
||||
t.Fatalf("Failed to generate non-admin keypair: %v", err) |
||||
} |
||||
|
||||
listener, _, cleanup := setupPolicyTestListener(t, adminHex) |
||||
defer cleanup() |
||||
|
||||
// Test admin's kind 3 event
|
||||
adminFollowEv := event.New() |
||||
adminFollowEv.Kind = kind.FollowList.K |
||||
adminFollowEv.Tags = tag.NewS() |
||||
if err := adminFollowEv.Sign(adminSigner); err != nil { |
||||
t.Fatalf("Failed to sign event: %v", err) |
||||
} |
||||
|
||||
if !listener.IsPolicyAdminFollowListEvent(adminFollowEv) { |
||||
t.Error("Should detect admin's follow list event") |
||||
} |
||||
|
||||
// Test non-admin's kind 3 event
|
||||
nonAdminFollowEv := event.New() |
||||
nonAdminFollowEv.Kind = kind.FollowList.K |
||||
nonAdminFollowEv.Tags = tag.NewS() |
||||
if err := nonAdminFollowEv.Sign(nonAdminSigner); err != nil { |
||||
t.Fatalf("Failed to sign event: %v", err) |
||||
} |
||||
|
||||
if listener.IsPolicyAdminFollowListEvent(nonAdminFollowEv) { |
||||
t.Error("Should not detect non-admin's follow list event") |
||||
} |
||||
|
||||
// Test admin's non-kind-3 event
|
||||
adminOtherEv := event.New() |
||||
adminOtherEv.Kind = 1 // Kind 1, not follow list
|
||||
adminOtherEv.Tags = tag.NewS() |
||||
if err := adminOtherEv.Sign(adminSigner); err != nil { |
||||
t.Fatalf("Failed to sign event: %v", err) |
||||
} |
||||
|
||||
if listener.IsPolicyAdminFollowListEvent(adminOtherEv) { |
||||
t.Error("Should not detect admin's non-follow-list event") |
||||
} |
||||
} |
||||
|
||||
// TestIsPolicyConfigEvent tests detection of policy config events
|
||||
func TestIsPolicyConfigEvent(t *testing.T) { |
||||
signer := p8k.MustNew() |
||||
if err := signer.Generate(); err != nil { |
||||
t.Fatalf("Failed to generate keypair: %v", err) |
||||
} |
||||
|
||||
// Kind 12345 event
|
||||
policyEv := event.New() |
||||
policyEv.Kind = kind.PolicyConfig.K |
||||
policyEv.Tags = tag.NewS() |
||||
if err := policyEv.Sign(signer); err != nil { |
||||
t.Fatalf("Failed to sign event: %v", err) |
||||
} |
||||
|
||||
if !IsPolicyConfigEvent(policyEv) { |
||||
t.Error("Should detect kind 12345 as policy config event") |
||||
} |
||||
|
||||
// Non-policy event
|
||||
otherEv := event.New() |
||||
otherEv.Kind = 1 |
||||
otherEv.Tags = tag.NewS() |
||||
if err := otherEv.Sign(signer); err != nil { |
||||
t.Fatalf("Failed to sign event: %v", err) |
||||
} |
||||
|
||||
if IsPolicyConfigEvent(otherEv) { |
||||
t.Error("Should not detect kind 1 as policy config event") |
||||
} |
||||
} |
||||
|
||||
// TestMessageProcessingPauseDuringPolicyUpdate tests that message processing is paused
|
||||
func TestMessageProcessingPauseDuringPolicyUpdate(t *testing.T) { |
||||
adminSigner := p8k.MustNew() |
||||
if err := adminSigner.Generate(); err != nil { |
||||
t.Fatalf("Failed to generate admin keypair: %v", err) |
||||
} |
||||
adminHex := hex.Enc(adminSigner.Pub()) |
||||
|
||||
listener, _, cleanup := setupPolicyTestListener(t, adminHex) |
||||
defer cleanup() |
||||
|
||||
// Track if pause was called
|
||||
pauseCalled := false |
||||
resumeCalled := false |
||||
|
||||
// We can't easily mock the mutex, but we can verify the policy update succeeds
|
||||
// which implies the pause/resume cycle completed
|
||||
|
||||
newPolicyJSON := `{ |
||||
"default_policy": "deny", |
||||
"policy_admins": ["` + adminHex + `"] |
||||
}` |
||||
ev := createPolicyConfigEvent(t, adminSigner, newPolicyJSON) |
||||
|
||||
err := listener.HandlePolicyConfigUpdate(ev) |
||||
if err != nil { |
||||
t.Errorf("Policy update failed: %v", err) |
||||
} |
||||
|
||||
// If we got here without deadlock, the pause/resume worked
|
||||
_ = pauseCalled |
||||
_ = resumeCalled |
||||
|
||||
// Verify policy was actually updated
|
||||
if listener.policyManager.DefaultPolicy != "deny" { |
||||
t.Error("Policy should have been updated") |
||||
} |
||||
} |
||||
@ -0,0 +1,734 @@
@@ -0,0 +1,734 @@
|
||||
<script> |
||||
export let isLoggedIn = false; |
||||
export let userRole = ""; |
||||
export let isPolicyAdmin = false; |
||||
export let policyEnabled = false; |
||||
export let policyJson = ""; |
||||
export let isLoadingPolicy = false; |
||||
export let policyMessage = ""; |
||||
export let policyMessageType = ""; |
||||
export let validationErrors = []; |
||||
export let policyAdmins = []; |
||||
export let policyFollows = []; |
||||
|
||||
import { createEventDispatcher } from "svelte"; |
||||
const dispatch = createEventDispatcher(); |
||||
|
||||
// New admin input |
||||
let newAdminInput = ""; |
||||
|
||||
function loadPolicy() { |
||||
dispatch("loadPolicy"); |
||||
} |
||||
|
||||
function validatePolicy() { |
||||
dispatch("validatePolicy"); |
||||
} |
||||
|
||||
function savePolicy() { |
||||
dispatch("savePolicy"); |
||||
} |
||||
|
||||
function formatJson() { |
||||
dispatch("formatJson"); |
||||
} |
||||
|
||||
function openLoginModal() { |
||||
dispatch("openLoginModal"); |
||||
} |
||||
|
||||
function refreshFollows() { |
||||
dispatch("refreshFollows"); |
||||
} |
||||
|
||||
function addPolicyAdmin() { |
||||
if (newAdminInput.trim()) { |
||||
dispatch("addPolicyAdmin", newAdminInput.trim()); |
||||
newAdminInput = ""; |
||||
} |
||||
} |
||||
|
||||
function removePolicyAdmin(pubkey) { |
||||
dispatch("removePolicyAdmin", pubkey); |
||||
} |
||||
|
||||
// Parse admins from current policy JSON for display |
||||
$: { |
||||
try { |
||||
if (policyJson) { |
||||
const parsed = JSON.parse(policyJson); |
||||
policyAdmins = parsed.policy_admins || []; |
||||
} |
||||
} catch (e) { |
||||
// Ignore parse errors |
||||
} |
||||
} |
||||
|
||||
// Pretty-print example policy for reference |
||||
const examplePolicy = `{ |
||||
"kind": { |
||||
"whitelist": [0, 1, 3, 6, 7, 10002], |
||||
"blacklist": [] |
||||
}, |
||||
"global": { |
||||
"description": "Global rules applied to all events", |
||||
"size_limit": 65536, |
||||
"max_age_of_event": 86400, |
||||
"max_age_event_in_future": 300 |
||||
}, |
||||
"rules": { |
||||
"1": { |
||||
"description": "Kind 1 (short text notes)", |
||||
"content_limit": 8192, |
||||
"write_allow_follows": true |
||||
}, |
||||
"30023": { |
||||
"description": "Long-form articles", |
||||
"content_limit": 100000, |
||||
"tag_validation": { |
||||
"d": "^[a-z0-9-]{1,64}$", |
||||
"t": "^[a-z0-9-]{1,32}$" |
||||
} |
||||
} |
||||
}, |
||||
"default_policy": "allow", |
||||
"policy_admins": ["<your-hex-pubkey>"], |
||||
"policy_follow_whitelist_enabled": true |
||||
}`; |
||||
</script> |
||||
|
||||
<div class="policy-view"> |
||||
<h2>Policy Configuration</h2> |
||||
{#if isLoggedIn && (userRole === "owner" || isPolicyAdmin)} |
||||
<div class="policy-section"> |
||||
<div class="policy-header"> |
||||
<h3>Policy Editor</h3> |
||||
<div class="policy-status"> |
||||
<span class="status-badge" class:enabled={policyEnabled}> |
||||
{policyEnabled ? "Policy Enabled" : "Policy Disabled"} |
||||
</span> |
||||
{#if isPolicyAdmin} |
||||
<span class="admin-badge">Policy Admin</span> |
||||
{/if} |
||||
</div> |
||||
</div> |
||||
|
||||
<div class="policy-info"> |
||||
<p> |
||||
Edit the policy JSON below and click "Save & Publish" to update the relay's policy configuration. |
||||
Changes are applied immediately after validation. |
||||
</p> |
||||
<p class="info-note"> |
||||
Policy updates are published as kind 12345 events and require policy admin permissions. |
||||
</p> |
||||
</div> |
||||
|
||||
<div class="editor-container"> |
||||
<textarea |
||||
class="policy-editor" |
||||
bind:value={policyJson} |
||||
placeholder="Loading policy configuration..." |
||||
disabled={isLoadingPolicy} |
||||
spellcheck="false" |
||||
></textarea> |
||||
</div> |
||||
|
||||
{#if validationErrors.length > 0} |
||||
<div class="validation-errors"> |
||||
<h4>Validation Errors:</h4> |
||||
<ul> |
||||
{#each validationErrors as error} |
||||
<li>{error}</li> |
||||
{/each} |
||||
</ul> |
||||
</div> |
||||
{/if} |
||||
|
||||
<div class="policy-actions"> |
||||
<button |
||||
class="policy-btn load-btn" |
||||
on:click={loadPolicy} |
||||
disabled={isLoadingPolicy} |
||||
> |
||||
Load Current |
||||
</button> |
||||
<button |
||||
class="policy-btn format-btn" |
||||
on:click={formatJson} |
||||
disabled={isLoadingPolicy} |
||||
> |
||||
Format JSON |
||||
</button> |
||||
<button |
||||
class="policy-btn validate-btn" |
||||
on:click={validatePolicy} |
||||
disabled={isLoadingPolicy} |
||||
> |
||||
Validate |
||||
</button> |
||||
<button |
||||
class="policy-btn save-btn" |
||||
on:click={savePolicy} |
||||
disabled={isLoadingPolicy} |
||||
> |
||||
Save & Publish |
||||
</button> |
||||
</div> |
||||
|
||||
{#if policyMessage} |
||||
<div |
||||
class="policy-message" |
||||
class:error={policyMessageType === "error"} |
||||
class:success={policyMessageType === "success"} |
||||
> |
||||
{policyMessage} |
||||
</div> |
||||
{/if} |
||||
</div> |
||||
|
||||
<!-- Policy Admins Section --> |
||||
<div class="policy-section"> |
||||
<h3>Policy Administrators</h3> |
||||
<div class="policy-info"> |
||||
<p> |
||||
Policy admins can update the relay's policy configuration via kind 12345 events. |
||||
Their follows get whitelisted if <code>policy_follow_whitelist_enabled</code> is true in the policy. |
||||
</p> |
||||
<p class="info-note"> |
||||
<strong>Note:</strong> Policy admins are separate from relay admins (ORLY_ADMINS). |
||||
Changes here update the JSON editor - click "Save & Publish" to apply. |
||||
</p> |
||||
</div> |
||||
|
||||
<div class="admin-list"> |
||||
{#if policyAdmins.length === 0} |
||||
<p class="no-items">No policy admins configured</p> |
||||
{:else} |
||||
{#each policyAdmins as admin} |
||||
<div class="admin-item"> |
||||
<span class="admin-pubkey" title={admin}>{admin.substring(0, 16)}...{admin.substring(admin.length - 8)}</span> |
||||
<button |
||||
class="remove-btn" |
||||
on:click={() => removePolicyAdmin(admin)} |
||||
disabled={isLoadingPolicy} |
||||
title="Remove admin" |
||||
> |
||||
✕ |
||||
</button> |
||||
</div> |
||||
{/each} |
||||
{/if} |
||||
</div> |
||||
|
||||
<div class="add-admin"> |
||||
<input |
||||
type="text" |
||||
placeholder="npub or hex pubkey" |
||||
bind:value={newAdminInput} |
||||
disabled={isLoadingPolicy} |
||||
on:keydown={(e) => e.key === "Enter" && addPolicyAdmin()} |
||||
/> |
||||
<button |
||||
class="policy-btn add-btn" |
||||
on:click={addPolicyAdmin} |
||||
disabled={isLoadingPolicy || !newAdminInput.trim()} |
||||
> |
||||
+ Add Admin |
||||
</button> |
||||
</div> |
||||
</div> |
||||
|
||||
<!-- Policy Follow Whitelist Section --> |
||||
<div class="policy-section"> |
||||
<h3>Policy Follow Whitelist</h3> |
||||
<div class="policy-info"> |
||||
<p> |
||||
Pubkeys followed by policy admins (kind 3 events). |
||||
These get automatic read+write access when rules have <code>write_allow_follows: true</code>. |
||||
</p> |
||||
</div> |
||||
|
||||
<div class="follows-header"> |
||||
<span class="follows-count">{policyFollows.length} pubkey(s) in whitelist</span> |
||||
<button |
||||
class="policy-btn refresh-btn" |
||||
on:click={refreshFollows} |
||||
disabled={isLoadingPolicy} |
||||
> |
||||
🔄 Refresh Follows |
||||
</button> |
||||
</div> |
||||
|
||||
<div class="follows-list"> |
||||
{#if policyFollows.length === 0} |
||||
<p class="no-items">No follows loaded. Click "Refresh Follows" to load from database.</p> |
||||
{:else} |
||||
<div class="follows-grid"> |
||||
{#each policyFollows as follow} |
||||
<div class="follow-item" title={follow}> |
||||
{follow.substring(0, 12)}...{follow.substring(follow.length - 6)} |
||||
</div> |
||||
{/each} |
||||
</div> |
||||
{/if} |
||||
</div> |
||||
</div> |
||||
|
||||
<div class="policy-section"> |
||||
<h3>Policy Reference</h3> |
||||
<div class="reference-content"> |
||||
<h4>Structure Overview</h4> |
||||
<ul class="field-list"> |
||||
<li><code>kind.whitelist</code> - Only allow these event kinds (takes precedence)</li> |
||||
<li><code>kind.blacklist</code> - Deny these event kinds (if no whitelist)</li> |
||||
<li><code>global</code> - Rules applied to all events</li> |
||||
<li><code>rules</code> - Per-kind rules (keyed by kind number as string)</li> |
||||
<li><code>default_policy</code> - "allow" or "deny" when no rules match</li> |
||||
<li><code>policy_admins</code> - Hex pubkeys that can update policy</li> |
||||
<li><code>policy_follow_whitelist_enabled</code> - Enable follow-based access</li> |
||||
</ul> |
||||
|
||||
<h4>Rule Fields</h4> |
||||
<ul class="field-list"> |
||||
<li><code>description</code> - Human-readable rule description</li> |
||||
<li><code>write_allow</code> / <code>write_deny</code> - Pubkey lists for write access</li> |
||||
<li><code>read_allow</code> / <code>read_deny</code> - Pubkey lists for read access</li> |
||||
<li><code>write_allow_follows</code> - Grant access to policy admin follows</li> |
||||
<li><code>size_limit</code> - Max total event size in bytes</li> |
||||
<li><code>content_limit</code> - Max content field size in bytes</li> |
||||
<li><code>max_expiry</code> - Max expiry offset in seconds</li> |
||||
<li><code>max_age_of_event</code> - Max age of created_at in seconds</li> |
||||
<li><code>max_age_event_in_future</code> - Max future offset in seconds</li> |
||||
<li><code>must_have_tags</code> - Required tag letters (e.g., ["d", "t"])</li> |
||||
<li><code>tag_validation</code> - Regex patterns for tag values</li> |
||||
<li><code>script</code> - Path to external validation script</li> |
||||
</ul> |
||||
|
||||
<h4>Example Policy</h4> |
||||
<pre class="example-json">{examplePolicy}</pre> |
||||
</div> |
||||
</div> |
||||
{:else if isLoggedIn} |
||||
<div class="permission-denied"> |
||||
<p>Policy configuration requires owner or policy admin permissions.</p> |
||||
<p> |
||||
To become a policy admin, ask an existing policy admin to add your pubkey |
||||
to the <code>policy_admins</code> list. |
||||
</p> |
||||
<p> |
||||
Current user role: <strong>{userRole || "none"}</strong> |
||||
</p> |
||||
</div> |
||||
{:else} |
||||
<div class="login-prompt"> |
||||
<p>Please log in to access policy configuration.</p> |
||||
<button class="login-btn" on:click={openLoginModal}>Log In</button> |
||||
</div> |
||||
{/if} |
||||
</div> |
||||
|
||||
<style> |
||||
.policy-view { |
||||
width: 100%; |
||||
max-width: 1200px; |
||||
margin: 0; |
||||
padding: 20px; |
||||
background: var(--header-bg); |
||||
color: var(--text-color); |
||||
border-radius: 8px; |
||||
} |
||||
|
||||
.policy-view h2 { |
||||
margin: 0 0 1.5rem 0; |
||||
color: var(--text-color); |
||||
font-size: 1.8rem; |
||||
font-weight: 600; |
||||
} |
||||
|
||||
.policy-section { |
||||
background-color: var(--card-bg); |
||||
border-radius: 8px; |
||||
padding: 1.5em; |
||||
margin-bottom: 1.5rem; |
||||
border: 1px solid var(--border-color); |
||||
} |
||||
|
||||
.policy-header { |
||||
display: flex; |
||||
justify-content: space-between; |
||||
align-items: center; |
||||
margin-bottom: 1rem; |
||||
} |
||||
|
||||
.policy-header h3 { |
||||
margin: 0; |
||||
color: var(--text-color); |
||||
font-size: 1.2rem; |
||||
font-weight: 600; |
||||
} |
||||
|
||||
.policy-status { |
||||
display: flex; |
||||
gap: 0.5rem; |
||||
} |
||||
|
||||
.status-badge { |
||||
padding: 0.25em 0.75em; |
||||
border-radius: 1rem; |
||||
font-size: 0.8em; |
||||
font-weight: 600; |
||||
background: var(--danger); |
||||
color: white; |
||||
} |
||||
|
||||
.status-badge.enabled { |
||||
background: var(--success); |
||||
} |
||||
|
||||
.admin-badge { |
||||
padding: 0.25em 0.75em; |
||||
border-radius: 1rem; |
||||
font-size: 0.8em; |
||||
font-weight: 600; |
||||
background: var(--primary); |
||||
color: white; |
||||
} |
||||
|
||||
.policy-info { |
||||
margin-bottom: 1rem; |
||||
padding: 1rem; |
||||
background: var(--bg-color); |
||||
border-radius: 4px; |
||||
border: 1px solid var(--border-color); |
||||
} |
||||
|
||||
.policy-info p { |
||||
margin: 0 0 0.5rem 0; |
||||
line-height: 1.5; |
||||
} |
||||
|
||||
.policy-info p:last-child { |
||||
margin-bottom: 0; |
||||
} |
||||
|
||||
.info-note { |
||||
font-size: 0.9em; |
||||
opacity: 0.8; |
||||
} |
||||
|
||||
.editor-container { |
||||
margin-bottom: 1rem; |
||||
} |
||||
|
||||
.policy-editor { |
||||
width: 100%; |
||||
height: 400px; |
||||
padding: 1em; |
||||
border: 1px solid var(--border-color); |
||||
border-radius: 4px; |
||||
background: var(--input-bg); |
||||
color: var(--input-text-color); |
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; |
||||
font-size: 0.85em; |
||||
line-height: 1.5; |
||||
resize: vertical; |
||||
tab-size: 2; |
||||
} |
||||
|
||||
.policy-editor:disabled { |
||||
opacity: 0.6; |
||||
cursor: not-allowed; |
||||
} |
||||
|
||||
.validation-errors { |
||||
margin-bottom: 1rem; |
||||
padding: 1rem; |
||||
background: var(--danger-bg, rgba(220, 53, 69, 0.1)); |
||||
border: 1px solid var(--danger); |
||||
border-radius: 4px; |
||||
} |
||||
|
||||
.validation-errors h4 { |
||||
margin: 0 0 0.5rem 0; |
||||
color: var(--danger); |
||||
font-size: 1rem; |
||||
} |
||||
|
||||
.validation-errors ul { |
||||
margin: 0; |
||||
padding-left: 1.5rem; |
||||
} |
||||
|
||||
.validation-errors li { |
||||
color: var(--danger); |
||||
margin-bottom: 0.25rem; |
||||
} |
||||
|
||||
.policy-actions { |
||||
display: flex; |
||||
gap: 0.5rem; |
||||
flex-wrap: wrap; |
||||
} |
||||
|
||||
.policy-btn { |
||||
background: var(--primary); |
||||
color: white; |
||||
border: none; |
||||
padding: 0.5em 1em; |
||||
border-radius: 4px; |
||||
cursor: pointer; |
||||
font-size: 0.9em; |
||||
transition: background-color 0.2s, filter 0.2s; |
||||
display: flex; |
||||
align-items: center; |
||||
gap: 0.25em; |
||||
} |
||||
|
||||
.policy-btn:hover:not(:disabled) { |
||||
filter: brightness(1.1); |
||||
} |
||||
|
||||
.policy-btn:disabled { |
||||
background: var(--secondary); |
||||
cursor: not-allowed; |
||||
} |
||||
|
||||
.load-btn { |
||||
background: var(--info); |
||||
} |
||||
|
||||
.format-btn { |
||||
background: var(--secondary); |
||||
} |
||||
|
||||
.validate-btn { |
||||
background: var(--warning); |
||||
} |
||||
|
||||
.save-btn { |
||||
background: var(--success); |
||||
} |
||||
|
||||
.policy-message { |
||||
padding: 1rem; |
||||
border-radius: 4px; |
||||
margin-top: 1rem; |
||||
background: var(--info-bg, rgba(23, 162, 184, 0.1)); |
||||
color: var(--info-text, var(--text-color)); |
||||
border: 1px solid var(--info); |
||||
} |
||||
|
||||
.policy-message.error { |
||||
background: var(--danger-bg, rgba(220, 53, 69, 0.1)); |
||||
color: var(--danger-text, var(--danger)); |
||||
border: 1px solid var(--danger); |
||||
} |
||||
|
||||
.policy-message.success { |
||||
background: var(--success-bg, rgba(40, 167, 69, 0.1)); |
||||
color: var(--success-text, var(--success)); |
||||
border: 1px solid var(--success); |
||||
} |
||||
|
||||
.reference-content h4 { |
||||
margin: 1rem 0 0.5rem 0; |
||||
color: var(--text-color); |
||||
font-size: 1rem; |
||||
} |
||||
|
||||
.reference-content h4:first-child { |
||||
margin-top: 0; |
||||
} |
||||
|
||||
.field-list { |
||||
margin: 0 0 1rem 0; |
||||
padding-left: 1.5rem; |
||||
} |
||||
|
||||
.field-list li { |
||||
margin-bottom: 0.25rem; |
||||
line-height: 1.5; |
||||
} |
||||
|
||||
.field-list code { |
||||
background: var(--code-bg, rgba(0, 0, 0, 0.1)); |
||||
padding: 0.1em 0.4em; |
||||
border-radius: 3px; |
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; |
||||
font-size: 0.9em; |
||||
} |
||||
|
||||
.example-json { |
||||
background: var(--input-bg); |
||||
color: var(--input-text-color); |
||||
padding: 1rem; |
||||
border-radius: 4px; |
||||
border: 1px solid var(--border-color); |
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; |
||||
font-size: 0.8em; |
||||
line-height: 1.4; |
||||
overflow-x: auto; |
||||
white-space: pre; |
||||
margin: 0; |
||||
} |
||||
|
||||
.permission-denied, |
||||
.login-prompt { |
||||
text-align: center; |
||||
padding: 2em; |
||||
background-color: var(--card-bg); |
||||
border-radius: 8px; |
||||
border: 1px solid var(--border-color); |
||||
color: var(--text-color); |
||||
} |
||||
|
||||
.permission-denied p, |
||||
.login-prompt p { |
||||
margin: 0 0 1rem 0; |
||||
line-height: 1.4; |
||||
} |
||||
|
||||
.permission-denied code { |
||||
background: var(--code-bg, rgba(0, 0, 0, 0.1)); |
||||
padding: 0.2em 0.4em; |
||||
border-radius: 0.25rem; |
||||
font-family: monospace; |
||||
font-size: 0.9em; |
||||
} |
||||
|
||||
.login-btn { |
||||
background: var(--primary); |
||||
color: white; |
||||
border: none; |
||||
padding: 0.75em 1.5em; |
||||
border-radius: 4px; |
||||
cursor: pointer; |
||||
font-weight: bold; |
||||
font-size: 0.9em; |
||||
transition: background-color 0.2s; |
||||
} |
||||
|
||||
.login-btn:hover { |
||||
filter: brightness(1.1); |
||||
} |
||||
|
||||
/* Admin list styles */ |
||||
.admin-list { |
||||
margin-bottom: 1rem; |
||||
} |
||||
|
||||
.admin-item { |
||||
display: flex; |
||||
justify-content: space-between; |
||||
align-items: center; |
||||
padding: 0.5em 0.75em; |
||||
background: var(--bg-color); |
||||
border: 1px solid var(--border-color); |
||||
border-radius: 4px; |
||||
margin-bottom: 0.5rem; |
||||
} |
||||
|
||||
.admin-pubkey { |
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; |
||||
font-size: 0.85em; |
||||
color: var(--text-color); |
||||
} |
||||
|
||||
.remove-btn { |
||||
background: var(--danger); |
||||
color: white; |
||||
border: none; |
||||
width: 24px; |
||||
height: 24px; |
||||
border-radius: 50%; |
||||
cursor: pointer; |
||||
font-size: 0.8em; |
||||
display: flex; |
||||
align-items: center; |
||||
justify-content: center; |
||||
transition: filter 0.2s; |
||||
} |
||||
|
||||
.remove-btn:hover:not(:disabled) { |
||||
filter: brightness(0.9); |
||||
} |
||||
|
||||
.remove-btn:disabled { |
||||
opacity: 0.5; |
||||
cursor: not-allowed; |
||||
} |
||||
|
||||
.add-admin { |
||||
display: flex; |
||||
gap: 0.5rem; |
||||
} |
||||
|
||||
.add-admin input { |
||||
flex: 1; |
||||
padding: 0.5em 0.75em; |
||||
border: 1px solid var(--border-color); |
||||
border-radius: 4px; |
||||
background: var(--input-bg); |
||||
color: var(--input-text-color); |
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; |
||||
font-size: 0.85em; |
||||
} |
||||
|
||||
.add-btn { |
||||
background: var(--success); |
||||
white-space: nowrap; |
||||
} |
||||
|
||||
.no-items { |
||||
color: var(--text-color); |
||||
opacity: 0.6; |
||||
font-style: italic; |
||||
padding: 1rem; |
||||
text-align: center; |
||||
} |
||||
|
||||
/* Follow list styles */ |
||||
.follows-header { |
||||
display: flex; |
||||
justify-content: space-between; |
||||
align-items: center; |
||||
margin-bottom: 1rem; |
||||
} |
||||
|
||||
.follows-count { |
||||
font-weight: 600; |
||||
color: var(--text-color); |
||||
} |
||||
|
||||
.refresh-btn { |
||||
background: var(--info); |
||||
} |
||||
|
||||
.follows-list { |
||||
max-height: 300px; |
||||
overflow-y: auto; |
||||
border: 1px solid var(--border-color); |
||||
border-radius: 4px; |
||||
background: var(--bg-color); |
||||
} |
||||
|
||||
.follows-grid { |
||||
display: grid; |
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); |
||||
gap: 0.5rem; |
||||
padding: 0.75rem; |
||||
} |
||||
|
||||
.follow-item { |
||||
padding: 0.4em 0.6em; |
||||
background: var(--card-bg); |
||||
border: 1px solid var(--border-color); |
||||
border-radius: 4px; |
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; |
||||
font-size: 0.75em; |
||||
color: var(--text-color); |
||||
text-overflow: ellipsis; |
||||
overflow: hidden; |
||||
white-space: nowrap; |
||||
} |
||||
</style> |
||||
@ -0,0 +1,339 @@
@@ -0,0 +1,339 @@
|
||||
package policy |
||||
|
||||
import ( |
||||
"context" |
||||
"os" |
||||
"path/filepath" |
||||
"testing" |
||||
"time" |
||||
|
||||
"github.com/adrg/xdg" |
||||
"git.mleku.dev/mleku/nostr/encoders/hex" |
||||
) |
||||
|
||||
// setupTestPolicy creates a policy manager with a temporary config file.
|
||||
// Returns the policy and a cleanup function.
|
||||
func setupTestPolicy(t *testing.T, appName string) (*P, func()) { |
||||
t.Helper() |
||||
|
||||
// Create config directory at XDG path
|
||||
configDir := filepath.Join(xdg.ConfigHome, appName) |
||||
if err := os.MkdirAll(configDir, 0755); err != nil { |
||||
t.Fatalf("Failed to create config dir: %v", err) |
||||
} |
||||
|
||||
// Create default policy.json
|
||||
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 |
||||
} |
||||
|
||||
// TestIsPolicyAdmin tests the IsPolicyAdmin method
|
||||
func TestIsPolicyAdmin(t *testing.T) { |
||||
policy, cleanup := setupTestPolicy(t, "test-policy-admin") |
||||
defer cleanup() |
||||
|
||||
// Set up policy with admins
|
||||
admin1Hex := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" |
||||
admin2Hex := "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210" |
||||
nonAdminHex := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" |
||||
|
||||
policyJSON := []byte(`{ |
||||
"policy_admins": [ |
||||
"` + admin1Hex + `", |
||||
"` + admin2Hex + `" |
||||
] |
||||
}`) |
||||
|
||||
tmpDir := t.TempDir() |
||||
if err := policy.Reload(policyJSON, tmpDir+"/policy.json"); err != nil { |
||||
t.Fatalf("Failed to reload policy: %v", err) |
||||
} |
||||
|
||||
// Convert hex to bytes for testing
|
||||
admin1Bin, _ := hex.Dec(admin1Hex) |
||||
admin2Bin, _ := hex.Dec(admin2Hex) |
||||
nonAdminBin, _ := hex.Dec(nonAdminHex) |
||||
|
||||
tests := []struct { |
||||
name string |
||||
pubkey []byte |
||||
expected bool |
||||
}{ |
||||
{ |
||||
name: "first admin is recognized", |
||||
pubkey: admin1Bin, |
||||
expected: true, |
||||
}, |
||||
{ |
||||
name: "second admin is recognized", |
||||
pubkey: admin2Bin, |
||||
expected: true, |
||||
}, |
||||
{ |
||||
name: "non-admin is not recognized", |
||||
pubkey: nonAdminBin, |
||||
expected: false, |
||||
}, |
||||
{ |
||||
name: "nil pubkey returns false", |
||||
pubkey: nil, |
||||
expected: false, |
||||
}, |
||||
{ |
||||
name: "empty pubkey returns false", |
||||
pubkey: []byte{}, |
||||
expected: false, |
||||
}, |
||||
{ |
||||
name: "wrong length pubkey returns false", |
||||
pubkey: []byte{0x01, 0x02, 0x03}, |
||||
expected: false, |
||||
}, |
||||
} |
||||
|
||||
for _, tt := range tests { |
||||
t.Run(tt.name, func(t *testing.T) { |
||||
result := policy.IsPolicyAdmin(tt.pubkey) |
||||
if result != tt.expected { |
||||
t.Errorf("IsPolicyAdmin() = %v, expected %v", result, tt.expected) |
||||
} |
||||
}) |
||||
} |
||||
} |
||||
|
||||
// TestIsPolicyFollow tests the IsPolicyFollow method
|
||||
func TestIsPolicyFollow(t *testing.T) { |
||||
policy, cleanup := setupTestPolicy(t, "test-policy-follow") |
||||
defer cleanup() |
||||
|
||||
// Set up some follows
|
||||
follow1Hex := "1111111111111111111111111111111111111111111111111111111111111111" |
||||
follow2Hex := "2222222222222222222222222222222222222222222222222222222222222222" |
||||
nonFollowHex := "3333333333333333333333333333333333333333333333333333333333333333" |
||||
|
||||
follow1Bin, _ := hex.Dec(follow1Hex) |
||||
follow2Bin, _ := hex.Dec(follow2Hex) |
||||
nonFollowBin, _ := hex.Dec(nonFollowHex) |
||||
|
||||
// Update policy follows directly
|
||||
policy.UpdatePolicyFollows([][]byte{follow1Bin, follow2Bin}) |
||||
|
||||
tests := []struct { |
||||
name string |
||||
pubkey []byte |
||||
expected bool |
||||
}{ |
||||
{ |
||||
name: "first follow is recognized", |
||||
pubkey: follow1Bin, |
||||
expected: true, |
||||
}, |
||||
{ |
||||
name: "second follow is recognized", |
||||
pubkey: follow2Bin, |
||||
expected: true, |
||||
}, |
||||
{ |
||||
name: "non-follow is not recognized", |
||||
pubkey: nonFollowBin, |
||||
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.IsPolicyFollow(tt.pubkey) |
||||
if result != tt.expected { |
||||
t.Errorf("IsPolicyFollow() = %v, expected %v", result, tt.expected) |
||||
} |
||||
}) |
||||
} |
||||
} |
||||
|
||||
// TestUpdatePolicyFollows tests the UpdatePolicyFollows method
|
||||
func TestUpdatePolicyFollows(t *testing.T) { |
||||
policy, cleanup := setupTestPolicy(t, "test-update-follows") |
||||
defer cleanup() |
||||
|
||||
// Initially no follows
|
||||
testPubkey, _ := hex.Dec("1111111111111111111111111111111111111111111111111111111111111111") |
||||
if policy.IsPolicyFollow(testPubkey) { |
||||
t.Error("Expected no follows initially") |
||||
} |
||||
|
||||
// Add follows
|
||||
follows := [][]byte{testPubkey} |
||||
policy.UpdatePolicyFollows(follows) |
||||
|
||||
if !policy.IsPolicyFollow(testPubkey) { |
||||
t.Error("Expected pubkey to be a follow after update") |
||||
} |
||||
|
||||
// Update with empty list
|
||||
policy.UpdatePolicyFollows([][]byte{}) |
||||
if policy.IsPolicyFollow(testPubkey) { |
||||
t.Error("Expected pubkey to not be a follow after clearing") |
||||
} |
||||
|
||||
// Update with nil
|
||||
policy.UpdatePolicyFollows(nil) |
||||
if policy.IsPolicyFollow(testPubkey) { |
||||
t.Error("Expected pubkey to not be a follow after nil update") |
||||
} |
||||
} |
||||
|
||||
// TestIsPolicyFollowWhitelistEnabled tests the IsPolicyFollowWhitelistEnabled method
|
||||
func TestIsPolicyFollowWhitelistEnabled(t *testing.T) { |
||||
policy, cleanup := setupTestPolicy(t, "test-whitelist-enabled") |
||||
defer cleanup() |
||||
|
||||
tmpDir := t.TempDir() |
||||
|
||||
// Test with disabled
|
||||
policyJSON := []byte(`{"policy_follow_whitelist_enabled": false}`) |
||||
if err := policy.Reload(policyJSON, tmpDir+"/policy.json"); err != nil { |
||||
t.Fatalf("Failed to reload policy: %v", err) |
||||
} |
||||
|
||||
if policy.IsPolicyFollowWhitelistEnabled() { |
||||
t.Error("Expected follow whitelist to be disabled") |
||||
} |
||||
|
||||
// Test with enabled
|
||||
policyJSON = []byte(`{"policy_follow_whitelist_enabled": true}`) |
||||
if err := policy.Reload(policyJSON, tmpDir+"/policy.json"); err != nil { |
||||
t.Fatalf("Failed to reload policy: %v", err) |
||||
} |
||||
|
||||
if !policy.IsPolicyFollowWhitelistEnabled() { |
||||
t.Error("Expected follow whitelist to be enabled") |
||||
} |
||||
} |
||||
|
||||
// TestGetPolicyAdminsBin tests the GetPolicyAdminsBin method
|
||||
func TestGetPolicyAdminsBin(t *testing.T) { |
||||
policy, cleanup := setupTestPolicy(t, "test-get-admins-bin") |
||||
defer cleanup() |
||||
|
||||
admin1Hex := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" |
||||
admin2Hex := "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210" |
||||
|
||||
policyJSON := []byte(`{ |
||||
"policy_admins": ["` + admin1Hex + `", "` + admin2Hex + `"] |
||||
}`) |
||||
|
||||
tmpDir := t.TempDir() |
||||
if err := policy.Reload(policyJSON, tmpDir+"/policy.json"); err != nil { |
||||
t.Fatalf("Failed to reload policy: %v", err) |
||||
} |
||||
|
||||
admins := policy.GetPolicyAdminsBin() |
||||
if len(admins) != 2 { |
||||
t.Errorf("Expected 2 admins, got %d", len(admins)) |
||||
} |
||||
|
||||
// Verify it's a copy (modification shouldn't affect original)
|
||||
if len(admins) > 0 { |
||||
admins[0][0] = 0xFF |
||||
originalAdmins := policy.GetPolicyAdminsBin() |
||||
if originalAdmins[0][0] == 0xFF { |
||||
t.Error("GetPolicyAdminsBin should return a copy, not the original slice") |
||||
} |
||||
} |
||||
} |
||||
|
||||
// TestFollowListConcurrency tests concurrent access to follow list
|
||||
func TestFollowListConcurrency(t *testing.T) { |
||||
policy, cleanup := setupTestPolicy(t, "test-concurrency") |
||||
defer cleanup() |
||||
|
||||
testPubkey, _ := hex.Dec("1111111111111111111111111111111111111111111111111111111111111111") |
||||
|
||||
// Run concurrent reads and writes
|
||||
done := make(chan bool) |
||||
for i := 0; i < 10; i++ { |
||||
go func() { |
||||
for j := 0; j < 100; j++ { |
||||
policy.UpdatePolicyFollows([][]byte{testPubkey}) |
||||
_ = policy.IsPolicyFollow(testPubkey) |
||||
_ = policy.IsPolicyAdmin(testPubkey) |
||||
} |
||||
done <- true |
||||
}() |
||||
} |
||||
|
||||
// Wait for all goroutines
|
||||
for i := 0; i < 10; i++ { |
||||
<-done |
||||
} |
||||
} |
||||
|
||||
// TestPolicyAdminAndFollowInteraction tests the interaction between admin and follow checks
|
||||
func TestPolicyAdminAndFollowInteraction(t *testing.T) { |
||||
policy, cleanup := setupTestPolicy(t, "test-admin-follow-interaction") |
||||
defer cleanup() |
||||
|
||||
// An admin who is also followed
|
||||
adminHex := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" |
||||
adminBin, _ := hex.Dec(adminHex) |
||||
|
||||
policyJSON := []byte(`{ |
||||
"policy_admins": ["` + adminHex + `"], |
||||
"policy_follow_whitelist_enabled": true |
||||
}`) |
||||
|
||||
tmpDir := t.TempDir() |
||||
if err := policy.Reload(policyJSON, tmpDir+"/policy.json"); err != nil { |
||||
t.Fatalf("Failed to reload policy: %v", err) |
||||
} |
||||
|
||||
// Admin should be recognized as admin
|
||||
if !policy.IsPolicyAdmin(adminBin) { |
||||
t.Error("Expected admin to be recognized as admin") |
||||
} |
||||
|
||||
// Admin is not automatically a follow
|
||||
if policy.IsPolicyFollow(adminBin) { |
||||
t.Error("Admin should not automatically be a follow") |
||||
} |
||||
|
||||
// Now add admin as a follow
|
||||
policy.UpdatePolicyFollows([][]byte{adminBin}) |
||||
|
||||
// Should be both admin and follow
|
||||
if !policy.IsPolicyAdmin(adminBin) { |
||||
t.Error("Expected admin to still be recognized as admin") |
||||
} |
||||
if !policy.IsPolicyFollow(adminBin) { |
||||
t.Error("Expected admin to now be recognized as follow") |
||||
} |
||||
} |
||||
@ -0,0 +1,403 @@
@@ -0,0 +1,403 @@
|
||||
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, |
||||
}, |
||||
} |
||||
|
||||
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)) |
||||
} |
||||
@ -0,0 +1,481 @@
@@ -0,0 +1,481 @@
|
||||
package policy |
||||
|
||||
import ( |
||||
"context" |
||||
"os" |
||||
"path/filepath" |
||||
"testing" |
||||
"time" |
||||
|
||||
"github.com/adrg/xdg" |
||||
"git.mleku.dev/mleku/nostr/encoders/event" |
||||
"git.mleku.dev/mleku/nostr/encoders/tag" |
||||
"git.mleku.dev/mleku/nostr/interfaces/signer/p8k" |
||||
"lol.mleku.dev/chk" |
||||
) |
||||
|
||||
// setupTagValidationTestPolicy creates a policy manager with a temporary config file for tag validation tests.
|
||||
func setupTagValidationTestPolicy(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 |
||||
} |
||||
|
||||
// createSignedTestEvent creates a signed event for testing
|
||||
func createSignedTestEvent(t *testing.T, kind uint16, content string) (*event.E, *p8k.Signer) { |
||||
signer := p8k.MustNew() |
||||
if err := signer.Generate(); chk.E(err) { |
||||
t.Fatalf("Failed to generate keypair: %v", err) |
||||
} |
||||
|
||||
ev := event.New() |
||||
ev.CreatedAt = time.Now().Unix() |
||||
ev.Kind = kind |
||||
ev.Content = []byte(content) |
||||
ev.Tags = tag.NewS() |
||||
|
||||
if err := ev.Sign(signer); chk.E(err) { |
||||
t.Fatalf("Failed to sign event: %v", err) |
||||
} |
||||
|
||||
return ev, signer |
||||
} |
||||
|
||||
// addTagToEvent adds a tag to an event
|
||||
func addTagToEvent(ev *event.E, key, value string) { |
||||
tagItem := tag.NewFromAny(key, value) |
||||
ev.Tags.Append(tagItem) |
||||
} |
||||
|
||||
// TestTagValidationBasic tests basic tag validation with regex patterns
|
||||
func TestTagValidationBasic(t *testing.T) { |
||||
policy, cleanup := setupTagValidationTestPolicy(t, "test-tag-basic") |
||||
defer cleanup() |
||||
|
||||
// Policy with tag validation for kind 30023 (long-form content)
|
||||
policyJSON := []byte(`{ |
||||
"default_policy": "allow", |
||||
"rules": { |
||||
"30023": { |
||||
"description": "Long-form content with tag validation", |
||||
"tag_validation": { |
||||
"d": "^[a-z0-9-]{1,64}$", |
||||
"t": "^[a-z0-9-]{1,32}$" |
||||
} |
||||
} |
||||
} |
||||
}`) |
||||
|
||||
tmpDir := t.TempDir() |
||||
if err := policy.Reload(policyJSON, tmpDir+"/policy.json"); err != nil { |
||||
t.Fatalf("Failed to reload policy: %v", err) |
||||
} |
||||
|
||||
tests := []struct { |
||||
name string |
||||
kind uint16 |
||||
tags map[string]string |
||||
expectAllow bool |
||||
}{ |
||||
{ |
||||
name: "valid d tag", |
||||
kind: 30023, |
||||
tags: map[string]string{ |
||||
"d": "my-article-slug", |
||||
}, |
||||
expectAllow: true, |
||||
}, |
||||
{ |
||||
name: "valid d and t tags", |
||||
kind: 30023, |
||||
tags: map[string]string{ |
||||
"d": "my-article-slug", |
||||
"t": "nostr", |
||||
}, |
||||
expectAllow: true, |
||||
}, |
||||
{ |
||||
name: "invalid d tag - contains uppercase", |
||||
kind: 30023, |
||||
tags: map[string]string{ |
||||
"d": "My-Article-Slug", |
||||
}, |
||||
expectAllow: false, |
||||
}, |
||||
{ |
||||
name: "invalid d tag - contains spaces", |
||||
kind: 30023, |
||||
tags: map[string]string{ |
||||
"d": "my article slug", |
||||
}, |
||||
expectAllow: false, |
||||
}, |
||||
{ |
||||
name: "invalid d tag - too long", |
||||
kind: 30023, |
||||
tags: map[string]string{ |
||||
"d": "this-is-a-very-long-slug-that-exceeds-the-sixty-four-character-limit-set-in-policy", |
||||
}, |
||||
expectAllow: false, |
||||
}, |
||||
{ |
||||
name: "invalid t tag - contains special chars", |
||||
kind: 30023, |
||||
tags: map[string]string{ |
||||
"d": "valid-slug", |
||||
"t": "nostr@tag", |
||||
}, |
||||
expectAllow: false, |
||||
}, |
||||
{ |
||||
name: "kind without tag validation - any tags allowed", |
||||
kind: 1, // Kind 1 has no tag validation rules
|
||||
tags: map[string]string{ |
||||
"d": "ANYTHING_GOES!!!", |
||||
"t": "spaces and Special Chars", |
||||
}, |
||||
expectAllow: true, |
||||
}, |
||||
} |
||||
|
||||
for _, tt := range tests { |
||||
t.Run(tt.name, func(t *testing.T) { |
||||
ev, signer := createSignedTestEvent(t, tt.kind, "test content") |
||||
|
||||
// Add tags to event
|
||||
for key, value := range tt.tags { |
||||
addTagToEvent(ev, key, value) |
||||
} |
||||
|
||||
// Re-sign after adding tags
|
||||
if err := ev.Sign(signer); chk.E(err) { |
||||
t.Fatalf("Failed to re-sign event: %v", err) |
||||
} |
||||
|
||||
allowed, err := policy.CheckPolicy("write", ev, signer.Pub(), "127.0.0.1") |
||||
if err != nil { |
||||
t.Fatalf("CheckPolicy returned error: %v", err) |
||||
} |
||||
|
||||
if allowed != tt.expectAllow { |
||||
t.Errorf("CheckPolicy() = %v, expected %v", allowed, tt.expectAllow) |
||||
} |
||||
}) |
||||
} |
||||
} |
||||
|
||||
// TestTagValidationMultipleSameTag tests validation when multiple tags have the same name
|
||||
func TestTagValidationMultipleSameTag(t *testing.T) { |
||||
policy, cleanup := setupTagValidationTestPolicy(t, "test-tag-multi") |
||||
defer cleanup() |
||||
|
||||
policyJSON := []byte(`{ |
||||
"default_policy": "allow", |
||||
"rules": { |
||||
"30023": { |
||||
"tag_validation": { |
||||
"t": "^[a-z0-9-]+$" |
||||
} |
||||
} |
||||
} |
||||
}`) |
||||
|
||||
tmpDir := t.TempDir() |
||||
if err := policy.Reload(policyJSON, tmpDir+"/policy.json"); err != nil { |
||||
t.Fatalf("Failed to reload policy: %v", err) |
||||
} |
||||
|
||||
tests := []struct { |
||||
name string |
||||
tags []string // Multiple t tags
|
||||
expectAllow bool |
||||
}{ |
||||
{ |
||||
name: "all tags valid", |
||||
tags: []string{"nostr", "bitcoin", "lightning"}, |
||||
expectAllow: true, |
||||
}, |
||||
{ |
||||
name: "one invalid tag among valid ones", |
||||
tags: []string{"nostr", "INVALID", "lightning"}, |
||||
expectAllow: false, |
||||
}, |
||||
{ |
||||
name: "first tag invalid", |
||||
tags: []string{"INVALID", "nostr", "bitcoin"}, |
||||
expectAllow: false, |
||||
}, |
||||
{ |
||||
name: "last tag invalid", |
||||
tags: []string{"nostr", "bitcoin", "INVALID"}, |
||||
expectAllow: false, |
||||
}, |
||||
} |
||||
|
||||
for _, tt := range tests { |
||||
t.Run(tt.name, func(t *testing.T) { |
||||
ev, signer := createSignedTestEvent(t, 30023, "test content") |
||||
|
||||
// Add multiple t tags
|
||||
for _, value := range tt.tags { |
||||
addTagToEvent(ev, "t", value) |
||||
} |
||||
|
||||
// Re-sign
|
||||
if err := ev.Sign(signer); chk.E(err) { |
||||
t.Fatalf("Failed to re-sign event: %v", err) |
||||
} |
||||
|
||||
allowed, err := policy.CheckPolicy("write", ev, signer.Pub(), "127.0.0.1") |
||||
if err != nil { |
||||
t.Fatalf("CheckPolicy returned error: %v", err) |
||||
} |
||||
|
||||
if allowed != tt.expectAllow { |
||||
t.Errorf("CheckPolicy() = %v, expected %v", allowed, tt.expectAllow) |
||||
} |
||||
}) |
||||
} |
||||
} |
||||
|
||||
// TestTagValidationInvalidRegex tests that invalid regex patterns are caught during validation
|
||||
func TestTagValidationInvalidRegex(t *testing.T) { |
||||
policy, cleanup := setupTagValidationTestPolicy(t, "test-tag-invalid-regex") |
||||
defer cleanup() |
||||
|
||||
invalidRegexPolicies := []struct { |
||||
name string |
||||
policy []byte |
||||
}{ |
||||
{ |
||||
name: "unclosed bracket", |
||||
policy: []byte(`{ |
||||
"rules": { |
||||
"30023": { |
||||
"tag_validation": { |
||||
"d": "[invalid" |
||||
} |
||||
} |
||||
} |
||||
}`), |
||||
}, |
||||
{ |
||||
name: "unclosed parenthesis", |
||||
policy: []byte(`{ |
||||
"rules": { |
||||
"30023": { |
||||
"tag_validation": { |
||||
"d": "(unclosed" |
||||
} |
||||
} |
||||
} |
||||
}`), |
||||
}, |
||||
{ |
||||
name: "invalid escape sequence", |
||||
policy: []byte(`{ |
||||
"rules": { |
||||
"30023": { |
||||
"tag_validation": { |
||||
"d": "\\k" |
||||
} |
||||
} |
||||
} |
||||
}`), |
||||
}, |
||||
} |
||||
|
||||
for _, tt := range invalidRegexPolicies { |
||||
t.Run(tt.name, func(t *testing.T) { |
||||
err := policy.ValidateJSON(tt.policy) |
||||
if err == nil { |
||||
t.Error("Expected validation error for invalid regex, got none") |
||||
} |
||||
}) |
||||
} |
||||
} |
||||
|
||||
// TestTagValidationEmptyTag tests behavior when a tag has no value
|
||||
func TestTagValidationEmptyTag(t *testing.T) { |
||||
policy, cleanup := setupTagValidationTestPolicy(t, "test-tag-empty") |
||||
defer cleanup() |
||||
|
||||
policyJSON := []byte(`{ |
||||
"default_policy": "allow", |
||||
"rules": { |
||||
"30023": { |
||||
"tag_validation": { |
||||
"d": "^[a-z0-9-]+$" |
||||
} |
||||
} |
||||
} |
||||
}`) |
||||
|
||||
tmpDir := t.TempDir() |
||||
if err := policy.Reload(policyJSON, tmpDir+"/policy.json"); err != nil { |
||||
t.Fatalf("Failed to reload policy: %v", err) |
||||
} |
||||
|
||||
// Create event with empty d tag value
|
||||
ev, signer := createSignedTestEvent(t, 30023, "test content") |
||||
addTagToEvent(ev, "d", "") |
||||
if err := ev.Sign(signer); chk.E(err) { |
||||
t.Fatalf("Failed to sign event: %v", err) |
||||
} |
||||
|
||||
allowed, err := policy.CheckPolicy("write", ev, signer.Pub(), "127.0.0.1") |
||||
if err != nil { |
||||
t.Fatalf("CheckPolicy returned error: %v", err) |
||||
} |
||||
|
||||
// Empty string doesn't match ^[a-z0-9-]+$ (+ requires at least one char)
|
||||
if allowed { |
||||
t.Error("Expected empty tag value to be rejected") |
||||
} |
||||
} |
||||
|
||||
// TestTagValidationWithWriteAllowFollows tests interaction between tag validation and follow whitelist
|
||||
func TestTagValidationWithWriteAllowFollows(t *testing.T) { |
||||
policy, cleanup := setupTagValidationTestPolicy(t, "test-tag-follows") |
||||
defer cleanup() |
||||
|
||||
// Create a test signer who will be a "follow"
|
||||
signer := p8k.MustNew() |
||||
if err := signer.Generate(); chk.E(err) { |
||||
t.Fatalf("Failed to generate keypair: %v", err) |
||||
} |
||||
|
||||
// Set up policy with tag validation AND write_allow_follows
|
||||
adminHex := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" |
||||
policyJSON := []byte(`{ |
||||
"default_policy": "deny", |
||||
"policy_admins": ["` + adminHex + `"], |
||||
"policy_follow_whitelist_enabled": true, |
||||
"rules": { |
||||
"30023": { |
||||
"write_allow_follows": true, |
||||
"tag_validation": { |
||||
"d": "^[a-z0-9-]+$" |
||||
} |
||||
} |
||||
} |
||||
}`) |
||||
|
||||
tmpDir := t.TempDir() |
||||
if err := policy.Reload(policyJSON, tmpDir+"/policy.json"); err != nil { |
||||
t.Fatalf("Failed to reload policy: %v", err) |
||||
} |
||||
|
||||
// Add the signer as a follow
|
||||
policy.UpdatePolicyFollows([][]byte{signer.Pub()}) |
||||
|
||||
// Test: Follow with valid tag should be allowed
|
||||
ev := event.New() |
||||
ev.CreatedAt = time.Now().Unix() |
||||
ev.Kind = 30023 |
||||
ev.Content = []byte("test content") |
||||
ev.Tags = tag.NewS() |
||||
addTagToEvent(ev, "d", "valid-slug") |
||||
if err := ev.Sign(signer); chk.E(err) { |
||||
t.Fatalf("Failed to sign event: %v", err) |
||||
} |
||||
|
||||
allowed, err := policy.CheckPolicy("write", ev, signer.Pub(), "127.0.0.1") |
||||
if err != nil { |
||||
t.Fatalf("CheckPolicy returned error: %v", err) |
||||
} |
||||
|
||||
if !allowed { |
||||
t.Error("Expected follow with valid tag to be allowed") |
||||
} |
||||
|
||||
// Test: Follow with invalid tag should still be rejected (tag validation applies)
|
||||
ev2 := event.New() |
||||
ev2.CreatedAt = time.Now().Unix() |
||||
ev2.Kind = 30023 |
||||
ev2.Content = []byte("test content") |
||||
ev2.Tags = tag.NewS() |
||||
addTagToEvent(ev2, "d", "INVALID_SLUG") |
||||
if err := ev2.Sign(signer); chk.E(err) { |
||||
t.Fatalf("Failed to sign event: %v", err) |
||||
} |
||||
|
||||
allowed2, err := policy.CheckPolicy("write", ev2, signer.Pub(), "127.0.0.1") |
||||
if err != nil { |
||||
t.Fatalf("CheckPolicy returned error: %v", err) |
||||
} |
||||
|
||||
if allowed2 { |
||||
t.Error("Expected follow with invalid tag to be rejected (tag validation should still apply)") |
||||
} |
||||
} |
||||
|
||||
// TestTagValidationGlobalRule tests tag validation in global rules
|
||||
func TestTagValidationGlobalRule(t *testing.T) { |
||||
policy, cleanup := setupTagValidationTestPolicy(t, "test-tag-global") |
||||
defer cleanup() |
||||
|
||||
// Policy with global tag validation (applies to all kinds)
|
||||
policyJSON := []byte(`{ |
||||
"default_policy": "allow", |
||||
"global": { |
||||
"tag_validation": { |
||||
"e": "^[a-f0-9]{64}$" |
||||
} |
||||
} |
||||
}`) |
||||
|
||||
tmpDir := t.TempDir() |
||||
if err := policy.Reload(policyJSON, tmpDir+"/policy.json"); err != nil { |
||||
t.Fatalf("Failed to reload policy: %v", err) |
||||
} |
||||
|
||||
// Valid e tag (64 hex chars)
|
||||
ev1, signer1 := createSignedTestEvent(t, 1, "test") |
||||
addTagToEvent(ev1, "e", "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef") |
||||
if err := ev1.Sign(signer1); chk.E(err) { |
||||
t.Fatalf("Failed to sign event: %v", err) |
||||
} |
||||
|
||||
allowed1, _ := policy.CheckPolicy("write", ev1, signer1.Pub(), "127.0.0.1") |
||||
if !allowed1 { |
||||
t.Error("Expected valid e tag to be allowed") |
||||
} |
||||
|
||||
// Invalid e tag (not 64 hex chars)
|
||||
ev2, signer2 := createSignedTestEvent(t, 1, "test") |
||||
addTagToEvent(ev2, "e", "not-a-valid-event-id") |
||||
if err := ev2.Sign(signer2); chk.E(err) { |
||||
t.Fatalf("Failed to sign event: %v", err) |
||||
} |
||||
|
||||
allowed2, _ := policy.CheckPolicy("write", ev2, signer2.Pub(), "127.0.0.1") |
||||
if allowed2 { |
||||
t.Error("Expected invalid e tag to be rejected") |
||||
} |
||||
} |
||||
Loading…
Reference in new issue