package service import ( "context" "net" "testing" "time" "github.com/user-management-system/internal/domain" gormsqlite "gorm.io/driver/sqlite" "gorm.io/gorm" "gorm.io/gorm/logger" ) // ============================================================================= // Webhook Service Tests // ============================================================================= func setupWebhookTestEnv(t *testing.T) (*WebhookService, *gorm.DB) { t.Helper() db, err := gorm.Open(gormsqlite.New(gormsqlite.Config{ DriverName: "sqlite", DSN: "file:webhook_test?mode=memory&cache=shared", }), &gorm.Config{ Logger: logger.Default.LogMode(logger.Silent), }) if err != nil { t.Fatalf("failed to connect database: %v", err) } if err := db.AutoMigrate(&domain.Webhook{}, &domain.WebhookDelivery{}); err != nil { t.Fatalf("failed to migrate: %v", err) } // Create service with disabled workers to avoid goroutine issues in tests svc := NewWebhookService(db, WebhookServiceConfig{ Enabled: false, WorkerCount: 0, QueueSize: 10, MaxRetries: 0, }) return svc, db } func TestWebhookService_NewWebhookService(t *testing.T) { t.Run("Create webhook service with default config", func(t *testing.T) { db, err := gorm.Open(gormsqlite.New(gormsqlite.Config{ DriverName: "sqlite", DSN: "file:webhook_default_test?mode=memory&cache=shared", }), &gorm.Config{ Logger: logger.Default.LogMode(logger.Silent), }) if err != nil { t.Fatalf("failed to connect database: %v", err) } svc := NewWebhookService(db) if svc == nil { t.Error("Expected non-nil service") } }) t.Run("Create webhook service with custom config", func(t *testing.T) { db, err := gorm.Open(gormsqlite.New(gormsqlite.Config{ DriverName: "sqlite", DSN: "file:webhook_custom_test?mode=memory&cache=shared", }), &gorm.Config{ Logger: logger.Default.LogMode(logger.Silent), }) if err != nil { t.Fatalf("failed to connect database: %v", err) } svc := NewWebhookService(db, WebhookServiceConfig{ Enabled: false, // Disable workers to avoid goroutine issues SecretHeader: "X-Custom-Signature", TimeoutSec: 30, MaxRetries: 5, WorkerCount: 0, QueueSize: 100, }) if svc == nil { t.Error("Expected non-nil service") } if svc.config.SecretHeader != "X-Custom-Signature" { t.Errorf("Expected SecretHeader 'X-Custom-Signature', got %s", svc.config.SecretHeader) } }) } func TestWebhookService_CreateWebhook(t *testing.T) { svc, _ := setupWebhookTestEnv(t) ctx := context.Background() t.Run("Create webhook success", func(t *testing.T) { req := &CreateWebhookRequest{ Name: "test-webhook", URL: "https://example.com/webhook", Secret: "test-secret", Events: []domain.WebhookEventType{domain.EventUserRegistered, domain.EventUserUpdated}, } webhook, err := svc.CreateWebhook(ctx, req, 1) if err != nil { t.Fatalf("CreateWebhook failed: %v", err) } if webhook.ID == 0 { t.Error("Expected webhook ID to be set") } }) } func TestWebhookService_GetWebhook(t *testing.T) { svc, _ := setupWebhookTestEnv(t) ctx := context.Background() // Create test webhook req := &CreateWebhookRequest{ Name: "get-test-webhook", URL: "https://example.com/webhook", Secret: "test-secret", Events: []domain.WebhookEventType{domain.EventUserRegistered}, } webhook, _ := svc.CreateWebhook(ctx, req, 1) t.Run("Get webhook success", func(t *testing.T) { result, err := svc.GetWebhook(ctx, webhook.ID) if err != nil { t.Fatalf("GetWebhook failed: %v", err) } if result.Name != "get-test-webhook" { t.Errorf("Expected name 'get-test-webhook', got %s", result.Name) } }) t.Run("Get non-existent webhook", func(t *testing.T) { _, err := svc.GetWebhook(ctx, 9999) if err == nil { t.Error("Expected error for non-existent webhook") } }) } func TestWebhookService_ListWebhooks(t *testing.T) { svc, _ := setupWebhookTestEnv(t) ctx := context.Background() // Create test webhooks for i := 0; i < 3; i++ { req := &CreateWebhookRequest{ Name: "list-test-webhook", URL: "https://example.com/webhook", Secret: "test-secret", Events: []domain.WebhookEventType{domain.EventUserRegistered}, } svc.CreateWebhook(ctx, req, 1) } t.Run("List webhooks", func(t *testing.T) { webhooks, err := svc.ListWebhooks(ctx, 1) if err != nil { t.Fatalf("ListWebhooks failed: %v", err) } if len(webhooks) < 3 { t.Errorf("Expected at least 3 webhooks, got %d", len(webhooks)) } }) } func TestWebhookService_UpdateWebhook(t *testing.T) { svc, _ := setupWebhookTestEnv(t) ctx := context.Background() // Create test webhook createReq := &CreateWebhookRequest{ Name: "update-test-webhook", URL: "https://example.com/webhook", Secret: "test-secret", Events: []domain.WebhookEventType{domain.EventUserRegistered}, } webhook, _ := svc.CreateWebhook(ctx, createReq, 1) t.Run("Update webhook", func(t *testing.T) { updateReq := &UpdateWebhookRequest{ Name: "updated-webhook", } err := svc.UpdateWebhook(ctx, webhook.ID, updateReq) if err != nil { t.Fatalf("UpdateWebhook failed: %v", err) } result, _ := svc.GetWebhook(ctx, webhook.ID) if result.Name != "updated-webhook" { t.Errorf("Expected name 'updated-webhook', got %s", result.Name) } }) } func TestWebhookService_DeleteWebhook(t *testing.T) { svc, _ := setupWebhookTestEnv(t) ctx := context.Background() // Create test webhook req := &CreateWebhookRequest{ Name: "delete-test-webhook", URL: "https://example.com/webhook", Secret: "test-secret", Events: []domain.WebhookEventType{domain.EventUserRegistered}, } webhook, _ := svc.CreateWebhook(ctx, req, 1) t.Run("Delete webhook", func(t *testing.T) { err := svc.DeleteWebhook(ctx, webhook.ID) if err != nil { t.Fatalf("DeleteWebhook failed: %v", err) } _, err = svc.GetWebhook(ctx, webhook.ID) if err == nil { t.Error("Expected error for deleted webhook") } }) } func TestWebhookService_Shutdown(t *testing.T) { db, err := gorm.Open(gormsqlite.New(gormsqlite.Config{ DriverName: "sqlite", DSN: "file:webhook_shutdown_test?mode=memory&cache=shared", }), &gorm.Config{ Logger: logger.Default.LogMode(logger.Silent), }) if err != nil { t.Fatalf("failed to connect database: %v", err) } svc := NewWebhookService(db, WebhookServiceConfig{ Enabled: false, // Disable workers to avoid goroutine issues WorkerCount: 0, QueueSize: 10, MaxRetries: 0, }) // Shutdown should not block done := make(chan bool) go func() { svc.Shutdown(context.Background()) done <- true }() select { case <-done: // Success case <-time.After(5 * time.Second): t.Error("Shutdown took too long") } } // ============================================================================= // Webhook Security Functions Tests // ============================================================================= func TestIsPrivateIP(t *testing.T) { tests := []struct { name string ip string expected bool }{ // Private ranges - 10.0.0.0/8 {"10.0.0.0", "10.0.0.0", true}, {"10.255.255.255", "10.255.255.255", true}, {"10.1.2.3", "10.1.2.3", true}, // Private ranges - 172.16.0.0/12 {"172.16.0.0", "172.16.0.0", true}, {"172.31.255.255", "172.31.255.255", true}, {"172.20.1.1", "172.20.1.1", true}, // Private ranges - 192.168.0.0/16 {"192.168.0.0", "192.168.0.0", true}, {"192.168.255.255", "192.168.255.255", true}, {"192.168.1.100", "192.168.1.100", true}, // Loopback {"127.0.0.1", "127.0.0.1", true}, {"127.255.255.255", "127.255.255.255", true}, {"::1", "::1", true}, // Public IPs {"8.8.8.8", "8.8.8.8", false}, {"1.1.1.1", "1.1.1.1", false}, {"93.184.216.34", "93.184.216.34", false}, {"142.250.80.46", "142.250.80.46", false}, // Edge cases {"", "", false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ip := net.ParseIP(tt.ip) if tt.ip == "" { // Empty IP should return false result := isPrivateIP(nil) if result != false { t.Errorf("isPrivateIP(nil) = %v, want %v", result, false) } return } if ip == nil { t.Skipf("could not parse IP: %s", tt.ip) } result := isPrivateIP(ip) if result != tt.expected { t.Errorf("isPrivateIP(%s) = %v, want %v", tt.ip, result, tt.expected) } }) } } func TestIsSafeURL(t *testing.T) { tests := []struct { name string url string expected bool }{ // Valid public HTTPS URLs {"https example.com", "https://example.com/webhook", true}, {"https with path", "https://example.com/api/v1/hook", true}, {"https with query", "https://example.com/hook?a=1&b=2", true}, {"https with port", "https://example.com:8443/hook", true}, {"https subdomains", "https://sub.example.com/hook", true}, // HTTP (allowed but public only) {"http public", "http://example.com/hook", true}, {"http with port", "http://example.com:8080/hook", true}, // Invalid schemes {"ftp scheme", "ftp://example.com/hook", false}, {"file scheme", "file:///etc/passwd", false}, {"data scheme", "data:text/html,", false}, {"javascript scheme", "javascript:alert(1)", false}, // Localhost blocked {"localhost http", "http://localhost/hook", false}, {"localhost https", "https://localhost/hook", false}, {"127.0.0.1", "http://127.0.0.1/hook", false}, {"::1", "http://[::1]/hook", false}, // Private IPs blocked {"10.x.x.x", "http://10.0.0.1/hook", false}, {"172.16.x.x", "http://172.16.0.1/hook", false}, {"192.168.x.x", "http://192.168.1.1/hook", false}, // Internal domains blocked {"internal domain", "https://server.internal/hook", false}, {"local domain", "https://host.local/hook", false}, {"corp domain", "https://host.corp/hook", false}, {"lan domain", "https://host.lan/hook", false}, {"intranet domain", "https://host.intranet/hook", false}, // Cloud metadata IPs blocked {"gcp metadata", "http://metadata.google.internal/", false}, {"aws metadata", "http://169.254.169.254/latest/meta-data/", false}, {"azure metadata", "http://metadata.azure.internal/", false}, {"aliyun metadata", "http://100.100.100.200/latest/meta-data/", false}, // Invalid URLs {"empty", "", false}, {"no scheme", "example.com/hook", false}, {"relative", "/hook", false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := isSafeURL(tt.url) if result != tt.expected { t.Errorf("isSafeURL(%q) = %v, want %v", tt.url, result, tt.expected) } }) } } func TestComputeHMAC(t *testing.T) { tests := []struct { name string payload []byte secret string }{ { name: "simple payload", payload: []byte(`{"event":"user.created"}`), secret: "test-secret", }, { name: "empty payload", payload: []byte{}, secret: "test-secret", }, { name: "empty secret", payload: []byte(`{"event":"user.deleted"}`), secret: "", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result1 := computeHMAC(tt.payload, tt.secret) result2 := computeHMAC(tt.payload, tt.secret) // Same input should produce same output if result1 != result2 { t.Errorf("computeHMAC not deterministic: got %s and %s", result1, result2) } // Result should not be empty for non-empty payload if len(tt.payload) > 0 && result1 == "" { t.Error("computeHMAC returned empty string for non-empty payload") } // Result should be hex-encoded (64 chars for SHA256) if len(result1) != 64 { t.Errorf("computeHMAC returned %d chars, want 64 (SHA256 hex)", len(result1)) } }) } } func TestComputeHMAC_DifferentInputs(t *testing.T) { payload1 := []byte(`{"event":"user.created"}`) payload2 := []byte(`{"event":"user.deleted"}`) secret := "test-secret" result1 := computeHMAC(payload1, secret) result2 := computeHMAC(payload2, secret) if result1 == result2 { t.Error("Different payloads should produce different HMACs") } } func TestComputeHMAC_DifferentSecrets(t *testing.T) { payload := []byte(`{"event":"user.created"}`) result1 := computeHMAC(payload, "secret1") result2 := computeHMAC(payload, "secret2") if result1 == result2 { t.Error("Different secrets should produce different HMACs") } } // ============================================================================= // Webhook Publish and Deliver Tests // ============================================================================= func TestWebhookService_Publish(t *testing.T) { svc, _ := setupWebhookTestEnv(t) ctx := context.Background() // Create test webhook req := &CreateWebhookRequest{ Name: "publish-test-webhook", URL: "https://example.com/webhook", Secret: "test-secret", Events: []domain.WebhookEventType{domain.EventUserRegistered}, } svc.CreateWebhook(ctx, req, 1) t.Run("Publish event when disabled", func(t *testing.T) { // Service is disabled in setupWebhookTestEnv svc.Publish(ctx, domain.EventUserRegistered, map[string]interface{}{"user_id": 1}) // Should not panic or error }) } func TestWebhookService_ListWebhooksPaginated(t *testing.T) { svc, _ := setupWebhookTestEnv(t) ctx := context.Background() // Create test webhooks for i := 0; i < 5; i++ { req := &CreateWebhookRequest{ Name: "paginated-webhook-" + string(rune('0'+i)), URL: "https://example.com/webhook", Secret: "test-secret", Events: []domain.WebhookEventType{domain.EventUserRegistered}, } svc.CreateWebhook(ctx, req, 1) } t.Run("List webhooks paginated", func(t *testing.T) { webhooks, total, err := svc.ListWebhooksPaginated(ctx, 1, 0, 10) if err != nil { t.Fatalf("ListWebhooksPaginated failed: %v", err) } if total < 5 { t.Errorf("Expected at least 5 webhooks, got %d", total) } if len(webhooks) < 5 { t.Errorf("Expected at least 5 webhooks in result, got %d", len(webhooks)) } }) } func TestWebhookService_GetWebhookDeliveries(t *testing.T) { svc, _ := setupWebhookTestEnv(t) ctx := context.Background() // Create test webhook req := &CreateWebhookRequest{ Name: "delivery-test-webhook", URL: "https://example.com/webhook", Secret: "test-secret", Events: []domain.WebhookEventType{domain.EventUserRegistered}, } webhook, _ := svc.CreateWebhook(ctx, req, 1) t.Run("Get webhook deliveries", func(t *testing.T) { deliveries, err := svc.GetWebhookDeliveries(ctx, webhook.ID, 10) if err != nil { t.Fatalf("GetWebhookDeliveries failed: %v", err) } // May be 0 if no deliveries recorded _ = deliveries }) }