Some checks failed
Go / build-and-release (push) Has been cancelled
Updated policy validation logic to apply only to write operations, ensuring constraints like max_expiry_duration and required tags do not affect read operations. Added corresponding test cases to verify behavior for both valid and invalid inputs. This change improves clarity between write and read validation rules. bump tag to update binary
1397 lines
34 KiB
Go
1397 lines
34 KiB
Go
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
|
|
}
|