tag_validation_test.go raw

   1  package policy
   2  
   3  import (
   4  	"context"
   5  	"os"
   6  	"path/filepath"
   7  	"testing"
   8  	"time"
   9  
  10  	"github.com/adrg/xdg"
  11  	"next.orly.dev/pkg/nostr/encoders/event"
  12  	"next.orly.dev/pkg/nostr/encoders/tag"
  13  	"next.orly.dev/pkg/nostr/interfaces/signer/p8k"
  14  	"next.orly.dev/pkg/lol/chk"
  15  )
  16  
  17  // setupTagValidationTestPolicy creates a policy manager with a temporary config file for tag validation tests.
  18  func setupTagValidationTestPolicy(t *testing.T, appName string) (*P, func()) {
  19  	t.Helper()
  20  
  21  	configDir := filepath.Join(xdg.ConfigHome, appName)
  22  	if err := os.MkdirAll(configDir, 0755); err != nil {
  23  		t.Fatalf("Failed to create config dir: %v", err)
  24  	}
  25  
  26  	configPath := filepath.Join(configDir, "policy.json")
  27  	defaultPolicy := []byte(`{"default_policy": "allow"}`)
  28  	if err := os.WriteFile(configPath, defaultPolicy, 0644); err != nil {
  29  		t.Fatalf("Failed to write policy file: %v", err)
  30  	}
  31  
  32  	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
  33  
  34  	policy := NewWithManager(ctx, appName, true, "")
  35  	if policy == nil {
  36  		cancel()
  37  		os.RemoveAll(configDir)
  38  		t.Fatal("Failed to create policy manager")
  39  	}
  40  
  41  	cleanup := func() {
  42  		cancel()
  43  		os.RemoveAll(configDir)
  44  	}
  45  
  46  	return policy, cleanup
  47  }
  48  
  49  // createSignedTestEvent creates a signed event for testing
  50  func createSignedTestEvent(t *testing.T, kind uint16, content string) (*event.E, *p8k.Signer) {
  51  	signer := p8k.MustNew()
  52  	if err := signer.Generate(); chk.E(err) {
  53  		t.Fatalf("Failed to generate keypair: %v", err)
  54  	}
  55  
  56  	ev := event.New()
  57  	ev.CreatedAt = time.Now().Unix()
  58  	ev.Kind = kind
  59  	ev.Content = []byte(content)
  60  	ev.Tags = tag.NewS()
  61  
  62  	if err := ev.Sign(signer); chk.E(err) {
  63  		t.Fatalf("Failed to sign event: %v", err)
  64  	}
  65  
  66  	return ev, signer
  67  }
  68  
  69  // addTagToEvent adds a tag to an event
  70  func addTagToEvent(ev *event.E, key, value string) {
  71  	tagItem := tag.NewFromAny(key, value)
  72  	ev.Tags.Append(tagItem)
  73  }
  74  
  75  // TestTagValidationBasic tests basic tag validation with regex patterns
  76  func TestTagValidationBasic(t *testing.T) {
  77  	policy, cleanup := setupTagValidationTestPolicy(t, "test-tag-basic")
  78  	defer cleanup()
  79  
  80  	// Policy with tag validation for kind 30023 (long-form content)
  81  	policyJSON := []byte(`{
  82  		"default_policy": "allow",
  83  		"rules": {
  84  			"30023": {
  85  				"description": "Long-form content with tag validation",
  86  				"tag_validation": {
  87  					"d": "^[a-z0-9-]{1,64}$",
  88  					"t": "^[a-z0-9-]{1,32}$"
  89  				}
  90  			}
  91  		}
  92  	}`)
  93  
  94  	tmpDir := t.TempDir()
  95  	if err := policy.Reload(policyJSON, tmpDir+"/policy.json"); err != nil {
  96  		t.Fatalf("Failed to reload policy: %v", err)
  97  	}
  98  
  99  	tests := []struct {
 100  		name        string
 101  		kind        uint16
 102  		tags        map[string]string
 103  		expectAllow bool
 104  	}{
 105  		{
 106  			name: "valid d tag",
 107  			kind: 30023,
 108  			tags: map[string]string{
 109  				"d": "my-article-slug",
 110  			},
 111  			expectAllow: true,
 112  		},
 113  		{
 114  			name: "valid d and t tags",
 115  			kind: 30023,
 116  			tags: map[string]string{
 117  				"d": "my-article-slug",
 118  				"t": "nostr",
 119  			},
 120  			expectAllow: true,
 121  		},
 122  		{
 123  			name: "invalid d tag - contains uppercase",
 124  			kind: 30023,
 125  			tags: map[string]string{
 126  				"d": "My-Article-Slug",
 127  			},
 128  			expectAllow: false,
 129  		},
 130  		{
 131  			name: "invalid d tag - contains spaces",
 132  			kind: 30023,
 133  			tags: map[string]string{
 134  				"d": "my article slug",
 135  			},
 136  			expectAllow: false,
 137  		},
 138  		{
 139  			name: "invalid d tag - too long",
 140  			kind: 30023,
 141  			tags: map[string]string{
 142  				"d": "this-is-a-very-long-slug-that-exceeds-the-sixty-four-character-limit-set-in-policy",
 143  			},
 144  			expectAllow: false,
 145  		},
 146  		{
 147  			name: "invalid t tag - contains special chars",
 148  			kind: 30023,
 149  			tags: map[string]string{
 150  				"d": "valid-slug",
 151  				"t": "nostr@tag",
 152  			},
 153  			expectAllow: false,
 154  		},
 155  		{
 156  			name: "kind without tag validation - any tags allowed",
 157  			kind: 1, // Kind 1 has no tag validation rules
 158  			tags: map[string]string{
 159  				"d": "ANYTHING_GOES!!!",
 160  				"t": "spaces and Special Chars",
 161  			},
 162  			expectAllow: true,
 163  		},
 164  	}
 165  
 166  	for _, tt := range tests {
 167  		t.Run(tt.name, func(t *testing.T) {
 168  			ev, signer := createSignedTestEvent(t, tt.kind, "test content")
 169  
 170  			// Add tags to event
 171  			for key, value := range tt.tags {
 172  				addTagToEvent(ev, key, value)
 173  			}
 174  
 175  			// Re-sign after adding tags
 176  			if err := ev.Sign(signer); chk.E(err) {
 177  				t.Fatalf("Failed to re-sign event: %v", err)
 178  			}
 179  
 180  			allowed, err := policy.CheckPolicy("write", ev, signer.Pub(), "127.0.0.1")
 181  			if err != nil {
 182  				t.Fatalf("CheckPolicy returned error: %v", err)
 183  			}
 184  
 185  			if allowed != tt.expectAllow {
 186  				t.Errorf("CheckPolicy() = %v, expected %v", allowed, tt.expectAllow)
 187  			}
 188  		})
 189  	}
 190  }
 191  
 192  // TestTagValidationMultipleSameTag tests validation when multiple tags have the same name
 193  func TestTagValidationMultipleSameTag(t *testing.T) {
 194  	policy, cleanup := setupTagValidationTestPolicy(t, "test-tag-multi")
 195  	defer cleanup()
 196  
 197  	policyJSON := []byte(`{
 198  		"default_policy": "allow",
 199  		"rules": {
 200  			"30023": {
 201  				"tag_validation": {
 202  					"t": "^[a-z0-9-]+$"
 203  				}
 204  			}
 205  		}
 206  	}`)
 207  
 208  	tmpDir := t.TempDir()
 209  	if err := policy.Reload(policyJSON, tmpDir+"/policy.json"); err != nil {
 210  		t.Fatalf("Failed to reload policy: %v", err)
 211  	}
 212  
 213  	tests := []struct {
 214  		name        string
 215  		tags        []string // Multiple t tags
 216  		expectAllow bool
 217  	}{
 218  		{
 219  			name:        "all tags valid",
 220  			tags:        []string{"nostr", "bitcoin", "lightning"},
 221  			expectAllow: true,
 222  		},
 223  		{
 224  			name:        "one invalid tag among valid ones",
 225  			tags:        []string{"nostr", "INVALID", "lightning"},
 226  			expectAllow: false,
 227  		},
 228  		{
 229  			name:        "first tag invalid",
 230  			tags:        []string{"INVALID", "nostr", "bitcoin"},
 231  			expectAllow: false,
 232  		},
 233  		{
 234  			name:        "last tag invalid",
 235  			tags:        []string{"nostr", "bitcoin", "INVALID"},
 236  			expectAllow: false,
 237  		},
 238  	}
 239  
 240  	for _, tt := range tests {
 241  		t.Run(tt.name, func(t *testing.T) {
 242  			ev, signer := createSignedTestEvent(t, 30023, "test content")
 243  
 244  			// Add multiple t tags
 245  			for _, value := range tt.tags {
 246  				addTagToEvent(ev, "t", value)
 247  			}
 248  
 249  			// Re-sign
 250  			if err := ev.Sign(signer); chk.E(err) {
 251  				t.Fatalf("Failed to re-sign event: %v", err)
 252  			}
 253  
 254  			allowed, err := policy.CheckPolicy("write", ev, signer.Pub(), "127.0.0.1")
 255  			if err != nil {
 256  				t.Fatalf("CheckPolicy returned error: %v", err)
 257  			}
 258  
 259  			if allowed != tt.expectAllow {
 260  				t.Errorf("CheckPolicy() = %v, expected %v", allowed, tt.expectAllow)
 261  			}
 262  		})
 263  	}
 264  }
 265  
 266  // TestTagValidationInvalidRegex tests that invalid regex patterns are caught during validation
 267  func TestTagValidationInvalidRegex(t *testing.T) {
 268  	policy, cleanup := setupTagValidationTestPolicy(t, "test-tag-invalid-regex")
 269  	defer cleanup()
 270  
 271  	invalidRegexPolicies := []struct {
 272  		name   string
 273  		policy []byte
 274  	}{
 275  		{
 276  			name: "unclosed bracket",
 277  			policy: []byte(`{
 278  				"rules": {
 279  					"30023": {
 280  						"tag_validation": {
 281  							"d": "[invalid"
 282  						}
 283  					}
 284  				}
 285  			}`),
 286  		},
 287  		{
 288  			name: "unclosed parenthesis",
 289  			policy: []byte(`{
 290  				"rules": {
 291  					"30023": {
 292  						"tag_validation": {
 293  							"d": "(unclosed"
 294  						}
 295  					}
 296  				}
 297  			}`),
 298  		},
 299  		{
 300  			name: "invalid escape sequence",
 301  			policy: []byte(`{
 302  				"rules": {
 303  					"30023": {
 304  						"tag_validation": {
 305  							"d": "\\k"
 306  						}
 307  					}
 308  				}
 309  			}`),
 310  		},
 311  	}
 312  
 313  	for _, tt := range invalidRegexPolicies {
 314  		t.Run(tt.name, func(t *testing.T) {
 315  			err := policy.ValidateJSON(tt.policy)
 316  			if err == nil {
 317  				t.Error("Expected validation error for invalid regex, got none")
 318  			}
 319  		})
 320  	}
 321  }
 322  
 323  // TestTagValidationEmptyTag tests behavior when a tag has no value
 324  func TestTagValidationEmptyTag(t *testing.T) {
 325  	policy, cleanup := setupTagValidationTestPolicy(t, "test-tag-empty")
 326  	defer cleanup()
 327  
 328  	policyJSON := []byte(`{
 329  		"default_policy": "allow",
 330  		"rules": {
 331  			"30023": {
 332  				"tag_validation": {
 333  					"d": "^[a-z0-9-]+$"
 334  				}
 335  			}
 336  		}
 337  	}`)
 338  
 339  	tmpDir := t.TempDir()
 340  	if err := policy.Reload(policyJSON, tmpDir+"/policy.json"); err != nil {
 341  		t.Fatalf("Failed to reload policy: %v", err)
 342  	}
 343  
 344  	// Create event with empty d tag value
 345  	ev, signer := createSignedTestEvent(t, 30023, "test content")
 346  	addTagToEvent(ev, "d", "")
 347  	if err := ev.Sign(signer); chk.E(err) {
 348  		t.Fatalf("Failed to sign event: %v", err)
 349  	}
 350  
 351  	allowed, err := policy.CheckPolicy("write", ev, signer.Pub(), "127.0.0.1")
 352  	if err != nil {
 353  		t.Fatalf("CheckPolicy returned error: %v", err)
 354  	}
 355  
 356  	// Empty string doesn't match ^[a-z0-9-]+$ (+ requires at least one char)
 357  	if allowed {
 358  		t.Error("Expected empty tag value to be rejected")
 359  	}
 360  }
 361  
 362  // TestTagValidationWithWriteAllowFollows tests interaction between tag validation and follow whitelist
 363  func TestTagValidationWithWriteAllowFollows(t *testing.T) {
 364  	policy, cleanup := setupTagValidationTestPolicy(t, "test-tag-follows")
 365  	defer cleanup()
 366  
 367  	// Create a test signer who will be a "follow"
 368  	signer := p8k.MustNew()
 369  	if err := signer.Generate(); chk.E(err) {
 370  		t.Fatalf("Failed to generate keypair: %v", err)
 371  	}
 372  
 373  	// Set up policy with tag validation AND write_allow_follows
 374  	adminHex := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
 375  	policyJSON := []byte(`{
 376  		"default_policy": "deny",
 377  		"policy_admins": ["` + adminHex + `"],
 378  		"policy_follow_whitelist_enabled": true,
 379  		"rules": {
 380  			"30023": {
 381  				"write_allow_follows": true,
 382  				"tag_validation": {
 383  					"d": "^[a-z0-9-]+$"
 384  				}
 385  			}
 386  		}
 387  	}`)
 388  
 389  	tmpDir := t.TempDir()
 390  	if err := policy.Reload(policyJSON, tmpDir+"/policy.json"); err != nil {
 391  		t.Fatalf("Failed to reload policy: %v", err)
 392  	}
 393  
 394  	// Add the signer as a follow
 395  	policy.UpdatePolicyFollows([][]byte{signer.Pub()})
 396  
 397  	// Test: Follow with valid tag should be allowed
 398  	ev := event.New()
 399  	ev.CreatedAt = time.Now().Unix()
 400  	ev.Kind = 30023
 401  	ev.Content = []byte("test content")
 402  	ev.Tags = tag.NewS()
 403  	addTagToEvent(ev, "d", "valid-slug")
 404  	if err := ev.Sign(signer); chk.E(err) {
 405  		t.Fatalf("Failed to sign event: %v", err)
 406  	}
 407  
 408  	allowed, err := policy.CheckPolicy("write", ev, signer.Pub(), "127.0.0.1")
 409  	if err != nil {
 410  		t.Fatalf("CheckPolicy returned error: %v", err)
 411  	}
 412  
 413  	if !allowed {
 414  		t.Error("Expected follow with valid tag to be allowed")
 415  	}
 416  
 417  	// Test: Follow with invalid tag should still be rejected (tag validation applies)
 418  	ev2 := event.New()
 419  	ev2.CreatedAt = time.Now().Unix()
 420  	ev2.Kind = 30023
 421  	ev2.Content = []byte("test content")
 422  	ev2.Tags = tag.NewS()
 423  	addTagToEvent(ev2, "d", "INVALID_SLUG")
 424  	if err := ev2.Sign(signer); chk.E(err) {
 425  		t.Fatalf("Failed to sign event: %v", err)
 426  	}
 427  
 428  	allowed2, err := policy.CheckPolicy("write", ev2, signer.Pub(), "127.0.0.1")
 429  	if err != nil {
 430  		t.Fatalf("CheckPolicy returned error: %v", err)
 431  	}
 432  
 433  	if allowed2 {
 434  		t.Error("Expected follow with invalid tag to be rejected (tag validation should still apply)")
 435  	}
 436  }
 437  
 438  // TestTagValidationGlobalRule tests tag validation in global rules
 439  func TestTagValidationGlobalRule(t *testing.T) {
 440  	policy, cleanup := setupTagValidationTestPolicy(t, "test-tag-global")
 441  	defer cleanup()
 442  
 443  	// Policy with global tag validation (applies to all kinds)
 444  	policyJSON := []byte(`{
 445  		"default_policy": "allow",
 446  		"global": {
 447  			"tag_validation": {
 448  				"e": "^[a-f0-9]{64}$"
 449  			}
 450  		}
 451  	}`)
 452  
 453  	tmpDir := t.TempDir()
 454  	if err := policy.Reload(policyJSON, tmpDir+"/policy.json"); err != nil {
 455  		t.Fatalf("Failed to reload policy: %v", err)
 456  	}
 457  
 458  	// Valid e tag (64 hex chars)
 459  	ev1, signer1 := createSignedTestEvent(t, 1, "test")
 460  	addTagToEvent(ev1, "e", "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")
 461  	if err := ev1.Sign(signer1); chk.E(err) {
 462  		t.Fatalf("Failed to sign event: %v", err)
 463  	}
 464  
 465  	allowed1, _ := policy.CheckPolicy("write", ev1, signer1.Pub(), "127.0.0.1")
 466  	if !allowed1 {
 467  		t.Error("Expected valid e tag to be allowed")
 468  	}
 469  
 470  	// Invalid e tag (not 64 hex chars)
 471  	ev2, signer2 := createSignedTestEvent(t, 1, "test")
 472  	addTagToEvent(ev2, "e", "not-a-valid-event-id")
 473  	if err := ev2.Sign(signer2); chk.E(err) {
 474  		t.Fatalf("Failed to sign event: %v", err)
 475  	}
 476  
 477  	allowed2, _ := policy.CheckPolicy("write", ev2, signer2.Pub(), "127.0.0.1")
 478  	if allowed2 {
 479  		t.Error("Expected invalid e tag to be rejected")
 480  	}
 481  }
 482