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