hotreload_test.go raw
1 package policy
2
3 import (
4 "context"
5 "encoding/json"
6 "os"
7 "path/filepath"
8 "strings"
9 "testing"
10 "time"
11
12 "github.com/adrg/xdg"
13 )
14
15 // setupHotreloadTestPolicy creates a policy manager with a temporary config file for hotreload tests.
16 func setupHotreloadTestPolicy(t *testing.T, appName string) (*P, func()) {
17 t.Helper()
18
19 configDir := filepath.Join(xdg.ConfigHome, appName)
20 if err := os.MkdirAll(configDir, 0755); err != nil {
21 t.Fatalf("Failed to create config dir: %v", err)
22 }
23
24 configPath := filepath.Join(configDir, "policy.json")
25 defaultPolicy := []byte(`{"default_policy": "allow"}`)
26 if err := os.WriteFile(configPath, defaultPolicy, 0644); err != nil {
27 t.Fatalf("Failed to write policy file: %v", err)
28 }
29
30 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
31
32 policy := NewWithManager(ctx, appName, true, "")
33 if policy == nil {
34 cancel()
35 os.RemoveAll(configDir)
36 t.Fatal("Failed to create policy manager")
37 }
38
39 cleanup := func() {
40 cancel()
41 os.RemoveAll(configDir)
42 }
43
44 return policy, cleanup
45 }
46
47 // TestValidateJSON tests the ValidateJSON method with various inputs
48 func TestValidateJSON(t *testing.T) {
49 policy, cleanup := setupHotreloadTestPolicy(t, "test-validate-json")
50 defer cleanup()
51
52 tests := []struct {
53 name string
54 json []byte
55 expectError bool
56 errorSubstr string
57 }{
58 {
59 name: "valid empty policy",
60 json: []byte(`{}`),
61 expectError: false,
62 },
63 {
64 name: "valid complete policy",
65 json: []byte(`{
66 "kind": {"whitelist": [1, 3, 7]},
67 "global": {"size_limit": 65536},
68 "rules": {
69 "1": {"description": "Short text notes", "content_limit": 8192}
70 },
71 "default_policy": "allow",
72 "policy_admins": ["0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"],
73 "policy_follow_whitelist_enabled": true
74 }`),
75 expectError: false,
76 },
77 {
78 name: "invalid JSON syntax",
79 json: []byte(`{"invalid": json}`),
80 expectError: true,
81 errorSubstr: "invalid character",
82 },
83 {
84 name: "invalid JSON - missing closing brace",
85 json: []byte(`{"kind": {"whitelist": [1]}`),
86 expectError: true,
87 },
88 {
89 name: "invalid policy_admins - wrong length",
90 json: []byte(`{
91 "policy_admins": ["not-64-chars"]
92 }`),
93 expectError: true,
94 errorSubstr: "invalid policy_admin pubkey",
95 },
96 {
97 name: "invalid policy_admins - non-hex characters",
98 json: []byte(`{
99 "policy_admins": ["zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"]
100 }`),
101 expectError: true,
102 errorSubstr: "invalid policy_admin pubkey",
103 },
104 {
105 name: "valid policy_admins - multiple admins",
106 json: []byte(`{
107 "policy_admins": [
108 "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
109 "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210"
110 ]
111 }`),
112 expectError: false,
113 },
114 {
115 name: "invalid tag_validation regex",
116 json: []byte(`{
117 "rules": {
118 "30023": {
119 "tag_validation": {
120 "d": "[invalid(regex"
121 }
122 }
123 }
124 }`),
125 expectError: true,
126 errorSubstr: "invalid regex",
127 },
128 {
129 name: "valid tag_validation regex",
130 json: []byte(`{
131 "rules": {
132 "30023": {
133 "tag_validation": {
134 "d": "^[a-z0-9-]{1,64}$",
135 "t": "^[a-z0-9-]{1,32}$"
136 }
137 }
138 }
139 }`),
140 expectError: false,
141 },
142 {
143 name: "invalid default_policy",
144 json: []byte(`{
145 "default_policy": "invalid"
146 }`),
147 expectError: true,
148 errorSubstr: "default_policy",
149 },
150 {
151 name: "valid default_policy allow",
152 json: []byte(`{
153 "default_policy": "allow"
154 }`),
155 expectError: false,
156 },
157 {
158 name: "valid default_policy deny",
159 json: []byte(`{
160 "default_policy": "deny"
161 }`),
162 expectError: false,
163 },
164 {
165 name: "valid owners - single owner",
166 json: []byte(`{
167 "owners": ["0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"]
168 }`),
169 expectError: false,
170 },
171 {
172 name: "valid owners - multiple owners",
173 json: []byte(`{
174 "owners": [
175 "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
176 "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210"
177 ]
178 }`),
179 expectError: false,
180 },
181 {
182 name: "invalid owners - wrong length",
183 json: []byte(`{
184 "owners": ["not-64-chars"]
185 }`),
186 expectError: true,
187 errorSubstr: "invalid owner pubkey",
188 },
189 {
190 name: "invalid owners - non-hex characters",
191 json: []byte(`{
192 "owners": ["zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"]
193 }`),
194 expectError: true,
195 errorSubstr: "invalid owner pubkey",
196 },
197 {
198 name: "valid policy with both owners and policy_admins",
199 json: []byte(`{
200 "owners": ["0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"],
201 "policy_admins": ["fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210"],
202 "policy_follow_whitelist_enabled": true
203 }`),
204 expectError: false,
205 },
206 }
207
208 for _, tt := range tests {
209 t.Run(tt.name, func(t *testing.T) {
210 err := policy.ValidateJSON(tt.json)
211 if tt.expectError {
212 if err == nil {
213 t.Errorf("Expected error but got none")
214 return
215 }
216 if tt.errorSubstr != "" && !containsSubstring(err.Error(), tt.errorSubstr) {
217 t.Errorf("Expected error containing %q, got: %v", tt.errorSubstr, err)
218 }
219 return
220 }
221 if err != nil {
222 t.Errorf("Unexpected error: %v", err)
223 }
224 })
225 }
226 }
227
228 // TestReload tests the Reload method
229 func TestReload(t *testing.T) {
230 policy, cleanup := setupHotreloadTestPolicy(t, "test-reload")
231 defer cleanup()
232
233 // Create temp directory for policy files
234 tmpDir := t.TempDir()
235 configPath := filepath.Join(tmpDir, "policy.json")
236
237 tests := []struct {
238 name string
239 initialJSON []byte
240 reloadJSON []byte
241 expectError bool
242 checkAfter func(t *testing.T, p *P)
243 }{
244 {
245 name: "reload with valid policy",
246 initialJSON: []byte(`{"default_policy": "allow"}`),
247 reloadJSON: []byte(`{
248 "default_policy": "deny",
249 "kind": {"whitelist": [1, 3]},
250 "policy_admins": ["0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"]
251 }`),
252 expectError: false,
253 checkAfter: func(t *testing.T, p *P) {
254 if p.DefaultPolicy != "deny" {
255 t.Errorf("Expected default_policy to be 'deny', got %q", p.DefaultPolicy)
256 }
257 if len(p.Kind.Whitelist) != 2 {
258 t.Errorf("Expected 2 whitelisted kinds, got %d", len(p.Kind.Whitelist))
259 }
260 if len(p.PolicyAdmins) != 1 {
261 t.Errorf("Expected 1 policy admin, got %d", len(p.PolicyAdmins))
262 }
263 },
264 },
265 {
266 name: "reload with invalid JSON fails without changes",
267 initialJSON: []byte(`{"default_policy": "allow"}`),
268 reloadJSON: []byte(`{"invalid json`),
269 expectError: true,
270 checkAfter: func(t *testing.T, p *P) {
271 // Policy should remain unchanged
272 if p.DefaultPolicy != "allow" {
273 t.Errorf("Expected default_policy to remain 'allow', got %q", p.DefaultPolicy)
274 }
275 },
276 },
277 {
278 name: "reload with invalid admin pubkey fails without changes",
279 initialJSON: []byte(`{"default_policy": "allow"}`),
280 reloadJSON: []byte(`{
281 "default_policy": "deny",
282 "policy_admins": ["invalid-pubkey"]
283 }`),
284 expectError: true,
285 checkAfter: func(t *testing.T, p *P) {
286 // Policy should remain unchanged
287 if p.DefaultPolicy != "allow" {
288 t.Errorf("Expected default_policy to remain 'allow', got %q", p.DefaultPolicy)
289 }
290 },
291 },
292 }
293
294 for _, tt := range tests {
295 t.Run(tt.name, func(t *testing.T) {
296 // Initialize policy with initial JSON
297 if tt.initialJSON != nil {
298 if err := policy.Reload(tt.initialJSON, configPath); err != nil {
299 t.Fatalf("Failed to set initial policy: %v", err)
300 }
301 }
302
303 // Attempt reload
304 err := policy.Reload(tt.reloadJSON, configPath)
305 if tt.expectError {
306 if err == nil {
307 t.Errorf("Expected error but got none")
308 }
309 } else {
310 if err != nil {
311 t.Errorf("Unexpected error: %v", err)
312 }
313 }
314
315 // Run post-reload checks
316 if tt.checkAfter != nil {
317 tt.checkAfter(t, policy)
318 }
319 })
320 }
321 }
322
323 // TestSaveToFile tests atomic file writing
324 func TestSaveToFile(t *testing.T) {
325 policy, cleanup := setupHotreloadTestPolicy(t, "test-save-file")
326 defer cleanup()
327
328 tmpDir := t.TempDir()
329 configPath := filepath.Join(tmpDir, "policy.json")
330
331 // Load a policy
332 policyJSON := []byte(`{
333 "default_policy": "allow",
334 "kind": {"whitelist": [1, 3, 7]},
335 "policy_admins": ["0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"]
336 }`)
337
338 if err := policy.Reload(policyJSON, configPath); err != nil {
339 t.Fatalf("Failed to reload policy: %v", err)
340 }
341
342 // Verify file was saved
343 if _, err := os.Stat(configPath); os.IsNotExist(err) {
344 t.Errorf("Policy file was not created at %s", configPath)
345 }
346
347 // Read and verify contents
348 data, err := os.ReadFile(configPath)
349 if err != nil {
350 t.Fatalf("Failed to read policy file: %v", err)
351 }
352
353 if len(data) == 0 {
354 t.Error("Policy file is empty")
355 }
356
357 // Verify it's valid JSON
358 var parsed map[string]interface{}
359 if err := json.Unmarshal(data, &parsed); err != nil {
360 t.Errorf("Policy file contains invalid JSON: %v", err)
361 }
362 }
363
364 // TestPauseResume tests the Pause and Resume methods
365 func TestPauseResume(t *testing.T) {
366 policy, cleanup := setupHotreloadTestPolicy(t, "test-pause-resume")
367 defer cleanup()
368
369 // Test Pause
370 if err := policy.Pause(); err != nil {
371 t.Errorf("Pause failed: %v", err)
372 }
373
374 // Test Resume
375 if err := policy.Resume(); err != nil {
376 t.Errorf("Resume failed: %v", err)
377 }
378
379 // Test multiple pause/resume cycles
380 for i := 0; i < 3; i++ {
381 if err := policy.Pause(); err != nil {
382 t.Errorf("Pause %d failed: %v", i, err)
383 }
384 if err := policy.Resume(); err != nil {
385 t.Errorf("Resume %d failed: %v", i, err)
386 }
387 }
388 }
389
390 // TestReloadPreservesExistingOnFailure verifies that failed reloads don't corrupt state
391 func TestReloadPreservesExistingOnFailure(t *testing.T) {
392 policy, cleanup := setupHotreloadTestPolicy(t, "test-reload-preserve")
393 defer cleanup()
394
395 tmpDir := t.TempDir()
396 configPath := filepath.Join(tmpDir, "policy.json")
397
398 // Set up initial valid policy
399 initialJSON := []byte(`{
400 "default_policy": "allow",
401 "kind": {"whitelist": [1, 3, 7]},
402 "policy_admins": ["0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"],
403 "policy_follow_whitelist_enabled": true
404 }`)
405
406 if err := policy.Reload(initialJSON, configPath); err != nil {
407 t.Fatalf("Failed to set initial policy: %v", err)
408 }
409
410 // Store initial state
411 initialDefaultPolicy := policy.DefaultPolicy
412 initialKindWhitelist := len(policy.Kind.Whitelist)
413 initialAdminCount := len(policy.PolicyAdmins)
414 initialFollowEnabled := policy.PolicyFollowWhitelistEnabled
415
416 // Attempt to reload with invalid JSON
417 invalidJSON := []byte(`{"policy_admins": ["invalid"]}`)
418 err := policy.Reload(invalidJSON, configPath)
419 if err == nil {
420 t.Fatal("Expected error for invalid policy_admins but got none")
421 }
422
423 // Verify state is preserved
424 if policy.DefaultPolicy != initialDefaultPolicy {
425 t.Errorf("DefaultPolicy changed from %q to %q after failed reload",
426 initialDefaultPolicy, policy.DefaultPolicy)
427 }
428 if len(policy.Kind.Whitelist) != initialKindWhitelist {
429 t.Errorf("Kind.Whitelist length changed from %d to %d after failed reload",
430 initialKindWhitelist, len(policy.Kind.Whitelist))
431 }
432 if len(policy.PolicyAdmins) != initialAdminCount {
433 t.Errorf("PolicyAdmins length changed from %d to %d after failed reload",
434 initialAdminCount, len(policy.PolicyAdmins))
435 }
436 if policy.PolicyFollowWhitelistEnabled != initialFollowEnabled {
437 t.Errorf("PolicyFollowWhitelistEnabled changed from %v to %v after failed reload",
438 initialFollowEnabled, policy.PolicyFollowWhitelistEnabled)
439 }
440 }
441
442 // containsSubstring checks if a string contains a substring (case-insensitive)
443 func containsSubstring(s, substr string) bool {
444 return strings.Contains(strings.ToLower(s), strings.ToLower(substr))
445 }
446
447 // TestGetOwnersBin tests the GetOwnersBin method for policy-defined owners
448 func TestGetOwnersBin(t *testing.T) {
449 policy, cleanup := setupHotreloadTestPolicy(t, "test-get-owners-bin")
450 defer cleanup()
451
452 tmpDir := t.TempDir()
453 configPath := filepath.Join(tmpDir, "policy.json")
454
455 // Test 1: Policy with no owners
456 emptyJSON := []byte(`{"default_policy": "allow"}`)
457 if err := policy.Reload(emptyJSON, configPath); err != nil {
458 t.Fatalf("Failed to reload policy: %v", err)
459 }
460
461 owners := policy.GetOwnersBin()
462 if len(owners) != 0 {
463 t.Errorf("Expected 0 owners, got %d", len(owners))
464 }
465
466 // Test 2: Policy with owners
467 ownerHex := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
468 withOwnersJSON := []byte(`{
469 "default_policy": "allow",
470 "owners": ["` + ownerHex + `"]
471 }`)
472 if err := policy.Reload(withOwnersJSON, configPath); err != nil {
473 t.Fatalf("Failed to reload policy with owners: %v", err)
474 }
475
476 owners = policy.GetOwnersBin()
477 if len(owners) != 1 {
478 t.Errorf("Expected 1 owner, got %d", len(owners))
479 }
480 if len(owners) > 0 && len(owners[0]) != 32 {
481 t.Errorf("Expected owner binary to be 32 bytes, got %d", len(owners[0]))
482 }
483
484 // Test 3: GetOwners returns hex strings
485 hexOwners := policy.GetOwners()
486 if len(hexOwners) != 1 {
487 t.Errorf("Expected 1 hex owner, got %d", len(hexOwners))
488 }
489 if len(hexOwners) > 0 && hexOwners[0] != ownerHex {
490 t.Errorf("Expected owner %q, got %q", ownerHex, hexOwners[0])
491 }
492
493 // Test 4: Policy with multiple owners
494 owner2Hex := "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210"
495 multiOwnersJSON := []byte(`{
496 "default_policy": "allow",
497 "owners": ["` + ownerHex + `", "` + owner2Hex + `"]
498 }`)
499 if err := policy.Reload(multiOwnersJSON, configPath); err != nil {
500 t.Fatalf("Failed to reload policy with multiple owners: %v", err)
501 }
502
503 owners = policy.GetOwnersBin()
504 if len(owners) != 2 {
505 t.Errorf("Expected 2 owners, got %d", len(owners))
506 }
507 }
508