Some checks failed
Go / build-and-release (push) Has been cancelled
Deleted `subscription_stability_test.go` to clean up unused or redundant code. Updated naming in test files for improved readability, replacing `tag` with `tg` for consistency. Also updated the `github.com/klauspost/compress` dependency to v1.18.2.
1236 lines
30 KiB
Go
1236 lines
30 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: "valid expiry at exact limit",
|
|
maxExpiryDuration: "PT1H",
|
|
eventExpiry: 3600, // exactly 1 hour
|
|
hasExpiryTag: true,
|
|
expectAllow: true,
|
|
},
|
|
{
|
|
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")
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// 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")
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// 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
|
|
}
|