You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1396 lines
34 KiB
1396 lines
34 KiB
package policy |
|
|
|
import ( |
|
"strconv" |
|
"testing" |
|
"time" |
|
|
|
"git.mleku.dev/mleku/nostr/encoders/event" |
|
"git.mleku.dev/mleku/nostr/encoders/hex" |
|
"git.mleku.dev/mleku/nostr/encoders/tag" |
|
"git.mleku.dev/mleku/nostr/interfaces/signer/p8k" |
|
"lol.mleku.dev/chk" |
|
) |
|
|
|
// ============================================================================= |
|
// parseDuration Tests (ISO-8601 format) |
|
// ============================================================================= |
|
|
|
func TestParseDuration(t *testing.T) { |
|
tests := []struct { |
|
name string |
|
input string |
|
expected int64 |
|
expectError bool |
|
}{ |
|
// Basic ISO-8601 time units (require T separator) |
|
{name: "seconds only", input: "PT30S", expected: 30}, |
|
{name: "minutes only", input: "PT5M", expected: 300}, |
|
{name: "hours only", input: "PT2H", expected: 7200}, |
|
|
|
// Basic ISO-8601 date units |
|
{name: "days only", input: "P1D", expected: 86400}, |
|
{name: "7 days", input: "P7D", expected: 604800}, |
|
{name: "30 days", input: "P30D", expected: 2592000}, |
|
{name: "weeks", input: "P1W", expected: 604800}, |
|
{name: "months", input: "P1M", expected: 2628000}, // ~30.44 days per library |
|
{name: "years", input: "P1Y", expected: 31536000}, |
|
|
|
// Combinations |
|
{name: "hours and minutes", input: "PT1H30M", expected: 5400}, |
|
{name: "days and hours", input: "P1DT12H", expected: 129600}, |
|
{name: "days hours minutes", input: "P1DT2H30M", expected: 95400}, |
|
{name: "full combo", input: "P1DT2H3M4S", expected: 93784}, |
|
|
|
// Edge cases |
|
{name: "zero seconds", input: "PT0S", expected: 0}, |
|
{name: "large days", input: "P365D", expected: 31536000}, |
|
{name: "decimal values", input: "PT1.5H", expected: 5400}, |
|
|
|
// Whitespace handling |
|
{name: "with leading space", input: " PT1H", expected: 3600}, |
|
{name: "with trailing space", input: "PT1H ", expected: 3600}, |
|
|
|
// Additional valid cases |
|
{name: "leading zeros", input: "P007D", expected: 604800}, |
|
{name: "decimal days", input: "P0.5D", expected: 43200}, |
|
{name: "fractional minutes", input: "PT0.5M", expected: 30}, |
|
{name: "weeks with days", input: "P1W3D", expected: 864000}, |
|
{name: "zero everything", input: "P0DT0H0M0S", expected: 0}, |
|
|
|
// Errors (strict ISO-8601 via sosodev/duration library) |
|
{name: "empty string", input: "", expectError: true}, |
|
{name: "whitespace only", input: " ", expectError: true}, |
|
{name: "missing P prefix", input: "1D", expectError: true}, |
|
{name: "invalid unit", input: "P5X", expectError: true}, |
|
{name: "H without T separator", input: "P1H", expectError: true}, |
|
{name: "S without T separator", input: "P30S", expectError: true}, |
|
{name: "D after T", input: "PT1D", expectError: true}, |
|
{name: "Y after T", input: "PT1Y", expectError: true}, |
|
{name: "W after T", input: "PT1W", expectError: true}, |
|
{name: "negative number", input: "P-5D", expectError: true}, |
|
{name: "unit without number", input: "PD", expectError: true}, |
|
{name: "unit without number time", input: "PTH", expectError: true}, |
|
} |
|
|
|
for _, tt := range tests { |
|
t.Run(tt.name, func(t *testing.T) { |
|
result, err := parseDuration(tt.input) |
|
if tt.expectError { |
|
if err == nil { |
|
t.Errorf("parseDuration(%q) expected error, got %d", tt.input, result) |
|
} |
|
return |
|
} |
|
if err != nil { |
|
t.Errorf("parseDuration(%q) unexpected error: %v", tt.input, err) |
|
return |
|
} |
|
if result != tt.expected { |
|
t.Errorf("parseDuration(%q) = %d, expected %d", tt.input, result, tt.expected) |
|
} |
|
}) |
|
} |
|
} |
|
|
|
// ============================================================================= |
|
// MaxExpiryDuration Tests |
|
// ============================================================================= |
|
|
|
func TestMaxExpiryDuration(t *testing.T) { |
|
tests := []struct { |
|
name string |
|
maxExpiryDuration string |
|
eventExpiry int64 // offset from created_at |
|
hasExpiryTag bool |
|
expectAllow bool |
|
}{ |
|
{ |
|
name: "valid expiry within limit", |
|
maxExpiryDuration: "PT1H", |
|
eventExpiry: 1800, // 30 minutes |
|
hasExpiryTag: true, |
|
expectAllow: true, |
|
}, |
|
{ |
|
name: "expiry at exact limit rejected", |
|
maxExpiryDuration: "PT1H", |
|
eventExpiry: 3600, // exactly 1 hour - >= means this is rejected |
|
hasExpiryTag: true, |
|
expectAllow: false, |
|
}, |
|
{ |
|
name: "expiry exceeds limit", |
|
maxExpiryDuration: "PT1H", |
|
eventExpiry: 7200, // 2 hours |
|
hasExpiryTag: true, |
|
expectAllow: false, |
|
}, |
|
{ |
|
name: "missing expiry tag when required", |
|
maxExpiryDuration: "PT1H", |
|
hasExpiryTag: false, |
|
expectAllow: false, |
|
}, |
|
{ |
|
name: "day-based duration", |
|
maxExpiryDuration: "P7D", |
|
eventExpiry: 86400, // 1 day |
|
hasExpiryTag: true, |
|
expectAllow: true, |
|
}, |
|
{ |
|
name: "complex duration P1DT12H", |
|
maxExpiryDuration: "P1DT12H", |
|
eventExpiry: 86400, // 1 day (within 1.5 days) |
|
hasExpiryTag: true, |
|
expectAllow: true, |
|
}, |
|
} |
|
|
|
for _, tt := range tests { |
|
t.Run(tt.name, func(t *testing.T) { |
|
signer, pubkey := generateTestKeypair(t) |
|
|
|
// Create policy with max_expiry_duration |
|
policyJSON := []byte(`{ |
|
"default_policy": "allow", |
|
"rules": { |
|
"1": { |
|
"description": "Test kind 1 with expiry", |
|
"max_expiry_duration": "` + tt.maxExpiryDuration + `" |
|
} |
|
} |
|
}`) |
|
|
|
policy, err := New(policyJSON) |
|
if err != nil { |
|
t.Fatalf("Failed to create policy: %v", err) |
|
} |
|
|
|
// Create event |
|
ev := createTestEventForNewFields(t, signer, "test content", 1) |
|
|
|
// Add expiry tag if needed |
|
if tt.hasExpiryTag { |
|
expiryTs := ev.CreatedAt + tt.eventExpiry |
|
addTag(ev, "expiration", string(rune(expiryTs))) |
|
// Re-add as proper string |
|
ev.Tags = tag.NewS() |
|
addTagString(ev, "expiration", int64ToString(expiryTs)) |
|
if err := ev.Sign(signer); chk.E(err) { |
|
t.Fatalf("Failed to re-sign event: %v", err) |
|
} |
|
} |
|
|
|
allowed, err := policy.CheckPolicy("write", ev, pubkey, "127.0.0.1") |
|
if err != nil { |
|
t.Fatalf("CheckPolicy error: %v", err) |
|
} |
|
|
|
if allowed != tt.expectAllow { |
|
t.Errorf("CheckPolicy() = %v, expected %v", allowed, tt.expectAllow) |
|
} |
|
}) |
|
} |
|
} |
|
|
|
// Test MaxExpiryDuration takes precedence over MaxExpiry |
|
func TestMaxExpiryDurationPrecedence(t *testing.T) { |
|
signer, pubkey := generateTestKeypair(t) |
|
|
|
// Policy where both max_expiry (seconds) and max_expiry_duration are set |
|
// max_expiry_duration should take precedence |
|
policyJSON := []byte(`{ |
|
"default_policy": "allow", |
|
"rules": { |
|
"1": { |
|
"description": "Test precedence", |
|
"max_expiry": 60, |
|
"max_expiry_duration": "PT1H" |
|
} |
|
} |
|
}`) |
|
|
|
policy, err := New(policyJSON) |
|
if err != nil { |
|
t.Fatalf("Failed to create policy: %v", err) |
|
} |
|
|
|
// Create event with expiry at 30 minutes (would fail with max_expiry=60s, pass with PT1H) |
|
ev := createTestEventForNewFields(t, signer, "test", 1) |
|
expiryTs := ev.CreatedAt + 1800 // 30 minutes |
|
addTagString(ev, "expiration", int64ToString(expiryTs)) |
|
if err := ev.Sign(signer); chk.E(err) { |
|
t.Fatalf("Failed to sign: %v", err) |
|
} |
|
|
|
allowed, err := policy.CheckPolicy("write", ev, pubkey, "127.0.0.1") |
|
if err != nil { |
|
t.Fatalf("CheckPolicy error: %v", err) |
|
} |
|
|
|
if !allowed { |
|
t.Error("MaxExpiryDuration should take precedence over MaxExpiry; expected allow") |
|
} |
|
} |
|
|
|
// Test that max_expiry_duration only applies to writes, not reads |
|
func TestMaxExpiryDurationWriteOnly(t *testing.T) { |
|
signer, pubkey := generateTestKeypair(t) |
|
|
|
// Policy with strict max_expiry_duration |
|
policyJSON := []byte(`{ |
|
"default_policy": "allow", |
|
"rules": { |
|
"4": { |
|
"description": "DM events with expiry", |
|
"max_expiry_duration": "PT10M", |
|
"privileged": true |
|
} |
|
} |
|
}`) |
|
|
|
policy, err := New(policyJSON) |
|
if err != nil { |
|
t.Fatalf("Failed to create policy: %v", err) |
|
} |
|
|
|
// Create event WITHOUT an expiry tag - this would fail write validation |
|
// but should still be readable |
|
ev := createTestEventForNewFields(t, signer, "test DM", 4) |
|
if err := ev.Sign(signer); chk.E(err) { |
|
t.Fatalf("Failed to sign: %v", err) |
|
} |
|
|
|
// Write should fail (no expiry tag when max_expiry_duration is set) |
|
allowed, err := policy.CheckPolicy("write", ev, pubkey, "127.0.0.1") |
|
if err != nil { |
|
t.Fatalf("CheckPolicy write error: %v", err) |
|
} |
|
if allowed { |
|
t.Error("Write should be denied for event without expiry tag when max_expiry_duration is set") |
|
} |
|
|
|
// Read should succeed (validation constraints don't apply to reads) |
|
allowed, err = policy.CheckPolicy("read", ev, pubkey, "127.0.0.1") |
|
if err != nil { |
|
t.Fatalf("CheckPolicy read error: %v", err) |
|
} |
|
if !allowed { |
|
t.Error("Read should be allowed - max_expiry_duration is write-only validation") |
|
} |
|
|
|
// Also test with an event that has expiry exceeding the limit |
|
ev2 := createTestEventForNewFields(t, signer, "test DM 2", 4) |
|
expiryTs := ev2.CreatedAt + 7200 // 2 hours - exceeds 10 minute limit |
|
addTagString(ev2, "expiration", int64ToString(expiryTs)) |
|
if err := ev2.Sign(signer); chk.E(err) { |
|
t.Fatalf("Failed to sign: %v", err) |
|
} |
|
|
|
// Write should fail (expiry exceeds limit) |
|
allowed, err = policy.CheckPolicy("write", ev2, pubkey, "127.0.0.1") |
|
if err != nil { |
|
t.Fatalf("CheckPolicy write error: %v", err) |
|
} |
|
if allowed { |
|
t.Error("Write should be denied for event with expiry exceeding max_expiry_duration") |
|
} |
|
|
|
// Read should still succeed |
|
allowed, err = policy.CheckPolicy("read", ev2, pubkey, "127.0.0.1") |
|
if err != nil { |
|
t.Fatalf("CheckPolicy read error: %v", err) |
|
} |
|
if !allowed { |
|
t.Error("Read should be allowed - max_expiry_duration is write-only validation") |
|
} |
|
} |
|
|
|
// ============================================================================= |
|
// ProtectedRequired Tests |
|
// ============================================================================= |
|
|
|
func TestProtectedRequired(t *testing.T) { |
|
tests := []struct { |
|
name string |
|
hasProtectedTag bool |
|
expectAllow bool |
|
}{ |
|
{ |
|
name: "has protected tag", |
|
hasProtectedTag: true, |
|
expectAllow: true, |
|
}, |
|
{ |
|
name: "missing protected tag", |
|
hasProtectedTag: false, |
|
expectAllow: false, |
|
}, |
|
} |
|
|
|
for _, tt := range tests { |
|
t.Run(tt.name, func(t *testing.T) { |
|
signer, pubkey := generateTestKeypair(t) |
|
|
|
policyJSON := []byte(`{ |
|
"default_policy": "allow", |
|
"rules": { |
|
"1": { |
|
"description": "Protected events only", |
|
"protected_required": true |
|
} |
|
} |
|
}`) |
|
|
|
policy, err := New(policyJSON) |
|
if err != nil { |
|
t.Fatalf("Failed to create policy: %v", err) |
|
} |
|
|
|
ev := createTestEventForNewFields(t, signer, "test content", 1) |
|
|
|
if tt.hasProtectedTag { |
|
addTagString(ev, "-", "") |
|
if err := ev.Sign(signer); chk.E(err) { |
|
t.Fatalf("Failed to re-sign: %v", err) |
|
} |
|
} |
|
|
|
allowed, err := policy.CheckPolicy("write", ev, pubkey, "127.0.0.1") |
|
if err != nil { |
|
t.Fatalf("CheckPolicy error: %v", err) |
|
} |
|
|
|
if allowed != tt.expectAllow { |
|
t.Errorf("CheckPolicy() = %v, expected %v", allowed, tt.expectAllow) |
|
} |
|
}) |
|
} |
|
} |
|
|
|
// ============================================================================= |
|
// IdentifierRegex Tests |
|
// ============================================================================= |
|
|
|
func TestIdentifierRegex(t *testing.T) { |
|
tests := []struct { |
|
name string |
|
regex string |
|
dTagValue string |
|
hasDTag bool |
|
expectAllow bool |
|
}{ |
|
{ |
|
name: "valid lowercase slug", |
|
regex: "^[a-z0-9-]{1,64}$", |
|
dTagValue: "my-article-slug", |
|
hasDTag: true, |
|
expectAllow: true, |
|
}, |
|
{ |
|
name: "invalid - contains uppercase", |
|
regex: "^[a-z0-9-]{1,64}$", |
|
dTagValue: "My-Article-Slug", |
|
hasDTag: true, |
|
expectAllow: false, |
|
}, |
|
{ |
|
name: "invalid - contains spaces", |
|
regex: "^[a-z0-9-]{1,64}$", |
|
dTagValue: "my article slug", |
|
hasDTag: true, |
|
expectAllow: false, |
|
}, |
|
{ |
|
name: "invalid - too long", |
|
regex: "^[a-z0-9-]{1,10}$", |
|
dTagValue: "this-is-too-long", |
|
hasDTag: true, |
|
expectAllow: false, |
|
}, |
|
{ |
|
name: "missing d tag when required", |
|
regex: "^[a-z0-9-]{1,64}$", |
|
hasDTag: false, |
|
expectAllow: false, |
|
}, |
|
{ |
|
name: "UUID pattern", |
|
regex: "^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$", |
|
dTagValue: "550e8400-e29b-41d4-a716-446655440000", |
|
hasDTag: true, |
|
expectAllow: true, |
|
}, |
|
{ |
|
name: "alphanumeric only", |
|
regex: "^[a-zA-Z0-9]+$", |
|
dTagValue: "MyArticle123", |
|
hasDTag: true, |
|
expectAllow: true, |
|
}, |
|
} |
|
|
|
for _, tt := range tests { |
|
t.Run(tt.name, func(t *testing.T) { |
|
signer, pubkey := generateTestKeypair(t) |
|
|
|
policyJSON := []byte(`{ |
|
"default_policy": "allow", |
|
"rules": { |
|
"30023": { |
|
"description": "Long-form with identifier regex", |
|
"identifier_regex": "` + tt.regex + `" |
|
} |
|
} |
|
}`) |
|
|
|
policy, err := New(policyJSON) |
|
if err != nil { |
|
t.Fatalf("Failed to create policy: %v", err) |
|
} |
|
|
|
ev := createTestEventForNewFields(t, signer, "test content", 30023) |
|
|
|
if tt.hasDTag { |
|
addTagString(ev, "d", tt.dTagValue) |
|
if err := ev.Sign(signer); chk.E(err) { |
|
t.Fatalf("Failed to re-sign: %v", err) |
|
} |
|
} |
|
|
|
allowed, err := policy.CheckPolicy("write", ev, pubkey, "127.0.0.1") |
|
if err != nil { |
|
t.Fatalf("CheckPolicy error: %v", err) |
|
} |
|
|
|
if allowed != tt.expectAllow { |
|
t.Errorf("CheckPolicy() = %v, expected %v", allowed, tt.expectAllow) |
|
} |
|
}) |
|
} |
|
} |
|
|
|
// Test that IdentifierRegex validates multiple d tags |
|
func TestIdentifierRegexMultipleDTags(t *testing.T) { |
|
signer, pubkey := generateTestKeypair(t) |
|
|
|
policyJSON := []byte(`{ |
|
"default_policy": "allow", |
|
"rules": { |
|
"30023": { |
|
"description": "Test multiple d tags", |
|
"identifier_regex": "^[a-z0-9-]+$" |
|
} |
|
} |
|
}`) |
|
|
|
policy, err := New(policyJSON) |
|
if err != nil { |
|
t.Fatalf("Failed to create policy: %v", err) |
|
} |
|
|
|
// Test with one valid and one invalid d tag |
|
ev := createTestEventForNewFields(t, signer, "test", 30023) |
|
addTagString(ev, "d", "valid-slug") |
|
addTagString(ev, "d", "INVALID-SLUG") // uppercase should fail |
|
if err := ev.Sign(signer); chk.E(err) { |
|
t.Fatalf("Failed to sign: %v", err) |
|
} |
|
|
|
allowed, err := policy.CheckPolicy("write", ev, pubkey, "127.0.0.1") |
|
if err != nil { |
|
t.Fatalf("CheckPolicy error: %v", err) |
|
} |
|
|
|
if allowed { |
|
t.Error("Should deny when any d tag fails regex validation") |
|
} |
|
} |
|
|
|
// ============================================================================= |
|
// FollowsWhitelistAdmins Tests |
|
// ============================================================================= |
|
|
|
func TestFollowsWhitelistAdmins(t *testing.T) { |
|
// Generate admin and user keypairs |
|
adminSigner, adminPubkey := generateTestKeypair(t) |
|
userSigner, userPubkey := generateTestKeypair(t) |
|
nonFollowSigner, nonFollowPubkey := generateTestKeypair(t) |
|
|
|
adminHex := hex.Enc(adminPubkey) |
|
|
|
policyJSON := []byte(`{ |
|
"default_policy": "deny", |
|
"rules": { |
|
"1": { |
|
"description": "Only admin follows can write", |
|
"follows_whitelist_admins": ["` + adminHex + `"] |
|
} |
|
} |
|
}`) |
|
|
|
policy, err := New(policyJSON) |
|
if err != nil { |
|
t.Fatalf("Failed to create policy: %v", err) |
|
} |
|
|
|
// Simulate loading admin's follows (user is followed by admin) |
|
policy.UpdateRuleFollowsWhitelist(1, [][]byte{userPubkey}) |
|
|
|
tests := []struct { |
|
name string |
|
signer *p8k.Signer |
|
pubkey []byte |
|
expectAllow bool |
|
}{ |
|
{ |
|
name: "followed user can write", |
|
signer: userSigner, |
|
pubkey: userPubkey, |
|
expectAllow: true, |
|
}, |
|
{ |
|
name: "non-followed user denied", |
|
signer: nonFollowSigner, |
|
pubkey: nonFollowPubkey, |
|
expectAllow: false, |
|
}, |
|
{ |
|
name: "admin can write (is in own follows conceptually)", |
|
signer: adminSigner, |
|
pubkey: adminPubkey, |
|
expectAllow: false, // Admin not in follows list in this test |
|
}, |
|
} |
|
|
|
for _, tt := range tests { |
|
t.Run(tt.name, func(t *testing.T) { |
|
ev := createTestEventForNewFields(t, tt.signer, "test content", 1) |
|
|
|
allowed, err := policy.CheckPolicy("write", ev, tt.pubkey, "127.0.0.1") |
|
if err != nil { |
|
t.Fatalf("CheckPolicy error: %v", err) |
|
} |
|
|
|
if allowed != tt.expectAllow { |
|
t.Errorf("CheckPolicy() = %v, expected %v", allowed, tt.expectAllow) |
|
} |
|
}) |
|
} |
|
} |
|
|
|
func TestGetAllFollowsWhitelistAdmins(t *testing.T) { |
|
admin1 := "1111111111111111111111111111111111111111111111111111111111111111" |
|
admin2 := "2222222222222222222222222222222222222222222222222222222222222222" |
|
admin3 := "3333333333333333333333333333333333333333333333333333333333333333" |
|
|
|
policyJSON := []byte(`{ |
|
"default_policy": "deny", |
|
"global": { |
|
"follows_whitelist_admins": ["` + admin1 + `"] |
|
}, |
|
"rules": { |
|
"1": { |
|
"follows_whitelist_admins": ["` + admin2 + `"] |
|
}, |
|
"30023": { |
|
"follows_whitelist_admins": ["` + admin2 + `", "` + admin3 + `"] |
|
} |
|
} |
|
}`) |
|
|
|
policy, err := New(policyJSON) |
|
if err != nil { |
|
t.Fatalf("Failed to create policy: %v", err) |
|
} |
|
|
|
admins := policy.GetAllFollowsWhitelistAdmins() |
|
|
|
// Should have 3 unique admins (admin2 is deduplicated) |
|
if len(admins) != 3 { |
|
t.Errorf("Expected 3 unique admins, got %d", len(admins)) |
|
} |
|
|
|
// Check all admins are present |
|
adminMap := make(map[string]bool) |
|
for _, a := range admins { |
|
adminMap[a] = true |
|
} |
|
|
|
for _, expected := range []string{admin1, admin2, admin3} { |
|
if !adminMap[expected] { |
|
t.Errorf("Missing admin %s", expected) |
|
} |
|
} |
|
} |
|
|
|
// ============================================================================= |
|
// Combinatorial Tests - New Fields with Existing Fields |
|
// ============================================================================= |
|
|
|
// Test MaxExpiryDuration combined with SizeLimit |
|
func TestMaxExpiryDurationWithSizeLimit(t *testing.T) { |
|
signer, pubkey := generateTestKeypair(t) |
|
|
|
policyJSON := []byte(`{ |
|
"default_policy": "allow", |
|
"rules": { |
|
"1": { |
|
"max_expiry_duration": "PT1H", |
|
"size_limit": 1000 |
|
} |
|
} |
|
}`) |
|
|
|
policy, err := New(policyJSON) |
|
if err != nil { |
|
t.Fatalf("Failed to create policy: %v", err) |
|
} |
|
|
|
tests := []struct { |
|
name string |
|
contentSize int |
|
hasExpiry bool |
|
expiryOK bool |
|
expectAllow bool |
|
}{ |
|
{ |
|
name: "both constraints satisfied", |
|
contentSize: 100, |
|
hasExpiry: true, |
|
expiryOK: true, |
|
expectAllow: true, |
|
}, |
|
{ |
|
name: "size exceeded", |
|
contentSize: 2000, |
|
hasExpiry: true, |
|
expiryOK: true, |
|
expectAllow: false, |
|
}, |
|
{ |
|
name: "expiry exceeded", |
|
contentSize: 100, |
|
hasExpiry: true, |
|
expiryOK: false, |
|
expectAllow: false, |
|
}, |
|
{ |
|
name: "missing expiry", |
|
contentSize: 100, |
|
hasExpiry: false, |
|
expectAllow: false, |
|
}, |
|
} |
|
|
|
for _, tt := range tests { |
|
t.Run(tt.name, func(t *testing.T) { |
|
content := make([]byte, tt.contentSize) |
|
for i := range content { |
|
content[i] = 'a' |
|
} |
|
|
|
ev := event.New() |
|
ev.CreatedAt = time.Now().Unix() |
|
ev.Kind = 1 |
|
ev.Content = content |
|
ev.Tags = tag.NewS() |
|
|
|
if tt.hasExpiry { |
|
var expiryOffset int64 = 1800 // 30 min (OK) |
|
if !tt.expiryOK { |
|
expiryOffset = 7200 // 2h (exceeds 1h limit) |
|
} |
|
addTagString(ev, "expiration", int64ToString(ev.CreatedAt+expiryOffset)) |
|
} |
|
|
|
if err := ev.Sign(signer); chk.E(err) { |
|
t.Fatalf("Failed to sign: %v", err) |
|
} |
|
|
|
allowed, err := policy.CheckPolicy("write", ev, pubkey, "127.0.0.1") |
|
if err != nil { |
|
t.Fatalf("CheckPolicy error: %v", err) |
|
} |
|
|
|
if allowed != tt.expectAllow { |
|
t.Errorf("CheckPolicy() = %v, expected %v", allowed, tt.expectAllow) |
|
} |
|
}) |
|
} |
|
} |
|
|
|
// Test ProtectedRequired combined with Privileged |
|
func TestProtectedRequiredWithPrivileged(t *testing.T) { |
|
authorSigner, authorPubkey := generateTestKeypair(t) |
|
_, recipientPubkey := generateTestKeypair(t) |
|
_, outsiderPubkey := generateTestKeypair(t) |
|
|
|
policyJSON := []byte(`{ |
|
"default_policy": "deny", |
|
"rules": { |
|
"4": { |
|
"description": "Encrypted DMs - protected and privileged", |
|
"protected_required": true, |
|
"privileged": true |
|
} |
|
} |
|
}`) |
|
|
|
policy, err := New(policyJSON) |
|
if err != nil { |
|
t.Fatalf("Failed to create policy: %v", err) |
|
} |
|
|
|
tests := []struct { |
|
name string |
|
hasProtected bool |
|
readerPubkey []byte |
|
isParty bool // is reader author or in p-tag |
|
accessType string |
|
expectAllow bool |
|
}{ |
|
{ |
|
name: "author can read protected event", |
|
hasProtected: true, |
|
readerPubkey: authorPubkey, |
|
isParty: true, |
|
accessType: "read", |
|
expectAllow: true, |
|
}, |
|
{ |
|
name: "recipient in p-tag can read", |
|
hasProtected: true, |
|
readerPubkey: recipientPubkey, |
|
isParty: true, |
|
accessType: "read", |
|
expectAllow: true, |
|
}, |
|
{ |
|
name: "outsider cannot read privileged event", |
|
hasProtected: true, |
|
readerPubkey: outsiderPubkey, |
|
isParty: false, |
|
accessType: "read", |
|
expectAllow: false, |
|
}, |
|
{ |
|
name: "missing protected tag - write denied", |
|
hasProtected: false, |
|
readerPubkey: authorPubkey, |
|
isParty: true, |
|
accessType: "write", |
|
expectAllow: false, |
|
}, |
|
} |
|
|
|
for _, tt := range tests { |
|
t.Run(tt.name, func(t *testing.T) { |
|
ev := createTestEventForNewFields(t, authorSigner, "encrypted content", 4) |
|
|
|
// Add recipient to p-tag |
|
addPTag(ev, recipientPubkey) |
|
|
|
if tt.hasProtected { |
|
addTagString(ev, "-", "") |
|
} |
|
|
|
if err := ev.Sign(authorSigner); chk.E(err) { |
|
t.Fatalf("Failed to sign: %v", err) |
|
} |
|
|
|
allowed, err := policy.CheckPolicy(tt.accessType, ev, tt.readerPubkey, "127.0.0.1") |
|
if err != nil { |
|
t.Fatalf("CheckPolicy error: %v", err) |
|
} |
|
|
|
if allowed != tt.expectAllow { |
|
t.Errorf("CheckPolicy() = %v, expected %v", allowed, tt.expectAllow) |
|
} |
|
}) |
|
} |
|
} |
|
|
|
// Test IdentifierRegex combined with TagValidation |
|
func TestIdentifierRegexWithTagValidation(t *testing.T) { |
|
signer, pubkey := generateTestKeypair(t) |
|
|
|
// Both identifier_regex (for d tag) and tag_validation (for t tag) |
|
policyJSON := []byte(`{ |
|
"default_policy": "allow", |
|
"rules": { |
|
"30023": { |
|
"identifier_regex": "^[a-z0-9-]+$", |
|
"tag_validation": { |
|
"t": "^[a-z]+$" |
|
} |
|
} |
|
} |
|
}`) |
|
|
|
policy, err := New(policyJSON) |
|
if err != nil { |
|
t.Fatalf("Failed to create policy: %v", err) |
|
} |
|
|
|
tests := []struct { |
|
name string |
|
dTag string |
|
tTag string |
|
hasDTag bool |
|
hasTTag bool |
|
expectAllow bool |
|
}{ |
|
{ |
|
name: "both tags valid", |
|
dTag: "my-article", |
|
tTag: "nostr", |
|
hasDTag: true, |
|
hasTTag: true, |
|
expectAllow: true, |
|
}, |
|
{ |
|
name: "d tag invalid", |
|
dTag: "MY-ARTICLE", |
|
tTag: "nostr", |
|
hasDTag: true, |
|
hasTTag: true, |
|
expectAllow: false, |
|
}, |
|
{ |
|
name: "t tag invalid", |
|
dTag: "my-article", |
|
tTag: "NOSTR123", |
|
hasDTag: true, |
|
hasTTag: true, |
|
expectAllow: false, |
|
}, |
|
{ |
|
name: "missing d tag", |
|
tTag: "nostr", |
|
hasDTag: false, |
|
hasTTag: true, |
|
expectAllow: false, |
|
}, |
|
{ |
|
name: "missing t tag - allowed (tag_validation only validates present tags)", |
|
dTag: "my-article", |
|
hasDTag: true, |
|
hasTTag: false, |
|
expectAllow: true, // tag_validation doesn't require tags to exist, only validates if present |
|
}, |
|
} |
|
|
|
for _, tt := range tests { |
|
t.Run(tt.name, func(t *testing.T) { |
|
ev := createTestEventForNewFields(t, signer, "article content", 30023) |
|
|
|
if tt.hasDTag { |
|
addTagString(ev, "d", tt.dTag) |
|
} |
|
if tt.hasTTag { |
|
addTagString(ev, "t", tt.tTag) |
|
} |
|
|
|
if err := ev.Sign(signer); chk.E(err) { |
|
t.Fatalf("Failed to sign: %v", err) |
|
} |
|
|
|
allowed, err := policy.CheckPolicy("write", ev, pubkey, "127.0.0.1") |
|
if err != nil { |
|
t.Fatalf("CheckPolicy error: %v", err) |
|
} |
|
|
|
if allowed != tt.expectAllow { |
|
t.Errorf("CheckPolicy() = %v, expected %v", allowed, tt.expectAllow) |
|
} |
|
}) |
|
} |
|
} |
|
|
|
// Test FollowsWhitelistAdmins combined with WriteAllow |
|
func TestFollowsWhitelistAdminsWithWriteAllow(t *testing.T) { |
|
_, adminPubkey := generateTestKeypair(t) |
|
followedSigner, followedPubkey := generateTestKeypair(t) |
|
explicitSigner, explicitPubkey := generateTestKeypair(t) |
|
_, deniedPubkey := generateTestKeypair(t) |
|
|
|
adminHex := hex.Enc(adminPubkey) |
|
explicitHex := hex.Enc(explicitPubkey) |
|
|
|
// Both follows whitelist and explicit write_allow |
|
policyJSON := []byte(`{ |
|
"default_policy": "deny", |
|
"rules": { |
|
"1": { |
|
"follows_whitelist_admins": ["` + adminHex + `"], |
|
"write_allow": ["` + explicitHex + `"] |
|
} |
|
} |
|
}`) |
|
|
|
policy, err := New(policyJSON) |
|
if err != nil { |
|
t.Fatalf("Failed to create policy: %v", err) |
|
} |
|
|
|
// Add followed user to whitelist |
|
policy.UpdateRuleFollowsWhitelist(1, [][]byte{followedPubkey}) |
|
|
|
tests := []struct { |
|
name string |
|
signer *p8k.Signer |
|
pubkey []byte |
|
expectAllow bool |
|
}{ |
|
{ |
|
name: "followed user allowed", |
|
signer: followedSigner, |
|
pubkey: followedPubkey, |
|
expectAllow: true, |
|
}, |
|
{ |
|
name: "explicit write_allow user allowed", |
|
signer: explicitSigner, |
|
pubkey: explicitPubkey, |
|
expectAllow: true, |
|
}, |
|
{ |
|
name: "user not in either list denied", |
|
signer: p8k.MustNew(), |
|
pubkey: deniedPubkey, |
|
expectAllow: false, |
|
}, |
|
} |
|
|
|
for _, tt := range tests { |
|
t.Run(tt.name, func(t *testing.T) { |
|
// Generate if needed |
|
if tt.signer.Pub() == nil { |
|
_ = tt.signer.Generate() |
|
} |
|
|
|
ev := createTestEventForNewFields(t, tt.signer, "test", 1) |
|
|
|
allowed, err := policy.CheckPolicy("write", ev, tt.pubkey, "127.0.0.1") |
|
if err != nil { |
|
t.Fatalf("CheckPolicy error: %v", err) |
|
} |
|
|
|
if allowed != tt.expectAllow { |
|
t.Errorf("CheckPolicy() = %v, expected %v", allowed, tt.expectAllow) |
|
} |
|
}) |
|
} |
|
} |
|
|
|
// Test all new fields combined |
|
func TestAllNewFieldsCombined(t *testing.T) { |
|
_, adminPubkey := generateTestKeypair(t) |
|
userSigner, userPubkey := generateTestKeypair(t) |
|
|
|
adminHex := hex.Enc(adminPubkey) |
|
|
|
policyJSON := []byte(`{ |
|
"default_policy": "deny", |
|
"rules": { |
|
"30023": { |
|
"description": "All new constraints", |
|
"max_expiry_duration": "P7D", |
|
"protected_required": true, |
|
"identifier_regex": "^[a-z0-9-]{1,32}$", |
|
"follows_whitelist_admins": ["` + adminHex + `"] |
|
} |
|
} |
|
}`) |
|
|
|
policy, err := New(policyJSON) |
|
if err != nil { |
|
t.Fatalf("Failed to create policy: %v", err) |
|
} |
|
|
|
// Add user to follows whitelist |
|
policy.UpdateRuleFollowsWhitelist(30023, [][]byte{userPubkey}) |
|
|
|
tests := []struct { |
|
name string |
|
dTag string |
|
hasExpiry bool |
|
expiryOK bool |
|
hasProtect bool |
|
expectAllow bool |
|
}{ |
|
{ |
|
name: "all constraints satisfied", |
|
dTag: "my-article", |
|
hasExpiry: true, |
|
expiryOK: true, |
|
hasProtect: true, |
|
expectAllow: true, |
|
}, |
|
{ |
|
name: "missing protected tag", |
|
dTag: "my-article", |
|
hasExpiry: true, |
|
expiryOK: true, |
|
hasProtect: false, |
|
expectAllow: false, |
|
}, |
|
{ |
|
name: "invalid d tag", |
|
dTag: "INVALID", |
|
hasExpiry: true, |
|
expiryOK: true, |
|
hasProtect: true, |
|
expectAllow: false, |
|
}, |
|
{ |
|
name: "expiry too long", |
|
dTag: "my-article", |
|
hasExpiry: true, |
|
expiryOK: false, |
|
hasProtect: true, |
|
expectAllow: false, |
|
}, |
|
} |
|
|
|
for _, tt := range tests { |
|
t.Run(tt.name, func(t *testing.T) { |
|
ev := createTestEventForNewFields(t, userSigner, "article content", 30023) |
|
|
|
addTagString(ev, "d", tt.dTag) |
|
|
|
if tt.hasExpiry { |
|
var offset int64 = 86400 // 1 day (OK) |
|
if !tt.expiryOK { |
|
offset = 864000 // 10 days (exceeds 7d) |
|
} |
|
addTagString(ev, "expiration", int64ToString(ev.CreatedAt+offset)) |
|
} |
|
|
|
if tt.hasProtect { |
|
addTagString(ev, "-", "") |
|
} |
|
|
|
if err := ev.Sign(userSigner); chk.E(err) { |
|
t.Fatalf("Failed to sign: %v", err) |
|
} |
|
|
|
allowed, err := policy.CheckPolicy("write", ev, userPubkey, "127.0.0.1") |
|
if err != nil { |
|
t.Fatalf("CheckPolicy error: %v", err) |
|
} |
|
|
|
if allowed != tt.expectAllow { |
|
t.Errorf("CheckPolicy() = %v, expected %v", allowed, tt.expectAllow) |
|
} |
|
}) |
|
} |
|
} |
|
|
|
// Test new fields in global rule |
|
func TestNewFieldsInGlobalRule(t *testing.T) { |
|
signer, pubkey := generateTestKeypair(t) |
|
|
|
policyJSON := []byte(`{ |
|
"default_policy": "allow", |
|
"global": { |
|
"max_expiry_duration": "P1D", |
|
"protected_required": true |
|
}, |
|
"rules": { |
|
"1": { |
|
"description": "Kind 1 events" |
|
} |
|
} |
|
}`) |
|
|
|
policy, err := New(policyJSON) |
|
if err != nil { |
|
t.Fatalf("Failed to create policy: %v", err) |
|
} |
|
|
|
// Event without protected tag should fail global rule |
|
ev := createTestEventForNewFields(t, signer, "test", 1) |
|
addTagString(ev, "expiration", int64ToString(ev.CreatedAt+3600)) |
|
if err := ev.Sign(signer); chk.E(err) { |
|
t.Fatalf("Failed to sign: %v", err) |
|
} |
|
|
|
allowed, err := policy.CheckPolicy("write", ev, pubkey, "127.0.0.1") |
|
if err != nil { |
|
t.Fatalf("CheckPolicy error: %v", err) |
|
} |
|
|
|
if allowed { |
|
t.Error("Global protected_required should deny event without - tag") |
|
} |
|
|
|
// Add protected tag |
|
addTagString(ev, "-", "") |
|
if err := ev.Sign(signer); chk.E(err) { |
|
t.Fatalf("Failed to sign: %v", err) |
|
} |
|
|
|
allowed, err = policy.CheckPolicy("write", ev, pubkey, "127.0.0.1") |
|
if err != nil { |
|
t.Fatalf("CheckPolicy error: %v", err) |
|
} |
|
|
|
if !allowed { |
|
t.Error("Should allow event with protected tag and valid expiry") |
|
} |
|
} |
|
|
|
// ============================================================================= |
|
// New() Validation Tests - Ensures invalid configs fail at load time |
|
// ============================================================================= |
|
|
|
// TestNewRejectsInvalidMaxExpiryDuration verifies that New() fails fast when |
|
// given an invalid max_expiry_duration format like "T10M" instead of "PT10M". |
|
// This prevents silent failures where constraints are ignored. |
|
func TestNewRejectsInvalidMaxExpiryDuration(t *testing.T) { |
|
tests := []struct { |
|
name string |
|
json string |
|
expectError bool |
|
errorMatch string |
|
}{ |
|
{ |
|
name: "valid PT10M format accepted", |
|
json: `{ |
|
"rules": { |
|
"4": {"max_expiry_duration": "PT10M"} |
|
} |
|
}`, |
|
expectError: false, |
|
}, |
|
{ |
|
name: "invalid T10M format (missing P prefix) rejected", |
|
json: `{ |
|
"rules": { |
|
"4": {"max_expiry_duration": "T10M"} |
|
} |
|
}`, |
|
expectError: true, |
|
errorMatch: "max_expiry_duration", |
|
}, |
|
{ |
|
name: "invalid 10M format (missing PT prefix) rejected", |
|
json: `{ |
|
"rules": { |
|
"4": {"max_expiry_duration": "10M"} |
|
} |
|
}`, |
|
expectError: true, |
|
errorMatch: "max_expiry_duration", |
|
}, |
|
{ |
|
name: "valid P7D format accepted", |
|
json: `{ |
|
"rules": { |
|
"1": {"max_expiry_duration": "P7D"} |
|
} |
|
}`, |
|
expectError: false, |
|
}, |
|
{ |
|
name: "invalid 7D format (missing P prefix) rejected", |
|
json: `{ |
|
"rules": { |
|
"1": {"max_expiry_duration": "7D"} |
|
} |
|
}`, |
|
expectError: true, |
|
errorMatch: "max_expiry_duration", |
|
}, |
|
} |
|
|
|
for _, tt := range tests { |
|
t.Run(tt.name, func(t *testing.T) { |
|
policy, err := New([]byte(tt.json)) |
|
|
|
if tt.expectError { |
|
if err == nil { |
|
t.Errorf("New() should have rejected invalid config, but returned policy: %+v", policy) |
|
return |
|
} |
|
if tt.errorMatch != "" && !contains(err.Error(), tt.errorMatch) { |
|
t.Errorf("Error %q should contain %q", err.Error(), tt.errorMatch) |
|
} |
|
} else { |
|
if err != nil { |
|
t.Errorf("New() unexpected error for valid config: %v", err) |
|
} |
|
if policy == nil { |
|
t.Error("New() returned nil policy for valid config") |
|
} |
|
} |
|
}) |
|
} |
|
} |
|
|
|
// ============================================================================= |
|
// ValidateJSON Tests for New Fields |
|
// ============================================================================= |
|
|
|
func TestValidateJSONNewFields(t *testing.T) { |
|
tests := []struct { |
|
name string |
|
json string |
|
expectError bool |
|
errorMatch string |
|
}{ |
|
{ |
|
name: "valid max_expiry_duration", |
|
json: `{ |
|
"rules": { |
|
"1": {"max_expiry_duration": "P7DT12H30M"} |
|
} |
|
}`, |
|
expectError: false, |
|
}, |
|
{ |
|
name: "invalid max_expiry_duration - no P prefix", |
|
json: `{ |
|
"rules": { |
|
"1": {"max_expiry_duration": "7D"} |
|
} |
|
}`, |
|
expectError: true, |
|
errorMatch: "max_expiry_duration", |
|
}, |
|
{ |
|
name: "invalid max_expiry_duration - invalid format", |
|
json: `{ |
|
"rules": { |
|
"1": {"max_expiry_duration": "invalid"} |
|
} |
|
}`, |
|
expectError: true, |
|
errorMatch: "max_expiry_duration", |
|
}, |
|
{ |
|
name: "valid identifier_regex", |
|
json: `{ |
|
"rules": { |
|
"30023": {"identifier_regex": "^[a-z0-9-]+$"} |
|
} |
|
}`, |
|
expectError: false, |
|
}, |
|
{ |
|
name: "invalid identifier_regex", |
|
json: `{ |
|
"rules": { |
|
"30023": {"identifier_regex": "[invalid("} |
|
} |
|
}`, |
|
expectError: true, |
|
errorMatch: "identifier_regex", |
|
}, |
|
{ |
|
name: "valid follows_whitelist_admins", |
|
json: `{ |
|
"rules": { |
|
"1": {"follows_whitelist_admins": ["1111111111111111111111111111111111111111111111111111111111111111"]} |
|
} |
|
}`, |
|
expectError: false, |
|
}, |
|
{ |
|
name: "invalid follows_whitelist_admins - wrong length", |
|
json: `{ |
|
"rules": { |
|
"1": {"follows_whitelist_admins": ["tooshort"]} |
|
} |
|
}`, |
|
expectError: true, |
|
errorMatch: "follows_whitelist_admins", |
|
}, |
|
{ |
|
name: "invalid follows_whitelist_admins - not hex", |
|
json: `{ |
|
"rules": { |
|
"1": {"follows_whitelist_admins": ["gggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggg"]} |
|
} |
|
}`, |
|
expectError: true, |
|
errorMatch: "follows_whitelist_admins", |
|
}, |
|
{ |
|
name: "valid global rule new fields", |
|
json: `{ |
|
"global": { |
|
"max_expiry_duration": "P1D", |
|
"identifier_regex": "^[a-z]+$", |
|
"protected_required": true |
|
} |
|
}`, |
|
expectError: false, |
|
}, |
|
} |
|
|
|
for _, tt := range tests { |
|
t.Run(tt.name, func(t *testing.T) { |
|
policy := &P{} |
|
err := policy.ValidateJSON([]byte(tt.json)) |
|
|
|
if tt.expectError { |
|
if err == nil { |
|
t.Error("Expected validation error, got nil") |
|
return |
|
} |
|
if tt.errorMatch != "" && !contains(err.Error(), tt.errorMatch) { |
|
t.Errorf("Error %q should contain %q", err.Error(), tt.errorMatch) |
|
} |
|
} else { |
|
if err != nil { |
|
t.Errorf("Unexpected validation error: %v", err) |
|
} |
|
} |
|
}) |
|
} |
|
} |
|
|
|
// ============================================================================= |
|
// Helper Functions |
|
// ============================================================================= |
|
|
|
func createTestEventForNewFields(t *testing.T, signer *p8k.Signer, content string, kind uint16) *event.E { |
|
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 test event: %v", err) |
|
} |
|
|
|
return ev |
|
} |
|
|
|
func addTagString(ev *event.E, key, value string) { |
|
tagItem := tag.NewFromAny(key, value) |
|
ev.Tags.Append(tagItem) |
|
} |
|
|
|
func int64ToString(i int64) string { |
|
return strconv.FormatInt(i, 10) |
|
} |
|
|
|
func contains(s, substr string) bool { |
|
return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsHelper(s, substr)) |
|
} |
|
|
|
func containsHelper(s, substr string) bool { |
|
for i := 0; i <= len(s)-len(substr); i++ { |
|
if s[i:i+len(substr)] == substr { |
|
return true |
|
} |
|
} |
|
return false |
|
}
|
|
|