From 67a351fbd901429a54fd2a0d1ca067b4c6248dc3 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Mon, 1 Dec 2025 22:07:40 +0000 Subject: [PATCH] test: Add tests for webhook template generation and bootstrap token functions - Add comprehensive tests for generatePayloadFromTemplateWithService covering: - Valid JSON template rendering - Invalid template syntax error handling - Template execution errors - ntfy service (plain text, skips JSON validation) - Various services (discord, telegram, pagerduty) JSON validation - Template function usage (upper, title) - Numeric and boolean values in templates - Special characters and empty template edge cases - Add tests for bootstrapTokenValid covering: - Nil router handling - Empty hash and empty token cases - Valid token validation - Invalid token rejection - Whitespace trimming - Add tests for clearBootstrapToken covering: - Nil router safety - Token file deletion - Missing file handling - Empty path handling --- internal/api/bootstrap_token_test.go | 163 +++++++++++ internal/notifications/notifications_test.go | 278 +++++++++++++++++++ 2 files changed, 441 insertions(+) diff --git a/internal/api/bootstrap_token_test.go b/internal/api/bootstrap_token_test.go index 049bab6..889c4c3 100644 --- a/internal/api/bootstrap_token_test.go +++ b/internal/api/bootstrap_token_test.go @@ -5,6 +5,8 @@ import ( "path/filepath" "runtime" "testing" + + "github.com/rcourtman/pulse-go-rewrite/internal/config" ) func TestLoadOrCreateBootstrapToken_EmptyDataPath(t *testing.T) { @@ -290,3 +292,164 @@ func TestGenerateBootstrapToken(t *testing.T) { } } } + +func TestBootstrapTokenValid(t *testing.T) { + t.Run("nil router returns false", func(t *testing.T) { + var r *Router = nil + if r.bootstrapTokenValid("anything") { + t.Error("expected false for nil router") + } + }) + + t.Run("empty hash returns false", func(t *testing.T) { + r := &Router{bootstrapTokenHash: ""} + if r.bootstrapTokenValid("anything") { + t.Error("expected false when bootstrapTokenHash is empty") + } + }) + + t.Run("empty token returns false", func(t *testing.T) { + // Generate a token and create a router with its hash + token, err := generateBootstrapToken() + if err != nil { + t.Fatalf("generateBootstrapToken() error: %v", err) + } + r := &Router{} + // Create a token and store its hash - use loadOrCreateBootstrapToken to get a hash indirectly + tmpDir := t.TempDir() + loadedToken, _, _, err := loadOrCreateBootstrapToken(tmpDir) + if err != nil { + t.Fatalf("loadOrCreateBootstrapToken() error: %v", err) + } + // Since we can't directly access HashAPIToken, we need to use initializeBootstrapToken + // Instead, let's test with a known hash by using the auth package + _ = token + _ = loadedToken + // For this test, we just need any non-empty hash + r.bootstrapTokenHash = "somehash" + + if r.bootstrapTokenValid("") { + t.Error("expected false for empty token") + } + if r.bootstrapTokenValid(" ") { + t.Error("expected false for whitespace-only token") + } + }) + + t.Run("valid token returns true", func(t *testing.T) { + tmpDir := t.TempDir() + token, _, _, err := loadOrCreateBootstrapToken(tmpDir) + if err != nil { + t.Fatalf("loadOrCreateBootstrapToken() error: %v", err) + } + + // Create a router that simulates having loaded the token + // We need to use the auth package to hash the token + cfg := &config.Config{DataPath: tmpDir} + r := &Router{config: cfg} + r.initializeBootstrapToken() + + if !r.bootstrapTokenValid(token) { + t.Error("expected true for valid token") + } + }) + + t.Run("invalid token returns false", func(t *testing.T) { + tmpDir := t.TempDir() + _, _, _, err := loadOrCreateBootstrapToken(tmpDir) + if err != nil { + t.Fatalf("loadOrCreateBootstrapToken() error: %v", err) + } + + cfg := &config.Config{DataPath: tmpDir} + r := &Router{config: cfg} + r.initializeBootstrapToken() + + if r.bootstrapTokenValid("wrongtoken") { + t.Error("expected false for wrong token") + } + }) + + t.Run("token with whitespace is trimmed", func(t *testing.T) { + tmpDir := t.TempDir() + token, _, _, err := loadOrCreateBootstrapToken(tmpDir) + if err != nil { + t.Fatalf("loadOrCreateBootstrapToken() error: %v", err) + } + + cfg := &config.Config{DataPath: tmpDir} + r := &Router{config: cfg} + r.initializeBootstrapToken() + + // Token with leading/trailing whitespace should still validate + if !r.bootstrapTokenValid(" " + token + " ") { + t.Error("expected true for token with surrounding whitespace") + } + }) +} + +func TestClearBootstrapToken(t *testing.T) { + t.Run("nil router does not panic", func(t *testing.T) { + var r *Router = nil + // Should not panic + r.clearBootstrapToken() + }) + + t.Run("clears token hash and path", func(t *testing.T) { + tmpDir := t.TempDir() + _, _, tokenPath, err := loadOrCreateBootstrapToken(tmpDir) + if err != nil { + t.Fatalf("loadOrCreateBootstrapToken() error: %v", err) + } + + r := &Router{ + bootstrapTokenHash: "somehash", + bootstrapTokenPath: tokenPath, + } + + r.clearBootstrapToken() + + if r.bootstrapTokenHash != "" { + t.Errorf("expected empty bootstrapTokenHash, got %q", r.bootstrapTokenHash) + } + if r.bootstrapTokenPath != "" { + t.Errorf("expected empty bootstrapTokenPath, got %q", r.bootstrapTokenPath) + } + + // Verify file was deleted + if _, err := os.Stat(tokenPath); !os.IsNotExist(err) { + t.Error("expected token file to be deleted") + } + }) + + t.Run("handles missing file gracefully", func(t *testing.T) { + r := &Router{ + bootstrapTokenHash: "somehash", + bootstrapTokenPath: "/nonexistent/path/token", + } + + // Should not panic, should clear the hash and path + r.clearBootstrapToken() + + if r.bootstrapTokenHash != "" { + t.Errorf("expected empty bootstrapTokenHash, got %q", r.bootstrapTokenHash) + } + if r.bootstrapTokenPath != "" { + t.Errorf("expected empty bootstrapTokenPath, got %q", r.bootstrapTokenPath) + } + }) + + t.Run("handles empty path", func(t *testing.T) { + r := &Router{ + bootstrapTokenHash: "somehash", + bootstrapTokenPath: "", + } + + // Should not panic + r.clearBootstrapToken() + + if r.bootstrapTokenHash != "" { + t.Errorf("expected empty bootstrapTokenHash, got %q", r.bootstrapTokenHash) + } + }) +} diff --git a/internal/notifications/notifications_test.go b/internal/notifications/notifications_test.go index cde009d..e827d8f 100644 --- a/internal/notifications/notifications_test.go +++ b/internal/notifications/notifications_test.go @@ -2210,3 +2210,281 @@ func TestCheckWebhookRateLimit(t *testing.T) { } }) } + +func TestGeneratePayloadFromTemplateWithService(t *testing.T) { + t.Run("valid JSON template", func(t *testing.T) { + nm := &NotificationManager{} + data := WebhookPayloadData{ + ResourceName: "test-vm", + Message: "CPU usage high", + Level: "warning", + } + template := `{"resource": "{{.ResourceName}}", "message": "{{.Message}}", "level": "{{.Level}}"}` + + result, err := nm.generatePayloadFromTemplateWithService(template, data, "generic") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + expected := `{"resource": "test-vm", "message": "CPU usage high", "level": "warning"}` + if string(result) != expected { + t.Fatalf("expected %q, got %q", expected, string(result)) + } + }) + + t.Run("invalid template syntax", func(t *testing.T) { + nm := &NotificationManager{} + data := WebhookPayloadData{ResourceName: "test"} + // Missing closing brace in template + template := `{"resource": "{{.ResourceName}"}` + + _, err := nm.generatePayloadFromTemplateWithService(template, data, "generic") + if err == nil { + t.Fatal("expected error for invalid template syntax") + } + if !strings.Contains(err.Error(), "invalid template") { + t.Fatalf("expected 'invalid template' in error, got %v", err) + } + }) + + t.Run("template execution error - missing method", func(t *testing.T) { + nm := &NotificationManager{} + data := WebhookPayloadData{ResourceName: "test"} + // Reference a non-existent field/method + template := `{"value": "{{.NonExistentMethod}}"}` + + _, err := nm.generatePayloadFromTemplateWithService(template, data, "generic") + if err == nil { + t.Fatal("expected error for non-existent method") + } + if !strings.Contains(err.Error(), "template execution failed") { + t.Fatalf("expected 'template execution failed' in error, got %v", err) + } + }) + + t.Run("ntfy service skips JSON validation", func(t *testing.T) { + nm := &NotificationManager{} + data := WebhookPayloadData{ + ResourceName: "server1", + Message: "Alert triggered", + } + // Plain text template (not valid JSON) + template := `Alert: {{.ResourceName}} - {{.Message}}` + + result, err := nm.generatePayloadFromTemplateWithService(template, data, "ntfy") + if err != nil { + t.Fatalf("expected no error for ntfy plain text, got %v", err) + } + + expected := "Alert: server1 - Alert triggered" + if string(result) != expected { + t.Fatalf("expected %q, got %q", expected, string(result)) + } + }) + + t.Run("non-ntfy service validates JSON", func(t *testing.T) { + nm := &NotificationManager{} + data := WebhookPayloadData{ResourceName: "test"} + // Plain text (invalid JSON) for non-ntfy service + template := `Plain text: {{.ResourceName}}` + + _, err := nm.generatePayloadFromTemplateWithService(template, data, "slack") + if err == nil { + t.Fatal("expected error for non-JSON output on slack service") + } + if !strings.Contains(err.Error(), "template produced invalid JSON") { + t.Fatalf("expected 'template produced invalid JSON' in error, got %v", err) + } + }) + + t.Run("discord service validates JSON", func(t *testing.T) { + nm := &NotificationManager{} + data := WebhookPayloadData{ + ResourceName: "vm-100", + Message: "Memory threshold exceeded", + } + template := `{"content": "{{.ResourceName}}: {{.Message}}"}` + + result, err := nm.generatePayloadFromTemplateWithService(template, data, "discord") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + expected := `{"content": "vm-100: Memory threshold exceeded"}` + if string(result) != expected { + t.Fatalf("expected %q, got %q", expected, string(result)) + } + }) + + t.Run("telegram service validates JSON", func(t *testing.T) { + nm := &NotificationManager{} + data := WebhookPayloadData{ + ChatID: "12345", + Message: "Alert notification", + } + template := `{"chat_id": "{{.ChatID}}", "text": "{{.Message}}"}` + + result, err := nm.generatePayloadFromTemplateWithService(template, data, "telegram") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + expected := `{"chat_id": "12345", "text": "Alert notification"}` + if string(result) != expected { + t.Fatalf("expected %q, got %q", expected, string(result)) + } + }) + + t.Run("template with numeric values", func(t *testing.T) { + nm := &NotificationManager{} + data := WebhookPayloadData{ + ResourceName: "vm-100", + Value: 85.5, + Threshold: 80.0, + } + template := `{"resource": "{{.ResourceName}}", "value": {{.Value}}, "threshold": {{.Threshold}}}` + + result, err := nm.generatePayloadFromTemplateWithService(template, data, "generic") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + // Verify it's valid JSON + var parsed map[string]interface{} + if err := json.Unmarshal(result, &parsed); err != nil { + t.Fatalf("result is not valid JSON: %v", err) + } + if parsed["value"].(float64) != 85.5 { + t.Fatalf("expected value 85.5, got %v", parsed["value"]) + } + }) + + t.Run("template with boolean values", func(t *testing.T) { + nm := &NotificationManager{} + data := WebhookPayloadData{ + ResourceName: "test", + Acknowledged: true, + } + template := `{"resource": "{{.ResourceName}}", "acknowledged": {{.Acknowledged}}}` + + result, err := nm.generatePayloadFromTemplateWithService(template, data, "generic") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + var parsed map[string]interface{} + if err := json.Unmarshal(result, &parsed); err != nil { + t.Fatalf("result is not valid JSON: %v", err) + } + if parsed["acknowledged"].(bool) != true { + t.Fatalf("expected acknowledged true, got %v", parsed["acknowledged"]) + } + }) + + t.Run("template with special characters in strings", func(t *testing.T) { + nm := &NotificationManager{} + data := WebhookPayloadData{ + ResourceName: "test", + Message: `Line1\nLine2 with "quotes" and \t tabs`, + } + // Use printf to escape for JSON + template := `{"message": "{{.Message}}"}` + + // This will produce invalid JSON because of unescaped characters + _, err := nm.generatePayloadFromTemplateWithService(template, data, "generic") + if err == nil { + t.Fatal("expected error for unescaped special characters in JSON") + } + if !strings.Contains(err.Error(), "template produced invalid JSON") { + t.Fatalf("expected 'template produced invalid JSON' in error, got %v", err) + } + }) + + t.Run("empty template", func(t *testing.T) { + nm := &NotificationManager{} + data := WebhookPayloadData{} + template := "" + + _, err := nm.generatePayloadFromTemplateWithService(template, data, "generic") + if err == nil { + t.Fatal("expected error for empty template producing invalid JSON") + } + }) + + t.Run("template with template functions", func(t *testing.T) { + nm := &NotificationManager{} + data := WebhookPayloadData{ + ResourceName: "test-server", + Level: "warning", + } + template := `{"resource": "{{upper .ResourceName}}", "level": "{{title .Level}}"}` + + result, err := nm.generatePayloadFromTemplateWithService(template, data, "generic") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + var parsed map[string]interface{} + if err := json.Unmarshal(result, &parsed); err != nil { + t.Fatalf("result is not valid JSON: %v", err) + } + if parsed["resource"] != "TEST-SERVER" { + t.Fatalf("expected 'TEST-SERVER', got %v", parsed["resource"]) + } + if parsed["level"] != "Warning" { + t.Fatalf("expected 'Warning', got %v", parsed["level"]) + } + }) + + t.Run("pagerduty service validates JSON", func(t *testing.T) { + nm := &NotificationManager{} + data := WebhookPayloadData{ + ResourceName: "critical-service", + Message: "Service down", + Level: "critical", + } + template := `{"routing_key": "test", "event_action": "trigger", "payload": {"summary": "{{.Message}}"}}` + + result, err := nm.generatePayloadFromTemplateWithService(template, data, "pagerduty") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + var parsed map[string]interface{} + if err := json.Unmarshal(result, &parsed); err != nil { + t.Fatalf("result is not valid JSON: %v", err) + } + }) + + t.Run("generic service validates JSON", func(t *testing.T) { + nm := &NotificationManager{} + data := WebhookPayloadData{ + ResourceName: "test", + } + template := `not valid json at all` + + _, err := nm.generatePayloadFromTemplateWithService(template, data, "generic") + if err == nil { + t.Fatal("expected error for invalid JSON") + } + }) + + t.Run("unknown service still validates JSON", func(t *testing.T) { + nm := &NotificationManager{} + data := WebhookPayloadData{ + ResourceName: "test", + } + // Valid JSON + template := `{"test": "{{.ResourceName}}"}` + + result, err := nm.generatePayloadFromTemplateWithService(template, data, "unknown_service") + if err != nil { + t.Fatalf("expected no error for valid JSON on unknown service, got %v", err) + } + + expected := `{"test": "test"}` + if string(result) != expected { + t.Fatalf("expected %q, got %q", expected, string(result)) + } + }) +}