- Add new test files for auth, service, and handler modules - Improve test organization and coverage - Refactor code for better maintainability - Add captcha, settings, stats, and theme handler tests - Add auth module tests (CAS, OAuth, password, SSO, state) - Add service layer tests for auth, export, permissions, roles - All Go tests pass (exit code 0) - All frontend tests pass (325 tests in 59 files)
529 lines
14 KiB
Go
529 lines
14 KiB
Go
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,<script>alert(1)</script>", 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
|
|
})
|
|
}
|