chore: Add tests for multi-credential DNS providers and enhance config generation

- Implemented tests to verify multi-credential DNS providers create separate TLS automation policies per zone with zone-specific credentials.
- Added tests for ZeroSSL issuer and both ACME and ZeroSSL issuers in multi-credential scenarios.
- Verified handling of ACME staging CA and scenarios where zones have no matching domains.
- Ensured graceful handling when provider type is not found in the registry.
- Added tests for disabled hosts, custom certificates, and advanced config normalization.
- Enhanced credential retrieval logic to handle multi-credential scenarios, including disabled credentials and catch-all matches.
- Improved security decision handling with admin whitelist checks.
- Updated encryption key handling in integration tests for consistent behavior.
This commit is contained in:
GitHub Actions
2026-01-08 22:57:16 +00:00
parent eba63d42d1
commit 1399e563fc
13 changed files with 4140 additions and 16 deletions

View File

@@ -2,6 +2,7 @@ package handlers
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
@@ -413,3 +414,229 @@ func TestAuditLogHandler_ServiceErrors(t *testing.T) {
assert.Contains(t, w.Body.String(), "Failed to retrieve audit log")
})
}
// TestAuditLogHandler_List_PaginationBoundaryEdgeCases tests pagination boundary edge cases
func TestAuditLogHandler_List_PaginationBoundaryEdgeCases(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupAuditLogTestDB(t)
securityService := services.NewSecurityService(db)
handler := NewAuditLogHandler(securityService)
// Create test audit logs
for i := 0; i < 5; i++ {
audit := models.SecurityAudit{
UUID: fmt.Sprintf("audit-%d", i),
Actor: "user-1",
Action: "test_action",
EventCategory: "test",
CreatedAt: time.Now(),
}
db.Create(&audit)
}
tests := []struct {
name string
queryParams string
expectPage int
expectLimit int
}{
{
name: "Negative page defaults to 1",
queryParams: "?page=-5",
expectPage: 1,
expectLimit: 50,
},
{
name: "Zero page defaults to 1",
queryParams: "?page=0",
expectPage: 1,
expectLimit: 50,
},
{
name: "Negative limit defaults to 50",
queryParams: "?limit=-10",
expectPage: 1,
expectLimit: 50,
},
{
name: "Zero limit defaults to 50",
queryParams: "?limit=0",
expectPage: 1,
expectLimit: 50,
},
{
name: "Limit over 100 defaults to 50",
queryParams: "?limit=200",
expectPage: 1,
expectLimit: 50,
},
{
name: "Non-numeric page ignored",
queryParams: "?page=abc",
expectPage: 1,
expectLimit: 50,
},
{
name: "Non-numeric limit ignored",
queryParams: "?limit=xyz",
expectPage: 1,
expectLimit: 50,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request, _ = http.NewRequest(http.MethodGet, "/api/v1/audit-logs"+tt.queryParams, nil)
handler.List(c)
assert.Equal(t, http.StatusOK, w.Code)
var response map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
assert.NoError(t, err)
pagination := response["pagination"].(map[string]interface{})
assert.Equal(t, float64(tt.expectPage), pagination["page"])
assert.Equal(t, float64(tt.expectLimit), pagination["limit"])
})
}
}
// TestAuditLogHandler_ListByProvider_PaginationBoundaryEdgeCases tests pagination boundary edge cases for provider list
func TestAuditLogHandler_ListByProvider_PaginationBoundaryEdgeCases(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupAuditLogTestDB(t)
securityService := services.NewSecurityService(db)
handler := NewAuditLogHandler(securityService)
providerID := uint(999)
// Create test audit logs for this provider
for i := 0; i < 3; i++ {
audit := models.SecurityAudit{
UUID: fmt.Sprintf("provider-audit-%d", i),
Actor: "user-1",
Action: "dns_provider_update",
EventCategory: "dns_provider",
ResourceID: &providerID,
CreatedAt: time.Now(),
}
db.Create(&audit)
}
tests := []struct {
name string
queryParams string
expectPage int
expectLimit int
}{
{
name: "Negative page defaults to 1",
queryParams: "?page=-1",
expectPage: 1,
expectLimit: 50,
},
{
name: "Zero limit defaults to 50",
queryParams: "?limit=0",
expectPage: 1,
expectLimit: 50,
},
{
name: "Limit over 100 defaults to 50",
queryParams: "?limit=150",
expectPage: 1,
expectLimit: 50,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{gin.Param{Key: "id", Value: "999"}}
c.Request, _ = http.NewRequest(http.MethodGet, "/api/v1/dns-providers/999/audit-logs"+tt.queryParams, nil)
handler.ListByProvider(c)
assert.Equal(t, http.StatusOK, w.Code)
var response map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
assert.NoError(t, err)
pagination := response["pagination"].(map[string]interface{})
assert.Equal(t, float64(tt.expectPage), pagination["page"])
assert.Equal(t, float64(tt.expectLimit), pagination["limit"])
})
}
}
// TestAuditLogHandler_List_InvalidDateFormats tests handling of invalid date formats
func TestAuditLogHandler_List_InvalidDateFormats(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupAuditLogTestDB(t)
securityService := services.NewSecurityService(db)
handler := NewAuditLogHandler(securityService)
// Invalid date formats should be ignored (not cause errors)
tests := []struct {
name string
queryParams string
}{
{
name: "Invalid start_date format",
queryParams: "?start_date=not-a-date",
},
{
name: "Invalid end_date format",
queryParams: "?end_date=invalid-format",
},
{
name: "Both dates invalid",
queryParams: "?start_date=bad&end_date=also-bad",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request, _ = http.NewRequest(http.MethodGet, "/api/v1/audit-logs"+tt.queryParams, nil)
handler.List(c)
// Should succeed (invalid dates are ignored, not errors)
assert.Equal(t, http.StatusOK, w.Code)
})
}
}
// TestAuditLogHandler_Get_InternalError tests Get when service returns internal error
func TestAuditLogHandler_Get_InternalError(t *testing.T) {
gin.SetMode(gin.TestMode)
// Create a fresh DB and immediately close it to simulate internal error
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
assert.NoError(t, err)
db.AutoMigrate(&models.SecurityAudit{})
securityService := services.NewSecurityService(db)
handler := NewAuditLogHandler(securityService)
// Close the DB to force internal error (not "not found")
sqlDB, _ := db.DB()
sqlDB.Close()
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{gin.Param{Key: "uuid", Value: "test-uuid"}}
c.Request, _ = http.NewRequest(http.MethodGet, "/api/v1/audit-logs/test-uuid", nil)
handler.Get(c)
assert.Equal(t, http.StatusInternalServerError, w.Code)
assert.Contains(t, w.Body.String(), "Failed to retrieve audit log")
}

View File

@@ -765,3 +765,194 @@ func TestCredentialHandler_Update_EncryptionError(t *testing.T) {
// Should succeed because encryption service is properly initialized
assert.Equal(t, http.StatusOK, w.Code)
}
// TestCredentialHandler_Update_InvalidProviderType tests update with invalid provider type
func TestCredentialHandler_Update_InvalidProviderType(t *testing.T) {
router, db, _ := setupCredentialHandlerTest(t)
// Create provider with invalid provider type
testKey := "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY="
encryptor, _ := crypto.NewEncryptionService(testKey)
creds := map[string]string{"api_token": "test-token"}
credsJSON, _ := json.Marshal(creds)
encrypted, _ := encryptor.Encrypt(credsJSON)
provider := &models.DNSProvider{
UUID: uuid.New().String(),
Name: "Invalid Provider",
ProviderType: "nonexistent-provider",
Enabled: true,
UseMultiCredentials: true,
CredentialsEncrypted: encrypted,
KeyVersion: 1,
}
require.NoError(t, db.Create(provider).Error)
// Create a credential for this provider
credential := &models.DNSProviderCredential{
UUID: uuid.New().String(),
DNSProviderID: provider.ID,
Label: "Test Credential",
CredentialsEncrypted: encrypted,
Enabled: true,
}
require.NoError(t, db.Create(credential).Error)
// Give SQLite time to release locks
time.Sleep(10 * time.Millisecond)
updateBody := map[string]interface{}{
"label": "Updated Label",
"credentials": map[string]string{"api_token": "new-token"},
}
body, _ := json.Marshal(updateBody)
url := fmt.Sprintf("/api/v1/dns-providers/%d/credentials/%d", provider.ID, credential.ID)
req, _ := http.NewRequest("PUT", url, bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
// Should return 400 because provider type is invalid
assert.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, w.Body.String(), "invalid provider type")
}
// TestCredentialHandler_Update_InvalidCredentials tests update with invalid credentials
func TestCredentialHandler_Update_InvalidCredentials(t *testing.T) {
router, db, _ := setupCredentialHandlerTest(t)
// Create a provider with cloudflare type
testKey := "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY="
encryptor, _ := crypto.NewEncryptionService(testKey)
creds := map[string]string{"api_token": "test-token"}
credsJSON, _ := json.Marshal(creds)
encrypted, _ := encryptor.Encrypt(credsJSON)
provider := &models.DNSProvider{
UUID: uuid.New().String(),
Name: "Cloudflare Provider",
ProviderType: "cloudflare",
Enabled: true,
UseMultiCredentials: true,
CredentialsEncrypted: encrypted,
KeyVersion: 1,
}
require.NoError(t, db.Create(provider).Error)
// Create a credential for this provider
credential := &models.DNSProviderCredential{
UUID: uuid.New().String(),
DNSProviderID: provider.ID,
Label: "Test Credential",
CredentialsEncrypted: encrypted,
Enabled: true,
}
require.NoError(t, db.Create(credential).Error)
// Give SQLite time to release locks
time.Sleep(10 * time.Millisecond)
// Update with empty credentials (invalid for cloudflare)
updateBody := map[string]interface{}{
"label": "Updated Label",
"credentials": map[string]string{},
}
body, _ := json.Marshal(updateBody)
url := fmt.Sprintf("/api/v1/dns-providers/%d/credentials/%d", provider.ID, credential.ID)
req, _ := http.NewRequest("PUT", url, bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
// Result depends on whether validation catches empty credentials
// Either 400 Bad Request or 200 OK (if validation doesn't check for empty)
statusOK := w.Code == http.StatusOK || w.Code == http.StatusBadRequest
assert.True(t, statusOK, "Expected 200 or 400, got %d", w.Code)
}
// TestCredentialHandler_Create_EmptyLabel tests creating credential with empty label
func TestCredentialHandler_Create_EmptyLabel(t *testing.T) {
router, _, provider := setupCredentialHandlerTest(t)
reqBody := map[string]interface{}{
"label": "",
"credentials": map[string]string{"api_token": "token"},
}
body, _ := json.Marshal(reqBody)
url := fmt.Sprintf("/api/v1/dns-providers/%d/credentials", provider.ID)
req, _ := http.NewRequest("POST", url, bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
// Should either succeed with default label or return error
statusOK := w.Code == http.StatusCreated || w.Code == http.StatusBadRequest
assert.True(t, statusOK, "Expected 201 or 400, got %d", w.Code)
}
// TestCredentialHandler_Update_WithZoneFilter tests updating credential with zone filter
func TestCredentialHandler_Update_WithZoneFilter(t *testing.T) {
router, db, provider := setupCredentialHandlerTest(t)
testKey := "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY="
encryptor, _ := crypto.NewEncryptionService(testKey)
credService := services.NewCredentialService(db, encryptor)
createReq := services.CreateCredentialRequest{
Label: "Test Credential",
ZoneFilter: "example.com",
Credentials: map[string]string{"api_token": "token"},
}
created, err := credService.Create(testContext(), provider.ID, createReq)
require.NoError(t, err)
// Give SQLite time to release locks
time.Sleep(10 * time.Millisecond)
updateBody := map[string]interface{}{
"label": "Updated Label",
"zone_filter": "*.newdomain.com",
}
body, _ := json.Marshal(updateBody)
url := fmt.Sprintf("/api/v1/dns-providers/%d/credentials/%d", provider.ID, created.ID)
req, _ := http.NewRequest("PUT", url, bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var response models.DNSProviderCredential
err = json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
assert.Equal(t, "Updated Label", response.Label)
assert.Equal(t, "*.newdomain.com", response.ZoneFilter)
}
// TestCredentialHandler_Delete_ProviderNotFound tests deleting credential with nonexistent provider
func TestCredentialHandler_Delete_ProviderNotFound(t *testing.T) {
router, _, _ := setupCredentialHandlerTest(t)
req, _ := http.NewRequest("DELETE", "/api/v1/dns-providers/9999/credentials/1", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
// The credential deletion may check provider or directly check credential
statusOK := w.Code == http.StatusNotFound || w.Code == http.StatusNoContent
assert.True(t, statusOK, "Expected 404 or 204, got %d", w.Code)
}
// TestCredentialHandler_Test_ProviderNotFound tests testing credential with nonexistent provider
func TestCredentialHandler_Test_ProviderNotFound(t *testing.T) {
router, _, _ := setupCredentialHandlerTest(t)
req, _ := http.NewRequest("POST", "/api/v1/dns-providers/9999/credentials/1/test", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusNotFound, w.Code)
}

View File

@@ -18,6 +18,7 @@ import (
"github.com/Wikid82/charon/backend/internal/crowdsec"
"github.com/Wikid82/charon/backend/internal/models"
"github.com/Wikid82/charon/backend/internal/services"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
@@ -1627,6 +1628,544 @@ func TestCrowdsecHandler_UnbanIP_Error(t *testing.T) {
require.Contains(t, w.Body.String(), "failed to unban")
}
// ============================================
// Additional CrowdSec Handler Tests for Coverage
// ============================================
func TestCrowdsecHandler_BanIP_ExecutionError(t *testing.T) {
t.Parallel()
gin.SetMode(gin.TestMode)
mockExec := &mockCmdExecutor{
output: []byte("error: failed to add decision"),
err: errors.New("cscli failed"),
}
db := setupCrowdDB(t)
h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", t.TempDir())
h.CmdExec = mockExec
r := gin.New()
g := r.Group("/api/v1")
h.RegisterRoutes(g)
body := `{"ip": "192.168.1.100", "duration": "1h", "reason": "test ban"}`
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/ban", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
require.Equal(t, http.StatusInternalServerError, w.Code)
require.Contains(t, w.Body.String(), "failed to ban IP")
}
// Note: TestCrowdsecHandler_Stop_Error is defined in crowdsec_stop_lapi_test.go
func TestCrowdsecHandler_CheckLAPIHealth_InvalidURL(t *testing.T) {
t.Parallel()
gin.SetMode(gin.TestMode)
db := setupCrowdDB(t)
require.NoError(t, db.AutoMigrate(&models.SecurityConfig{}))
// Create config with invalid URL
cfg := models.SecurityConfig{
UUID: "default",
CrowdSecAPIURL: "http://evil.external.com:8080", // Should be blocked by SSRF policy
}
require.NoError(t, db.Create(&cfg).Error)
h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", t.TempDir())
// Initialize security service
h.Security = services.NewSecurityService(db)
r := gin.New()
g := r.Group("/api/v1")
h.RegisterRoutes(g)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/lapi/health", http.NoBody)
r.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
var resp map[string]any
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
require.False(t, resp["healthy"].(bool))
require.Contains(t, resp, "error")
}
func TestCrowdsecHandler_GetLAPIDecisions_Fallback(t *testing.T) {
t.Parallel()
gin.SetMode(gin.TestMode)
// Mock executor that simulates fallback to cscli
mockExec := &mockCmdExecutor{
output: []byte(`[{"id": 1, "origin": "cscli", "type": "ban", "scope": "ip", "value": "10.0.0.1"}]`),
err: nil,
}
db := setupCrowdDB(t)
require.NoError(t, db.AutoMigrate(&models.SecurityConfig{}))
// Create config with invalid URL to trigger fallback
cfg := models.SecurityConfig{
UUID: "default",
CrowdSecAPIURL: "http://external.evil.com:8080",
}
require.NoError(t, db.Create(&cfg).Error)
h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", t.TempDir())
h.CmdExec = mockExec
h.Security = services.NewSecurityService(db)
r := gin.New()
g := r.Group("/api/v1")
h.RegisterRoutes(g)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/decisions/lapi", http.NoBody)
r.ServeHTTP(w, req)
// Should fall back to cscli-based method
require.Equal(t, http.StatusOK, w.Code)
}
func TestCrowdsecHandler_PullPreset_CerberusDisabled(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Setenv("FEATURE_CERBERUS_ENABLED", "false")
h := NewCrowdsecHandler(OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
r := gin.New()
g := r.Group("/api/v1")
h.RegisterRoutes(g)
body := `{"slug": "test-slug"}`
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/presets/pull", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
require.Equal(t, http.StatusNotFound, w.Code)
require.Contains(t, w.Body.String(), "cerberus disabled")
}
func TestCrowdsecHandler_PullPreset_InvalidPayload(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Setenv("FEATURE_CERBERUS_ENABLED", "true")
h := NewCrowdsecHandler(OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
r := gin.New()
g := r.Group("/api/v1")
h.RegisterRoutes(g)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/presets/pull", strings.NewReader("not-json"))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
require.Equal(t, http.StatusBadRequest, w.Code)
require.Contains(t, w.Body.String(), "invalid payload")
}
func TestCrowdsecHandler_PullPreset_EmptySlug(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Setenv("FEATURE_CERBERUS_ENABLED", "true")
h := NewCrowdsecHandler(OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
r := gin.New()
g := r.Group("/api/v1")
h.RegisterRoutes(g)
body := `{"slug": ""}`
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/presets/pull", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
require.Equal(t, http.StatusBadRequest, w.Code)
require.Contains(t, w.Body.String(), "slug required")
}
func TestCrowdsecHandler_PullPreset_HubUnavailable(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Setenv("FEATURE_CERBERUS_ENABLED", "true")
h := NewCrowdsecHandler(OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
h.Hub = nil // Simulate hub unavailable
r := gin.New()
g := r.Group("/api/v1")
h.RegisterRoutes(g)
body := `{"slug": "test-slug"}`
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/presets/pull", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
require.Equal(t, http.StatusServiceUnavailable, w.Code)
require.Contains(t, w.Body.String(), "hub service unavailable")
}
func TestCrowdsecHandler_ApplyPreset_CerberusDisabled(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Setenv("FEATURE_CERBERUS_ENABLED", "false")
h := NewCrowdsecHandler(OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
r := gin.New()
g := r.Group("/api/v1")
h.RegisterRoutes(g)
body := `{"slug": "test-slug"}`
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/presets/apply", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
require.Equal(t, http.StatusNotFound, w.Code)
require.Contains(t, w.Body.String(), "cerberus disabled")
}
func TestCrowdsecHandler_ApplyPreset_InvalidPayload(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Setenv("FEATURE_CERBERUS_ENABLED", "true")
h := NewCrowdsecHandler(OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
r := gin.New()
g := r.Group("/api/v1")
h.RegisterRoutes(g)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/presets/apply", strings.NewReader("not-json"))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
require.Equal(t, http.StatusBadRequest, w.Code)
require.Contains(t, w.Body.String(), "invalid payload")
}
func TestCrowdsecHandler_ApplyPreset_EmptySlug(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Setenv("FEATURE_CERBERUS_ENABLED", "true")
h := NewCrowdsecHandler(OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
r := gin.New()
g := r.Group("/api/v1")
h.RegisterRoutes(g)
body := `{"slug": " "}`
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/presets/apply", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
require.Equal(t, http.StatusBadRequest, w.Code)
require.Contains(t, w.Body.String(), "slug required")
}
func TestCrowdsecHandler_ApplyPreset_HubUnavailable(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Setenv("FEATURE_CERBERUS_ENABLED", "true")
h := NewCrowdsecHandler(OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
h.Hub = nil // Simulate hub unavailable
r := gin.New()
g := r.Group("/api/v1")
h.RegisterRoutes(g)
body := `{"slug": "test-slug"}`
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/presets/apply", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
require.Equal(t, http.StatusServiceUnavailable, w.Code)
require.Contains(t, w.Body.String(), "hub service unavailable")
}
func TestCrowdsecHandler_UpdateAcquisitionConfig_MissingContent(t *testing.T) {
t.Parallel()
gin.SetMode(gin.TestMode)
h := NewCrowdsecHandler(OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
r := gin.New()
g := r.Group("/api/v1")
h.RegisterRoutes(g)
body := `{}`
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPut, "/api/v1/admin/crowdsec/acquisition", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
require.Equal(t, http.StatusBadRequest, w.Code)
require.Contains(t, w.Body.String(), "content is required")
}
func TestCrowdsecHandler_UpdateAcquisitionConfig_InvalidJSON(t *testing.T) {
t.Parallel()
gin.SetMode(gin.TestMode)
h := NewCrowdsecHandler(OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
r := gin.New()
g := r.Group("/api/v1")
h.RegisterRoutes(g)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPut, "/api/v1/admin/crowdsec/acquisition", strings.NewReader("not-json"))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
require.Equal(t, http.StatusBadRequest, w.Code)
}
func TestCrowdsecHandler_ListDecisions_WithConfigYaml(t *testing.T) {
t.Parallel()
gin.SetMode(gin.TestMode)
tmpDir := t.TempDir()
// Create config.yaml to trigger the config path code
require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "config.yaml"), []byte("# test config"), 0o644))
mockExec := &mockCmdExecutor{
output: []byte(`[{"id": 1, "origin": "cscli", "type": "ban", "scope": "ip", "value": "10.0.0.1"}]`),
err: nil,
}
db := setupCrowdDB(t)
h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", tmpDir)
h.CmdExec = mockExec
r := gin.New()
g := r.Group("/api/v1")
h.RegisterRoutes(g)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/decisions", http.NoBody)
r.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
// Verify the -c flag was passed
require.NotEmpty(t, mockExec.calls)
foundConfigFlag := false
for _, call := range mockExec.calls {
for i, arg := range call.args {
if arg == "-c" && i+1 < len(call.args) {
foundConfigFlag = true
break
}
}
}
require.True(t, foundConfigFlag, "Expected -c flag to be passed when config.yaml exists")
}
func TestCrowdsecHandler_BanIP_WithConfigYaml(t *testing.T) {
t.Parallel()
gin.SetMode(gin.TestMode)
tmpDir := t.TempDir()
// Create config.yaml to trigger the config path code
require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "config.yaml"), []byte("# test config"), 0o644))
mockExec := &mockCmdExecutor{
output: []byte("Decision created"),
err: nil,
}
db := setupCrowdDB(t)
h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", tmpDir)
h.CmdExec = mockExec
r := gin.New()
g := r.Group("/api/v1")
h.RegisterRoutes(g)
body := `{"ip": "192.168.1.100"}`
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/ban", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
}
func TestCrowdsecHandler_UnbanIP_WithConfigYaml(t *testing.T) {
t.Parallel()
gin.SetMode(gin.TestMode)
tmpDir := t.TempDir()
// Create config.yaml to trigger the config path code
require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "config.yaml"), []byte("# test config"), 0o644))
mockExec := &mockCmdExecutor{
output: []byte("Decision deleted"),
err: nil,
}
db := setupCrowdDB(t)
h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", tmpDir)
h.CmdExec = mockExec
r := gin.New()
g := r.Group("/api/v1")
h.RegisterRoutes(g)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodDelete, "/api/v1/admin/crowdsec/ban/192.168.1.100", http.NoBody)
r.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
}
func TestCrowdsecHandler_Status_LAPIReady(t *testing.T) {
t.Parallel()
gin.SetMode(gin.TestMode)
tmpDir := t.TempDir()
// Create config.yaml
require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "config.yaml"), []byte("# test config"), 0o644))
// Mock executor that returns success for LAPI status
mockExec := &mockCmdExecutor{
output: []byte("LAPI OK"),
err: nil,
}
// fakeExec that reports running
fe := &fakeExec{started: true}
db := setupCrowdDB(t)
h := NewCrowdsecHandler(db, fe, "/bin/false", tmpDir)
h.CmdExec = mockExec
r := gin.New()
g := r.Group("/api/v1")
h.RegisterRoutes(g)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/status", http.NoBody)
r.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
var resp map[string]any
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
require.True(t, resp["running"].(bool))
require.True(t, resp["lapi_ready"].(bool))
}
func TestCrowdsecHandler_Status_LAPINotReady(t *testing.T) {
t.Parallel()
gin.SetMode(gin.TestMode)
tmpDir := t.TempDir()
// Mock executor that returns error for LAPI status
mockExec := &mockCmdExecutor{
output: []byte("error: LAPI unavailable"),
err: errors.New("lapi check failed"),
}
// fakeExec that reports running
fe := &fakeExec{started: true}
db := setupCrowdDB(t)
h := NewCrowdsecHandler(db, fe, "/bin/false", tmpDir)
h.CmdExec = mockExec
r := gin.New()
g := r.Group("/api/v1")
h.RegisterRoutes(g)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/status", http.NoBody)
r.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
var resp map[string]any
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
require.True(t, resp["running"].(bool))
require.False(t, resp["lapi_ready"].(bool))
}
func TestCrowdsecHandler_ListDecisions_WithCreatedAt(t *testing.T) {
t.Parallel()
gin.SetMode(gin.TestMode)
// Mock executor that returns decisions with created_at field
mockExec := &mockCmdExecutor{
output: []byte(`[{"id": 1, "origin": "cscli", "type": "ban", "scope": "ip", "value": "10.0.0.1", "created_at": "2024-01-01T12:00:00Z", "until": "2024-01-02T12:00:00Z"}]`),
err: nil,
}
db := setupCrowdDB(t)
h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", t.TempDir())
h.CmdExec = mockExec
r := gin.New()
g := r.Group("/api/v1")
h.RegisterRoutes(g)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/decisions", http.NoBody)
r.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
var resp map[string]any
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
decisions := resp["decisions"].([]any)
require.Len(t, decisions, 1)
decision := decisions[0].(map[string]any)
require.Equal(t, "2024-01-02T12:00:00Z", decision["until"])
}
// Note: TestTTLRemainingSeconds, TestMapCrowdsecStatus, TestActorFromContext
// are defined in crowdsec_handler_comprehensive_test.go
func TestCrowdsecHandler_HubEndpoints(t *testing.T) {
t.Parallel()
gin.SetMode(gin.TestMode)
// Test with nil Hub
h := &CrowdsecHandler{Hub: nil}
endpoints := h.hubEndpoints()
require.Nil(t, endpoints)
// Test with Hub having base URLs
db := setupCrowdDB(t)
h2 := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", t.TempDir())
endpoints2 := h2.hubEndpoints()
// Hub is initialized with default URLs
require.NotNil(t, endpoints2)
}
func TestCrowdsecHandler_ConsoleEnroll_ProgressConflict(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Setenv("FEATURE_CROWDSEC_CONSOLE_ENROLLMENT", "true")
h, _ := setupTestConsoleEnrollment(t)
// First enroll to create an "in progress" state
body := `{"enrollment_key": "abc123456789", "agent_name": "test-agent-1"}`
r := gin.New()
g := r.Group("/api/v1")
h.RegisterRoutes(g)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/console/enroll", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
// Try to enroll again without force - should succeed or conflict based on state
w2 := httptest.NewRecorder()
req2 := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/console/enroll", strings.NewReader(body))
req2.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w2, req2)
// May succeed or return conflict depending on implementation
require.True(t, w2.Code == http.StatusOK || w2.Code == http.StatusConflict)
}
func TestCrowdsecHandler_GetCachedPreset_CerberusDisabled(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Setenv("FEATURE_CERBERUS_ENABLED", "false")

View File

@@ -782,3 +782,145 @@ func TestEncryptionHandler_RefreshKey_InvalidOldKey(t *testing.T) {
// Should have failure count > 0 due to decryption error
assert.Greater(t, result.FailureCount, 0)
}
// TestEncryptionHandler_GetActorFromGinContext_InvalidType tests getActorFromGinContext with invalid type
func TestEncryptionHandler_GetActorFromGinContext_InvalidType(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
var capturedActor string
router.Use(func(c *gin.Context) {
c.Set("user_id", int64(999)) // int64 instead of uint or string
c.Next()
})
router.GET("/test", func(c *gin.Context) {
capturedActor = getActorFromGinContext(c)
c.Status(http.StatusOK)
})
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/test", nil)
router.ServeHTTP(w, req)
// Invalid type should return "system" as fallback
assert.Equal(t, "system", capturedActor)
}
// TestEncryptionHandler_RotateWithPartialFailures tests rotation that has some successes and failures
func TestEncryptionHandler_RotateWithPartialFailures(t *testing.T) {
db := setupEncryptionTestDB(t)
// Generate test keys
currentKey, err := crypto.GenerateNewKey()
require.NoError(t, err)
nextKey, err := crypto.GenerateNewKey()
require.NoError(t, err)
os.Setenv("CHARON_ENCRYPTION_KEY", currentKey)
os.Setenv("CHARON_ENCRYPTION_KEY_NEXT", nextKey)
defer func() {
os.Unsetenv("CHARON_ENCRYPTION_KEY")
os.Unsetenv("CHARON_ENCRYPTION_KEY_NEXT")
}()
// Create a valid provider
currentService, err := crypto.NewEncryptionService(currentKey)
require.NoError(t, err)
validCreds := map[string]string{"api_key": "valid123"}
credJSON, _ := json.Marshal(validCreds)
validEncrypted, _ := currentService.Encrypt(credJSON)
validProvider := models.DNSProvider{
UUID: "valid-provider-uuid",
Name: "Valid Provider",
ProviderType: "cloudflare",
CredentialsEncrypted: validEncrypted,
KeyVersion: 1,
}
require.NoError(t, db.Create(&validProvider).Error)
// Create an invalid provider (corrupted encrypted data)
invalidProvider := models.DNSProvider{
UUID: "invalid-provider-uuid",
Name: "Invalid Provider",
ProviderType: "route53",
CredentialsEncrypted: "corrupted-data-that-cannot-be-decrypted",
KeyVersion: 1,
}
require.NoError(t, db.Create(&invalidProvider).Error)
rotationService, err := crypto.NewRotationService(db)
require.NoError(t, err)
securityService := services.NewSecurityService(db)
defer securityService.Close()
handler := NewEncryptionHandler(rotationService, securityService)
router := setupEncryptionTestRouter(handler, true)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/v1/admin/encryption/rotate", nil)
router.ServeHTTP(w, req)
securityService.Flush()
assert.Equal(t, http.StatusOK, w.Code)
var result crypto.RotationResult
err = json.Unmarshal(w.Body.Bytes(), &result)
require.NoError(t, err)
// Should have at least 2 providers attempted
assert.Equal(t, 2, result.TotalProviders)
// Should have at least 1 success (valid provider)
assert.GreaterOrEqual(t, result.SuccessCount, 1)
// Should have at least 1 failure (invalid provider)
assert.GreaterOrEqual(t, result.FailureCount, 1)
// Failed providers list should be populated
assert.NotEmpty(t, result.FailedProviders)
}
// TestEncryptionHandler_isAdmin_NoRoleSet tests isAdmin when no role is set
func TestEncryptionHandler_isAdmin_NoRoleSet(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
// No middleware setting user_role
router.GET("/test", func(c *gin.Context) {
if isAdmin(c) {
c.JSON(http.StatusOK, gin.H{"admin": true})
} else {
c.JSON(http.StatusForbidden, gin.H{"admin": false})
}
})
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/test", nil)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusForbidden, w.Code)
}
// TestEncryptionHandler_isAdmin_NonAdminRole tests isAdmin with non-admin role
func TestEncryptionHandler_isAdmin_NonAdminRole(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set("user_role", "user") // Regular user, not admin
c.Next()
})
router.GET("/test", func(c *gin.Context) {
if isAdmin(c) {
c.JSON(http.StatusOK, gin.H{"admin": true})
} else {
c.JSON(http.StatusForbidden, gin.H{"admin": false})
}
})
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/test", nil)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusForbidden, w.Code)
}

View File

@@ -857,3 +857,175 @@ func TestPluginHandler_Count(t *testing.T) {
// ReloadPlugins: 2 (Success, WithErrors)
// Total: 20+ tests ✓
}
// =============================================================================
// Additional DB Error Path Tests for coverage
// =============================================================================
// TestPluginHandler_EnablePlugin_DBUpdateError tests DB error when updating plugin enabled status
func TestPluginHandler_EnablePlugin_DBUpdateError(t *testing.T) {
gin.SetMode(gin.TestMode)
db := OpenTestDBWithMigrations(t)
pluginLoader := services.NewPluginLoaderService(db, "/tmp/plugins", nil)
plugin := models.Plugin{
UUID: "plugin-db-error",
Name: "DB Error Plugin",
Type: "db-error-type",
Enabled: false,
Status: models.PluginStatusError,
FilePath: "/path/to/dberror.so",
}
db.Create(&plugin)
handler := NewPluginHandler(db, pluginLoader)
// Close the underlying connection to simulate DB error
sqlDB, _ := db.DB()
sqlDB.Close()
router := gin.New()
router.POST("/plugins/:id/enable", handler.EnablePlugin)
req := httptest.NewRequest(http.MethodPost, "/plugins/1/enable", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
// Should return 500 internal server error
assert.Equal(t, http.StatusInternalServerError, w.Code)
}
// TestPluginHandler_DisablePlugin_DBUpdateError tests DB error when updating plugin disabled status
func TestPluginHandler_DisablePlugin_DBUpdateError(t *testing.T) {
gin.SetMode(gin.TestMode)
db := OpenTestDBWithMigrations(t)
pluginLoader := services.NewPluginLoaderService(db, "/tmp/plugins", nil)
plugin := models.Plugin{
UUID: "plugin-disable-error",
Name: "Disable Error Plugin",
Type: "disable-error-type",
Enabled: true,
Status: models.PluginStatusLoaded,
FilePath: "/path/to/disableerror.so",
}
db.Create(&plugin)
handler := NewPluginHandler(db, pluginLoader)
// Close the underlying connection to simulate DB error
sqlDB, _ := db.DB()
sqlDB.Close()
router := gin.New()
router.POST("/plugins/:id/disable", handler.DisablePlugin)
req := httptest.NewRequest(http.MethodPost, "/plugins/1/disable", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
// Should return 500 internal server error
assert.Equal(t, http.StatusInternalServerError, w.Code)
}
// TestPluginHandler_GetPlugin_DBInternalError tests DB internal error when getting a plugin
func TestPluginHandler_GetPlugin_DBInternalError(t *testing.T) {
gin.SetMode(gin.TestMode)
db := OpenTestDBWithMigrations(t)
pluginLoader := services.NewPluginLoaderService(db, "/tmp/plugins", nil)
// Create a plugin first
plugin := models.Plugin{
UUID: "plugin-get-error",
Name: "Get Error Plugin",
Type: "get-error-type",
Enabled: true,
FilePath: "/path/to/geterror.so",
}
db.Create(&plugin)
handler := NewPluginHandler(db, pluginLoader)
// Close the underlying connection to simulate DB error
sqlDB, _ := db.DB()
sqlDB.Close()
router := gin.New()
router.GET("/plugins/:id", handler.GetPlugin)
req := httptest.NewRequest(http.MethodGet, "/plugins/1", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
// Should return 500 internal server error
assert.Equal(t, http.StatusInternalServerError, w.Code)
assert.Contains(t, w.Body.String(), "Failed to get plugin")
}
// TestPluginHandler_EnablePlugin_FirstDBLookupError tests DB error in first plugin lookup
func TestPluginHandler_EnablePlugin_FirstDBLookupError(t *testing.T) {
gin.SetMode(gin.TestMode)
db := OpenTestDBWithMigrations(t)
pluginLoader := services.NewPluginLoaderService(db, "/tmp/plugins", nil)
// Create a plugin
plugin := models.Plugin{
UUID: "plugin-first-lookup",
Name: "First Lookup Plugin",
Type: "first-lookup-type",
Enabled: false,
FilePath: "/path/to/firstlookup.so",
}
db.Create(&plugin)
handler := NewPluginHandler(db, pluginLoader)
// Close the underlying connection to simulate DB error
sqlDB, _ := db.DB()
sqlDB.Close()
router := gin.New()
router.POST("/plugins/:id/enable", handler.EnablePlugin)
req := httptest.NewRequest(http.MethodPost, "/plugins/1/enable", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
// Should return 500 internal server error (DB lookup failure)
assert.Equal(t, http.StatusInternalServerError, w.Code)
assert.Contains(t, w.Body.String(), "Failed to get plugin")
}
// TestPluginHandler_DisablePlugin_FirstDBLookupError tests DB error in first plugin lookup during disable
func TestPluginHandler_DisablePlugin_FirstDBLookupError(t *testing.T) {
gin.SetMode(gin.TestMode)
db := OpenTestDBWithMigrations(t)
pluginLoader := services.NewPluginLoaderService(db, "/tmp/plugins", nil)
// Create a plugin
plugin := models.Plugin{
UUID: "plugin-disable-lookup",
Name: "Disable Lookup Plugin",
Type: "disable-lookup-type",
Enabled: true,
FilePath: "/path/to/disablelookup.so",
}
db.Create(&plugin)
handler := NewPluginHandler(db, pluginLoader)
// Close the underlying connection to simulate DB error
sqlDB, _ := db.DB()
sqlDB.Close()
router := gin.New()
router.POST("/plugins/:id/disable", handler.DisablePlugin)
req := httptest.NewRequest(http.MethodPost, "/plugins/1/disable", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
// Should return 500 internal server error (DB lookup failure)
assert.Equal(t, http.StatusInternalServerError, w.Code)
assert.Contains(t, w.Body.String(), "Failed to get plugin")
}

View File

@@ -0,0 +1,842 @@
package handlers
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"github.com/Wikid82/charon/backend/internal/crypto"
"github.com/Wikid82/charon/backend/internal/models"
"github.com/Wikid82/charon/backend/internal/services"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
// =============================================================================
// Additional Plugin Handler Tests for Coverage
// =============================================================================
func TestPluginHandler_EnablePlugin_DatabaseUpdateError(t *testing.T) {
gin.SetMode(gin.TestMode)
db := OpenTestDBWithMigrations(t)
pluginLoader := services.NewPluginLoaderService(db, "/tmp/plugins", nil)
// Create plugin
plugin := models.Plugin{
UUID: "plugin-db-error-uuid",
Name: "Test Plugin",
Type: "test-type",
Enabled: false,
Status: models.PluginStatusError,
FilePath: "/nonexistent/path.so",
}
db.Create(&plugin)
handler := NewPluginHandler(db, pluginLoader)
// Close DB to trigger error during update
sqlDB, _ := db.DB()
sqlDB.Close()
router := gin.New()
router.POST("/plugins/:id/enable", handler.EnablePlugin)
req := httptest.NewRequest(http.MethodPost, "/plugins/1/enable", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusInternalServerError, w.Code)
}
func TestPluginHandler_DisablePlugin_DatabaseUpdateError(t *testing.T) {
gin.SetMode(gin.TestMode)
db := OpenTestDBWithMigrations(t)
pluginLoader := services.NewPluginLoaderService(db, "/tmp/plugins", nil)
// Create plugin
plugin := models.Plugin{
UUID: "plugin-disable-error-uuid",
Name: "Test Plugin",
Type: "test-type-disable",
Enabled: true,
Status: models.PluginStatusLoaded,
FilePath: "/path/to/plugin.so",
}
db.Create(&plugin)
handler := NewPluginHandler(db, pluginLoader)
// Close DB to trigger error during update
sqlDB, _ := db.DB()
sqlDB.Close()
router := gin.New()
router.POST("/plugins/:id/disable", handler.DisablePlugin)
req := httptest.NewRequest(http.MethodPost, "/plugins/1/disable", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusInternalServerError, w.Code)
}
func TestPluginHandler_GetPlugin_DatabaseError(t *testing.T) {
gin.SetMode(gin.TestMode)
db := OpenTestDBWithMigrations(t)
pluginLoader := services.NewPluginLoaderService(db, "/tmp/plugins", nil)
// Create plugin first
plugin := models.Plugin{
UUID: "get-error-uuid",
Name: "Get Error",
Type: "get-error-type",
Enabled: true,
FilePath: "/path/to/get.so",
}
db.Create(&plugin)
handler := NewPluginHandler(db, pluginLoader)
// Close DB to trigger database error
sqlDB, _ := db.DB()
sqlDB.Close()
router := gin.New()
router.GET("/plugins/:id", handler.GetPlugin)
req := httptest.NewRequest(http.MethodGet, "/plugins/1", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusInternalServerError, w.Code)
assert.Contains(t, w.Body.String(), "Failed to get plugin")
}
func TestPluginHandler_EnablePlugin_DatabaseFirstError(t *testing.T) {
gin.SetMode(gin.TestMode)
db := OpenTestDBWithMigrations(t)
pluginLoader := services.NewPluginLoaderService(db, "/tmp/plugins", nil)
handler := NewPluginHandler(db, pluginLoader)
// Close DB to trigger error when fetching plugin
sqlDB, _ := db.DB()
sqlDB.Close()
router := gin.New()
router.POST("/plugins/:id/enable", handler.EnablePlugin)
req := httptest.NewRequest(http.MethodPost, "/plugins/1/enable", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusInternalServerError, w.Code)
assert.Contains(t, w.Body.String(), "Failed to get plugin")
}
func TestPluginHandler_DisablePlugin_DatabaseFirstError(t *testing.T) {
gin.SetMode(gin.TestMode)
db := OpenTestDBWithMigrations(t)
pluginLoader := services.NewPluginLoaderService(db, "/tmp/plugins", nil)
handler := NewPluginHandler(db, pluginLoader)
// Close DB to trigger error when fetching plugin
sqlDB, _ := db.DB()
sqlDB.Close()
router := gin.New()
router.POST("/plugins/:id/disable", handler.DisablePlugin)
req := httptest.NewRequest(http.MethodPost, "/plugins/1/disable", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusInternalServerError, w.Code)
assert.Contains(t, w.Body.String(), "Failed to get plugin")
}
// =============================================================================
// Encryption Handler - Additional Coverage Tests
// =============================================================================
func TestEncryptionHandler_Validate_NonAdminAccess(t *testing.T) {
gin.SetMode(gin.TestMode)
currentKey, _ := crypto.GenerateNewKey()
os.Setenv("CHARON_ENCRYPTION_KEY", currentKey)
defer os.Unsetenv("CHARON_ENCRYPTION_KEY")
db := setupEncryptionTestDB(t)
rotationService, _ := crypto.NewRotationService(db)
securityService := services.NewSecurityService(db)
defer securityService.Close()
handler := NewEncryptionHandler(rotationService, securityService)
router := setupEncryptionTestRouter(handler, false)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/v1/admin/encryption/validate", nil)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusForbidden, w.Code)
}
func TestEncryptionHandler_GetHistory_PaginationBoundary(t *testing.T) {
gin.SetMode(gin.TestMode)
currentKey, _ := crypto.GenerateNewKey()
os.Setenv("CHARON_ENCRYPTION_KEY", currentKey)
defer os.Unsetenv("CHARON_ENCRYPTION_KEY")
db := setupEncryptionTestDB(t)
rotationService, _ := crypto.NewRotationService(db)
securityService := services.NewSecurityService(db)
defer securityService.Close()
handler := NewEncryptionHandler(rotationService, securityService)
router := setupEncryptionTestRouter(handler, true)
// Test invalid page number (negative)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/v1/admin/encryption/history?page=-1&limit=10", nil)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
// Test limit exceeding max (should clamp)
w = httptest.NewRecorder()
req, _ = http.NewRequest("GET", "/api/v1/admin/encryption/history?page=1&limit=200", nil)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var response map[string]any
json.Unmarshal(w.Body.Bytes(), &response)
// limit should not exceed 100
assert.LessOrEqual(t, response["limit"].(float64), float64(100))
}
func TestEncryptionHandler_GetStatus_VersionInfo(t *testing.T) {
gin.SetMode(gin.TestMode)
currentKey, _ := crypto.GenerateNewKey()
os.Setenv("CHARON_ENCRYPTION_KEY", currentKey)
defer func() {
os.Unsetenv("CHARON_ENCRYPTION_KEY")
}()
db := setupEncryptionTestDB(t)
rotationService, _ := crypto.NewRotationService(db)
securityService := services.NewSecurityService(db)
defer securityService.Close()
handler := NewEncryptionHandler(rotationService, securityService)
router := setupEncryptionTestRouter(handler, true)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/v1/admin/encryption/status", nil)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var status crypto.RotationStatus
err := json.Unmarshal(w.Body.Bytes(), &status)
assert.NoError(t, err)
// Verify the status response has expected fields
assert.True(t, status.CurrentVersion >= 1)
}
// =============================================================================
// Settings Handler - Additional Unique Coverage Tests
// =============================================================================
func TestSettingsHandler_TestPublicURL_RoleNotExists(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
require.NoError(t, err)
handler := NewSettingsHandler(db)
router := gin.New()
// Don't set any role
router.POST("/test-url", handler.TestPublicURL)
body := `{"url": "https://example.com"}`
req, _ := http.NewRequest("POST", "/test-url", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusForbidden, w.Code)
}
func TestSettingsHandler_TestPublicURL_InvalidURLFormat(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
require.NoError(t, err)
handler := NewSettingsHandler(db)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
router.POST("/test-url", handler.TestPublicURL)
body := `{"url": "not-a-valid-url"}`
req, _ := http.NewRequest("POST", "/test-url", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
}
func TestSettingsHandler_TestPublicURL_PrivateIPBlocked_Coverage(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
require.NoError(t, err)
handler := NewSettingsHandler(db)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
router.POST("/test-url", handler.TestPublicURL)
// SSRF attempt with private IP
body := `{"url": "http://192.168.1.1"}`
req, _ := http.NewRequest("POST", "/test-url", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
// Should return 200 but with reachable=false due to SSRF protection
assert.Equal(t, http.StatusOK, w.Code)
var response map[string]any
json.Unmarshal(w.Body.Bytes(), &response)
assert.False(t, response["reachable"].(bool))
}
func TestSettingsHandler_ValidatePublicURL_WithTrailingSlash(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
require.NoError(t, err)
handler := NewSettingsHandler(db)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
router.POST("/validate-url", handler.ValidatePublicURL)
// URL with trailing slash (should normalize and may produce warning)
body := `{"url": "https://example.com/"}`
req, _ := http.NewRequest("POST", "/validate-url", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var response map[string]any
json.Unmarshal(w.Body.Bytes(), &response)
assert.True(t, response["valid"].(bool))
}
func TestSettingsHandler_ValidatePublicURL_MissingScheme(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
require.NoError(t, err)
handler := NewSettingsHandler(db)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
router.POST("/validate-url", handler.ValidatePublicURL)
// Invalid URL (missing scheme)
body := `{"url": "example.com"}`
req, _ := http.NewRequest("POST", "/validate-url", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
var response map[string]any
json.Unmarshal(w.Body.Bytes(), &response)
assert.False(t, response["valid"].(bool))
}
// =============================================================================
// Audit Log Handler - Additional Coverage Tests
// =============================================================================
func TestAuditLogHandler_List_PaginationEdgeCases(t *testing.T) {
gin.SetMode(gin.TestMode)
dbPath := fmt.Sprintf("/tmp/test_audit_pagination_%d.db", time.Now().UnixNano())
t.Cleanup(func() { os.Remove(dbPath) })
db, _ := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
db.AutoMigrate(&models.SecurityAudit{}, &models.DNSProvider{})
// Create test audits
for i := 0; i < 10; i++ {
db.Create(&models.SecurityAudit{
Actor: "user1",
Action: fmt.Sprintf("action_%d", i),
EventCategory: "test",
Details: "{}",
})
}
secService := services.NewSecurityService(db)
defer secService.Close()
handler := NewAuditLogHandler(secService)
router := gin.New()
router.GET("/audit", handler.List)
// Test with pagination
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/audit?page=2&limit=3", nil)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
}
func TestAuditLogHandler_List_CategoryFilter(t *testing.T) {
gin.SetMode(gin.TestMode)
dbPath := fmt.Sprintf("/tmp/test_audit_category_%d.db", time.Now().UnixNano())
t.Cleanup(func() { os.Remove(dbPath) })
db, _ := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
db.AutoMigrate(&models.SecurityAudit{}, &models.DNSProvider{})
// Create test audits with different categories
db.Create(&models.SecurityAudit{
Actor: "user1",
Action: "action1",
EventCategory: "encryption",
Details: "{}",
})
db.Create(&models.SecurityAudit{
Actor: "user2",
Action: "action2",
EventCategory: "security",
Details: "{}",
})
secService := services.NewSecurityService(db)
defer secService.Close()
handler := NewAuditLogHandler(secService)
router := gin.New()
router.GET("/audit", handler.List)
// Test with category filter
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/audit?category=encryption", nil)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
}
func TestAuditLogHandler_ListByProvider_DatabaseError(t *testing.T) {
gin.SetMode(gin.TestMode)
dbPath := fmt.Sprintf("/tmp/test_audit_db_error_%d.db", time.Now().UnixNano())
t.Cleanup(func() { os.Remove(dbPath) })
db, _ := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
db.AutoMigrate(&models.SecurityAudit{}, &models.DNSProvider{})
secService := services.NewSecurityService(db)
defer secService.Close()
handler := NewAuditLogHandler(secService)
// Close DB to trigger error
sqlDB, _ := db.DB()
sqlDB.Close()
router := gin.New()
router.GET("/audit/provider/:id", handler.ListByProvider)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/audit/provider/1", nil)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusInternalServerError, w.Code)
}
func TestAuditLogHandler_ListByProvider_InvalidProviderID(t *testing.T) {
gin.SetMode(gin.TestMode)
dbPath := fmt.Sprintf("/tmp/test_audit_invalid_id_%d.db", time.Now().UnixNano())
t.Cleanup(func() { os.Remove(dbPath) })
db, _ := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
db.AutoMigrate(&models.SecurityAudit{}, &models.DNSProvider{})
secService := services.NewSecurityService(db)
defer secService.Close()
handler := NewAuditLogHandler(secService)
router := gin.New()
router.GET("/audit/provider/:id", handler.ListByProvider)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/audit/provider/invalid", nil)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
}
// =============================================================================
// getActorFromGinContext Additional Coverage
// =============================================================================
func TestGetActorFromGinContext_InvalidUserIDType(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
var capturedActor string
router.Use(func(c *gin.Context) {
c.Set("user_id", 123.45) // float - invalid type
c.Next()
})
router.GET("/test", func(c *gin.Context) {
capturedActor = getActorFromGinContext(c)
c.Status(http.StatusOK)
})
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/test", nil)
router.ServeHTTP(w, req)
// Should fall back to "system" for invalid type
assert.Equal(t, "system", capturedActor)
}
// =============================================================================
// isAdmin Additional Coverage
// =============================================================================
func TestIsAdmin_NonAdminRole(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set("user_role", "user") // Not admin
c.Next()
})
router.GET("/test", func(c *gin.Context) {
if isAdmin(c) {
c.JSON(http.StatusOK, gin.H{"admin": true})
} else {
c.JSON(http.StatusForbidden, gin.H{"admin": false})
}
})
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/test", nil)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusForbidden, w.Code)
}
// =============================================================================
// Credential Handler - Additional Coverage Tests
// =============================================================================
func setupCredentialHandlerTestWithCtx(t *testing.T) (*gin.Engine, *gorm.DB, *models.DNSProvider, context.Context) {
os.Setenv("CHARON_ENCRYPTION_KEY", "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=")
t.Cleanup(func() { os.Unsetenv("CHARON_ENCRYPTION_KEY") })
gin.SetMode(gin.TestMode)
router := gin.New()
dbName := fmt.Sprintf("file:%s?mode=memory&cache=shared&_journal_mode=WAL", t.Name())
db, err := gorm.Open(sqlite.Open(dbName), &gorm.Config{})
require.NoError(t, err)
t.Cleanup(func() {
sqlDB, _ := db.DB()
sqlDB.Close()
})
err = db.AutoMigrate(
&models.DNSProvider{},
&models.DNSProviderCredential{},
&models.SecurityAudit{},
)
require.NoError(t, err)
testKey := "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY="
encryptor, _ := crypto.NewEncryptionService(testKey)
creds := map[string]string{"api_token": "test-token"}
credsJSON, _ := json.Marshal(creds)
encrypted, _ := encryptor.Encrypt(credsJSON)
provider := &models.DNSProvider{
UUID: "test-uuid",
Name: "Test Provider",
ProviderType: "cloudflare",
Enabled: true,
UseMultiCredentials: true,
CredentialsEncrypted: encrypted,
KeyVersion: 1,
}
db.Create(provider)
credService := services.NewCredentialService(db, encryptor)
credHandler := NewCredentialHandler(credService)
router.GET("/api/v1/dns-providers/:id/credentials", credHandler.List)
router.POST("/api/v1/dns-providers/:id/credentials", credHandler.Create)
router.GET("/api/v1/dns-providers/:id/credentials/:cred_id", credHandler.Get)
router.PUT("/api/v1/dns-providers/:id/credentials/:cred_id", credHandler.Update)
router.DELETE("/api/v1/dns-providers/:id/credentials/:cred_id", credHandler.Delete)
router.POST("/api/v1/dns-providers/:id/credentials/:cred_id/test", credHandler.Test)
router.POST("/api/v1/dns-providers/:id/enable-multi-credentials", credHandler.EnableMultiCredentials)
return router, db, provider, context.Background()
}
func TestCredentialHandler_Update_InvalidProviderType(t *testing.T) {
router, db, _, _ := setupCredentialHandlerTestWithCtx(t)
testKey := "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY="
encryptor, _ := crypto.NewEncryptionService(testKey)
// Create provider with invalid type
creds := map[string]string{"api_token": "test-token"}
credsJSON, _ := json.Marshal(creds)
encrypted, _ := encryptor.Encrypt(credsJSON)
provider := &models.DNSProvider{
UUID: "invalid-type-uuid",
Name: "Invalid Type Provider",
ProviderType: "nonexistent-provider",
Enabled: true,
UseMultiCredentials: true,
CredentialsEncrypted: encrypted,
KeyVersion: 1,
}
db.Create(provider)
// Create credential
credService := services.NewCredentialService(db, encryptor)
createReq := services.CreateCredentialRequest{
Label: "Original",
Credentials: map[string]string{"api_token": "token"},
}
// This should fail because provider type doesn't exist
_, err := credService.Create(context.Background(), provider.ID, createReq)
if err != nil {
// Expected - provider type validation fails
return
}
// If it didn't fail, try update with bad credentials
updateBody := `{"label":"Updated","credentials":{"invalid_field":"value"}}`
url := fmt.Sprintf("/api/v1/dns-providers/%d/credentials/1", provider.ID)
req, _ := http.NewRequest("PUT", url, strings.NewReader(updateBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
}
func TestCredentialHandler_List_DatabaseClosed(t *testing.T) {
os.Setenv("CHARON_ENCRYPTION_KEY", "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=")
defer os.Unsetenv("CHARON_ENCRYPTION_KEY")
gin.SetMode(gin.TestMode)
router := gin.New()
dbName := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
db, _ := gorm.Open(sqlite.Open(dbName), &gorm.Config{})
db.AutoMigrate(&models.DNSProvider{}, &models.DNSProviderCredential{})
testKey := "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY="
encryptor, _ := crypto.NewEncryptionService(testKey)
credService := services.NewCredentialService(db, encryptor)
credHandler := NewCredentialHandler(credService)
router.GET("/api/v1/dns-providers/:id/credentials", credHandler.List)
// Close DB to trigger error
sqlDB, _ := db.DB()
sqlDB.Close()
req, _ := http.NewRequest("GET", "/api/v1/dns-providers/1/credentials", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusInternalServerError, w.Code)
}
// =============================================================================
// Settings Handler - MaskPasswordForTest Coverage (unique test name)
// =============================================================================
func TestSettingsHandler_MaskPasswordForTestFunction(t *testing.T) {
tests := []struct {
name string
password string
expected string
}{
{"empty string", "", ""},
{"non-empty password", "secret123", "********"},
{"already masked", "********", "********"},
{"single char", "x", "********"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := MaskPasswordForTest(tt.password)
assert.Equal(t, tt.expected, result)
})
}
}
// =============================================================================
// Credential Handler - Additional Update/Delete Error Paths (unique names)
// =============================================================================
func TestCredentialHandler_Update_NotFoundError(t *testing.T) {
router, _, provider, _ := setupCredentialHandlerTestWithCtx(t)
updateBody := `{"label":"Updated","credentials":{"api_token":"new-token"}}`
url := fmt.Sprintf("/api/v1/dns-providers/%d/credentials/9999", provider.ID)
req, _ := http.NewRequest("PUT", url, strings.NewReader(updateBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusNotFound, w.Code)
assert.Contains(t, w.Body.String(), "not found")
}
func TestCredentialHandler_Update_MalformedJSON(t *testing.T) {
router, _, provider, _ := setupCredentialHandlerTestWithCtx(t)
url := fmt.Sprintf("/api/v1/dns-providers/%d/credentials/1", provider.ID)
req, _ := http.NewRequest("PUT", url, strings.NewReader("invalid json"))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
}
func TestCredentialHandler_Update_BadCredentialID(t *testing.T) {
router, _, provider, _ := setupCredentialHandlerTestWithCtx(t)
url := fmt.Sprintf("/api/v1/dns-providers/%d/credentials/invalid", provider.ID)
req, _ := http.NewRequest("PUT", url, strings.NewReader(`{}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, w.Body.String(), "Invalid credential ID")
}
func TestCredentialHandler_Delete_NotFoundError(t *testing.T) {
router, _, provider, _ := setupCredentialHandlerTestWithCtx(t)
url := fmt.Sprintf("/api/v1/dns-providers/%d/credentials/9999", provider.ID)
req, _ := http.NewRequest("DELETE", url, nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusNotFound, w.Code)
}
func TestCredentialHandler_Delete_BadCredentialID(t *testing.T) {
router, _, provider, _ := setupCredentialHandlerTestWithCtx(t)
url := fmt.Sprintf("/api/v1/dns-providers/%d/credentials/invalid", provider.ID)
req, _ := http.NewRequest("DELETE", url, nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
}
func TestCredentialHandler_Test_BadCredentialID(t *testing.T) {
router, _, provider, _ := setupCredentialHandlerTestWithCtx(t)
url := fmt.Sprintf("/api/v1/dns-providers/%d/credentials/invalid/test", provider.ID)
req, _ := http.NewRequest("POST", url, nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
}
func TestCredentialHandler_EnableMultiCredentials_BadProviderID(t *testing.T) {
router, _, _, _ := setupCredentialHandlerTestWithCtx(t)
req, _ := http.NewRequest("POST", "/api/v1/dns-providers/invalid/enable-multi-credentials", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
}
// =============================================================================
// Encryption Handler - Additional Validate Success Test
// =============================================================================
func TestEncryptionHandler_Validate_AdminSuccess(t *testing.T) {
gin.SetMode(gin.TestMode)
currentKey, _ := crypto.GenerateNewKey()
os.Setenv("CHARON_ENCRYPTION_KEY", currentKey)
defer os.Unsetenv("CHARON_ENCRYPTION_KEY")
db := setupEncryptionTestDB(t)
rotationService, _ := crypto.NewRotationService(db)
securityService := services.NewSecurityService(db)
defer securityService.Close()
handler := NewEncryptionHandler(rotationService, securityService)
router := setupEncryptionTestRouter(handler, true)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/v1/admin/encryption/validate", nil)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
}

View File

@@ -2,31 +2,27 @@ package handlers
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"github.com/Wikid82/charon/backend/internal/config"
"github.com/Wikid82/charon/backend/internal/models"
)
// setupTestDB creates a test database using the robust OpenTestDB function
// which configures SQLite with WAL journal mode and busy timeout for parallel test execution.
// It pre-migrates Setting and SecurityConfig tables needed by the security handler tests.
func setupTestDB(t *testing.T) *gorm.DB {
// lightweight in-memory DB unique per test run
dsn := fmt.Sprintf("file:security_handler_test_%d?mode=memory&cache=shared", time.Now().UnixNano())
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
if err != nil {
t.Fatalf("failed to open DB: %v", err)
}
t.Helper()
db := OpenTestDB(t)
if err := db.AutoMigrate(&models.Setting{}, &models.SecurityConfig{}); err != nil {
t.Fatalf("failed to migrate: %v", err)
t.Fatalf("failed to migrate test db: %v", err)
}
return db
}

View File

@@ -963,3 +963,107 @@ func TestSettingsHandler_TestPublicURL_InvalidScheme(t *testing.T) {
})
}
}
func TestSettingsHandler_ValidatePublicURL_InvalidJSON(t *testing.T) {
gin.SetMode(gin.TestMode)
handler, _ := setupSettingsHandlerWithMail(t)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
router.POST("/settings/validate-url", handler.ValidatePublicURL)
req, _ := http.NewRequest("POST", "/settings/validate-url", bytes.NewBufferString("not-json"))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
}
func TestSettingsHandler_ValidatePublicURL_URLWithWarning(t *testing.T) {
gin.SetMode(gin.TestMode)
handler, _ := setupSettingsHandlerWithMail(t)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
router.POST("/settings/validate-url", handler.ValidatePublicURL)
// URL with HTTP scheme may generate a warning
body := map[string]string{"url": "http://example.com"}
jsonBody, _ := json.Marshal(body)
req, _ := http.NewRequest("POST", "/settings/validate-url", bytes.NewBuffer(jsonBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var resp map[string]any
json.Unmarshal(w.Body.Bytes(), &resp)
assert.Equal(t, true, resp["valid"])
// May have a warning about HTTP vs HTTPS
}
func TestSettingsHandler_UpdateSMTPConfig_DatabaseError(t *testing.T) {
gin.SetMode(gin.TestMode)
handler, db := setupSettingsHandlerWithMail(t)
// Close the database to force an error
sqlDB, _ := db.DB()
_ = sqlDB.Close()
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
router.PUT("/settings/smtp", handler.UpdateSMTPConfig)
// Include password (not masked) to skip GetSMTPConfig path which would also fail
body := map[string]any{
"host": "smtp.example.com",
"port": 587,
"from_address": "test@example.com",
"encryption": "starttls",
"password": "test-password", // Provide password to skip GetSMTPConfig call
}
jsonBody, _ := json.Marshal(body)
req, _ := http.NewRequest("PUT", "/settings/smtp", bytes.NewBuffer(jsonBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusInternalServerError, w.Code)
assert.Contains(t, w.Body.String(), "Failed to save")
}
func TestSettingsHandler_TestPublicURL_IPv6LocalhostBlocked(t *testing.T) {
gin.SetMode(gin.TestMode)
handler, _ := setupSettingsHandlerWithMail(t)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
router.POST("/settings/test-url", handler.TestPublicURL)
// Test IPv6 loopback address
body := map[string]string{"url": "http://[::1]"}
jsonBody, _ := json.Marshal(body)
req, _ := http.NewRequest("POST", "/settings/test-url", bytes.NewBuffer(jsonBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var resp map[string]any
json.Unmarshal(w.Body.Bytes(), &resp)
assert.False(t, resp["reachable"].(bool))
// IPv6 loopback should be blocked
}

View File

@@ -224,3 +224,805 @@ func TestRegister_DNSProviders_RegisteredWhenEncryptionKeyValid(t *testing.T) {
assert.True(t, paths["/api/v1/dns-providers"], "dns providers list route should be registered")
assert.True(t, paths["/api/v1/dns-providers/types"], "dns providers types route should be registered")
}
func TestRegister_AllRoutesRegistered(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared&_test_all_routes"), &gorm.Config{})
require.NoError(t, err)
cfg := config.Config{
JWTSecret: "test-secret",
EncryptionKey: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
}
require.NoError(t, Register(router, db, cfg))
routes := router.Routes()
routeMap := make(map[string][]string) // path -> methods
for _, r := range routes {
routeMap[r.Path] = append(routeMap[r.Path], r.Method)
}
// Core routes
assert.Contains(t, routeMap, "/api/v1/health")
assert.Contains(t, routeMap, "/metrics")
// Auth routes
assert.Contains(t, routeMap, "/api/v1/auth/login")
assert.Contains(t, routeMap, "/api/v1/auth/register")
assert.Contains(t, routeMap, "/api/v1/auth/verify")
assert.Contains(t, routeMap, "/api/v1/auth/status")
assert.Contains(t, routeMap, "/api/v1/auth/logout")
assert.Contains(t, routeMap, "/api/v1/auth/me")
// User routes
assert.Contains(t, routeMap, "/api/v1/setup")
assert.Contains(t, routeMap, "/api/v1/invite/validate")
assert.Contains(t, routeMap, "/api/v1/invite/accept")
assert.Contains(t, routeMap, "/api/v1/users")
// Settings routes
assert.Contains(t, routeMap, "/api/v1/settings")
assert.Contains(t, routeMap, "/api/v1/settings/smtp")
// Security routes
assert.Contains(t, routeMap, "/api/v1/security/status")
assert.Contains(t, routeMap, "/api/v1/security/config")
assert.Contains(t, routeMap, "/api/v1/audit-logs")
// Notification routes
assert.Contains(t, routeMap, "/api/v1/notifications")
assert.Contains(t, routeMap, "/api/v1/notifications/providers")
// Uptime routes
assert.Contains(t, routeMap, "/api/v1/uptime/monitors")
// DNS Providers routes (when encryption key is set)
assert.Contains(t, routeMap, "/api/v1/dns-providers")
assert.Contains(t, routeMap, "/api/v1/dns-providers/types")
assert.Contains(t, routeMap, "/api/v1/dns-providers/:id/credentials")
// Admin routes - plugins should always be registered
assert.Contains(t, routeMap, "/api/v1/admin/plugins")
// CrowdSec routes
assert.Contains(t, routeMap, "/api/v1/admin/crowdsec/status")
assert.Contains(t, routeMap, "/api/v1/admin/crowdsec/start")
assert.Contains(t, routeMap, "/api/v1/admin/crowdsec/stop")
// Total route count should be substantial
assert.Greater(t, len(routes), 50, "Expected more than 50 routes to be registered")
}
func TestRegister_MiddlewareApplied(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared&_test_middleware"), &gorm.Config{})
require.NoError(t, err)
cfg := config.Config{JWTSecret: "test-secret"}
require.NoError(t, Register(router, db, cfg))
// Test that security headers middleware is applied
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/health", nil)
router.ServeHTTP(w, req)
// Security headers should be present
assert.NotEmpty(t, w.Header().Get("X-Content-Type-Options"))
assert.NotEmpty(t, w.Header().Get("X-Frame-Options"))
// Response should be compressed (gzip middleware applied)
// Note: Only compressed if Accept-Encoding is set
req2 := httptest.NewRequest(http.MethodGet, "/api/v1/health", nil)
req2.Header.Set("Accept-Encoding", "gzip")
w2 := httptest.NewRecorder()
router.ServeHTTP(w2, req2)
// Check for gzip content encoding when response is large enough
assert.Equal(t, http.StatusOK, w2.Code)
}
func TestRegister_AuthenticatedRoutes(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared&_test_auth_routes"), &gorm.Config{})
require.NoError(t, err)
cfg := config.Config{JWTSecret: "test-secret"}
require.NoError(t, Register(router, db, cfg))
// Test that protected routes require authentication
protectedPaths := []struct {
method string
path string
}{
{http.MethodGet, "/api/v1/backups"},
{http.MethodPost, "/api/v1/backups"},
{http.MethodGet, "/api/v1/logs"},
{http.MethodGet, "/api/v1/settings"},
{http.MethodGet, "/api/v1/notifications"},
{http.MethodGet, "/api/v1/users"},
{http.MethodGet, "/api/v1/auth/me"},
{http.MethodPost, "/api/v1/auth/logout"},
{http.MethodGet, "/api/v1/uptime/monitors"},
}
for _, tc := range protectedPaths {
t.Run(tc.method+"_"+tc.path, func(t *testing.T) {
w := httptest.NewRecorder()
req := httptest.NewRequest(tc.method, tc.path, nil)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusUnauthorized, w.Code, "Route %s %s should require auth", tc.method, tc.path)
})
}
}
func TestRegister_AdminRoutes(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared&_test_admin_routes"), &gorm.Config{})
require.NoError(t, err)
cfg := config.Config{
JWTSecret: "test-secret",
EncryptionKey: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
}
require.NoError(t, Register(router, db, cfg))
// Admin routes should exist and require auth
adminPaths := []string{
"/api/v1/admin/plugins",
"/api/v1/admin/crowdsec/status",
}
for _, path := range adminPaths {
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, path, nil)
router.ServeHTTP(w, req)
// Should require auth (401) not be missing (404)
assert.Equal(t, http.StatusUnauthorized, w.Code, "Admin route %s should exist and require auth", path)
}
}
func TestRegister_PublicRoutes(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared&_test_public_routes"), &gorm.Config{})
require.NoError(t, err)
cfg := config.Config{JWTSecret: "test-secret"}
require.NoError(t, Register(router, db, cfg))
// Public routes should be accessible without auth (route exists, not 404)
publicPaths := []struct {
method string
path string
}{
{http.MethodGet, "/api/v1/health"},
{http.MethodGet, "/metrics"},
{http.MethodGet, "/api/v1/setup"},
{http.MethodGet, "/api/v1/auth/status"},
}
for _, tc := range publicPaths {
t.Run(tc.method+"_"+tc.path, func(t *testing.T) {
w := httptest.NewRecorder()
req := httptest.NewRequest(tc.method, tc.path, nil)
router.ServeHTTP(w, req)
// Should not be 404 (route exists)
assert.NotEqual(t, http.StatusNotFound, w.Code, "Public route %s %s should exist", tc.method, tc.path)
})
}
}
func TestRegister_HealthEndpoint(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared&_test_health_endpoint"), &gorm.Config{})
require.NoError(t, err)
cfg := config.Config{JWTSecret: "test-secret"}
require.NoError(t, Register(router, db, cfg))
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/health", nil)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Body.String(), "status")
}
func TestRegister_MetricsEndpoint(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared&_test_metrics_endpoint"), &gorm.Config{})
require.NoError(t, err)
cfg := config.Config{JWTSecret: "test-secret"}
require.NoError(t, Register(router, db, cfg))
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/metrics", nil)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
// Prometheus metrics format
assert.Contains(t, w.Header().Get("Content-Type"), "text/plain")
}
func TestRegister_DBHealthEndpoint(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared&_test_db_health"), &gorm.Config{})
require.NoError(t, err)
cfg := config.Config{JWTSecret: "test-secret"}
require.NoError(t, Register(router, db, cfg))
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/health/db", nil)
router.ServeHTTP(w, req)
// Should return OK or service unavailable, but not 404
assert.NotEqual(t, http.StatusNotFound, w.Code)
}
func TestRegister_LoginEndpoint(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared&_test_login"), &gorm.Config{})
require.NoError(t, err)
cfg := config.Config{JWTSecret: "test-secret"}
require.NoError(t, Register(router, db, cfg))
// Test login endpoint exists and accepts POST
body := `{"username": "test", "password": "test"}`
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
// Should not be 404 (route exists)
assert.NotEqual(t, http.StatusNotFound, w.Code)
}
func TestRegister_SetupEndpoint(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared&_test_setup"), &gorm.Config{})
require.NoError(t, err)
cfg := config.Config{JWTSecret: "test-secret"}
require.NoError(t, Register(router, db, cfg))
// GET /setup should return setup status
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/setup", nil)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Body.String(), "setup")
}
func TestRegister_WithEncryptionRoutes(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared&_test_encryption_routes"), &gorm.Config{})
require.NoError(t, err)
// Set valid encryption key env var (32-byte key base64 encoded)
t.Setenv("CHARON_ENCRYPTION_KEY", "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
cfg := config.Config{
JWTSecret: "test-secret",
EncryptionKey: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
}
require.NoError(t, Register(router, db, cfg))
// Check if encryption routes are registered (may depend on env)
routes := router.Routes()
routeMap := make(map[string]bool)
for _, r := range routes {
routeMap[r.Path] = true
}
// DNS providers should be registered with valid encryption key
assert.True(t, routeMap["/api/v1/dns-providers"])
assert.True(t, routeMap["/api/v1/dns-providers/types"])
}
func TestRegister_UptimeCheckEndpoint(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared&_test_uptime_check"), &gorm.Config{})
require.NoError(t, err)
cfg := config.Config{JWTSecret: "test-secret"}
require.NoError(t, Register(router, db, cfg))
// Uptime check route should exist and require auth
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/system/uptime/check", nil)
router.ServeHTTP(w, req)
// Should require auth
assert.Equal(t, http.StatusUnauthorized, w.Code)
}
func TestRegister_CrowdSecRoutes(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared&_test_crowdsec_routes"), &gorm.Config{})
require.NoError(t, err)
cfg := config.Config{JWTSecret: "test-secret"}
require.NoError(t, Register(router, db, cfg))
// CrowdSec routes should exist
routes := router.Routes()
routeMap := make(map[string]bool)
for _, r := range routes {
routeMap[r.Path] = true
}
// CrowdSec management routes
assert.True(t, routeMap["/api/v1/admin/crowdsec/start"])
assert.True(t, routeMap["/api/v1/admin/crowdsec/stop"])
assert.True(t, routeMap["/api/v1/admin/crowdsec/status"])
assert.True(t, routeMap["/api/v1/admin/crowdsec/presets"])
assert.True(t, routeMap["/api/v1/admin/crowdsec/decisions"])
assert.True(t, routeMap["/api/v1/admin/crowdsec/ban"])
}
func TestRegister_SecurityRoutes(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared&_test_security_routes"), &gorm.Config{})
require.NoError(t, err)
cfg := config.Config{JWTSecret: "test-secret"}
require.NoError(t, Register(router, db, cfg))
routes := router.Routes()
routeMap := make(map[string]bool)
for _, r := range routes {
routeMap[r.Path] = true
}
// Security routes
assert.True(t, routeMap["/api/v1/security/status"])
assert.True(t, routeMap["/api/v1/security/config"])
assert.True(t, routeMap["/api/v1/security/enable"])
assert.True(t, routeMap["/api/v1/security/disable"])
assert.True(t, routeMap["/api/v1/security/decisions"])
assert.True(t, routeMap["/api/v1/security/rulesets"])
assert.True(t, routeMap["/api/v1/security/geoip/status"])
assert.True(t, routeMap["/api/v1/security/waf/exclusions"])
}
func TestRegister_AccessListRoutes(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared&_test_acl_routes"), &gorm.Config{})
require.NoError(t, err)
cfg := config.Config{JWTSecret: "test-secret"}
require.NoError(t, Register(router, db, cfg))
routes := router.Routes()
routeMap := make(map[string]bool)
for _, r := range routes {
routeMap[r.Path] = true
}
// Access List routes
assert.True(t, routeMap["/api/v1/access-lists"])
assert.True(t, routeMap["/api/v1/access-lists/:id"])
assert.True(t, routeMap["/api/v1/access-lists/:id/test"])
assert.True(t, routeMap["/api/v1/access-lists/templates"])
}
func TestRegister_CertificateRoutes(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared&_test_cert_routes"), &gorm.Config{})
require.NoError(t, err)
cfg := config.Config{JWTSecret: "test-secret"}
require.NoError(t, Register(router, db, cfg))
routes := router.Routes()
routeMap := make(map[string]bool)
for _, r := range routes {
routeMap[r.Path] = true
}
// Certificate routes
assert.True(t, routeMap["/api/v1/certificates"])
assert.True(t, routeMap["/api/v1/certificates/:id"])
}
// TestRegister_NilHandlers verifies registration behavior with minimal/nil components
func TestRegister_NilHandlers(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
// Create a minimal DB connection that will work
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared&_test_nil_handlers"), &gorm.Config{})
require.NoError(t, err)
// Config with minimal settings - no encryption key, no special features
cfg := config.Config{
JWTSecret: "test-secret",
Environment: "production",
EncryptionKey: "", // No encryption key - DNS providers won't be registered
}
err = Register(router, db, cfg)
assert.NoError(t, err)
// Verify that routes still work without DNS provider features
routes := router.Routes()
routeMap := make(map[string]bool)
for _, r := range routes {
routeMap[r.Path] = true
}
// Core routes should still be registered
assert.True(t, routeMap["/api/v1/health"])
assert.True(t, routeMap["/api/v1/auth/login"])
// DNS provider routes should NOT be registered (no encryption key)
assert.False(t, routeMap["/api/v1/dns-providers"])
}
// TestRegister_MiddlewareOrder verifies middleware is attached in correct order
func TestRegister_MiddlewareOrder(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared&_test_middleware_order"), &gorm.Config{})
require.NoError(t, err)
cfg := config.Config{
JWTSecret: "test-secret",
Environment: "development",
}
err = Register(router, db, cfg)
require.NoError(t, err)
// Test that security headers are applied (they should come first)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/health", nil)
router.ServeHTTP(w, req)
// Security headers should be present regardless of response
assert.NotEmpty(t, w.Header().Get("X-Content-Type-Options"), "Security headers middleware should set X-Content-Type-Options")
assert.NotEmpty(t, w.Header().Get("X-Frame-Options"), "Security headers middleware should set X-Frame-Options")
// In development mode, CSP should be more permissive
assert.Equal(t, http.StatusOK, w.Code)
}
// TestRegister_GzipCompression verifies gzip middleware is working
func TestRegister_GzipCompression(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared&_test_gzip"), &gorm.Config{})
require.NoError(t, err)
cfg := config.Config{JWTSecret: "test-secret"}
require.NoError(t, Register(router, db, cfg))
// Request with Accept-Encoding: gzip
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/health", nil)
req.Header.Set("Accept-Encoding", "gzip")
router.ServeHTTP(w, req)
// Response should be OK (gzip will only compress if response is large enough)
assert.Equal(t, http.StatusOK, w.Code)
}
// TestRegister_CerberusMiddleware verifies Cerberus security middleware is applied
func TestRegister_CerberusMiddleware(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared&_test_cerberus_mw"), &gorm.Config{})
require.NoError(t, err)
cfg := config.Config{
JWTSecret: "test-secret",
Security: config.SecurityConfig{
CerberusEnabled: true,
},
}
err = Register(router, db, cfg)
require.NoError(t, err)
// API routes should have Cerberus middleware applied
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/setup", nil)
router.ServeHTTP(w, req)
// Should still work (Cerberus allows normal requests)
assert.Equal(t, http.StatusOK, w.Code)
}
// TestRegister_FeatureFlagsEndpoint verifies feature flags endpoint is registered
func TestRegister_FeatureFlagsEndpoint(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared&_test_feature_flags"), &gorm.Config{})
require.NoError(t, err)
cfg := config.Config{JWTSecret: "test-secret"}
require.NoError(t, Register(router, db, cfg))
// Feature flags should require auth
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/feature-flags", nil)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusUnauthorized, w.Code)
}
// TestRegister_WebSocketRoutes verifies WebSocket routes are registered
func TestRegister_WebSocketRoutes(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared&_test_ws_routes"), &gorm.Config{})
require.NoError(t, err)
cfg := config.Config{JWTSecret: "test-secret"}
require.NoError(t, Register(router, db, cfg))
routes := router.Routes()
routeMap := make(map[string]bool)
for _, r := range routes {
routeMap[r.Path] = true
}
// WebSocket routes should be registered
assert.True(t, routeMap["/api/v1/logs/live"])
assert.True(t, routeMap["/api/v1/websocket/connections"])
assert.True(t, routeMap["/api/v1/websocket/stats"])
assert.True(t, routeMap["/api/v1/cerberus/logs/ws"])
}
// TestRegister_NotificationRoutes verifies all notification routes are registered
func TestRegister_NotificationRoutes(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared&_test_notification_routes"), &gorm.Config{})
require.NoError(t, err)
cfg := config.Config{JWTSecret: "test-secret"}
require.NoError(t, Register(router, db, cfg))
routes := router.Routes()
routeMap := make(map[string]bool)
for _, r := range routes {
routeMap[r.Path] = true
}
// Notification routes
assert.True(t, routeMap["/api/v1/notifications"])
assert.True(t, routeMap["/api/v1/notifications/:id/read"])
assert.True(t, routeMap["/api/v1/notifications/read-all"])
assert.True(t, routeMap["/api/v1/notifications/providers"])
assert.True(t, routeMap["/api/v1/notifications/providers/:id"])
assert.True(t, routeMap["/api/v1/notifications/templates"])
assert.True(t, routeMap["/api/v1/notifications/external-templates"])
assert.True(t, routeMap["/api/v1/notifications/external-templates/:id"])
}
// TestRegister_DomainRoutes verifies domain management routes
func TestRegister_DomainRoutes(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared&_test_domain_routes"), &gorm.Config{})
require.NoError(t, err)
cfg := config.Config{JWTSecret: "test-secret"}
require.NoError(t, Register(router, db, cfg))
routes := router.Routes()
routeMap := make(map[string]bool)
for _, r := range routes {
routeMap[r.Path] = true
}
// Domain routes
assert.True(t, routeMap["/api/v1/domains"])
assert.True(t, routeMap["/api/v1/domains/:id"])
}
// TestRegister_VerifyAuthEndpoint tests the verify endpoint for Caddy forward auth
func TestRegister_VerifyAuthEndpoint(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared&_test_verify_auth"), &gorm.Config{})
require.NoError(t, err)
cfg := config.Config{JWTSecret: "test-secret"}
require.NoError(t, Register(router, db, cfg))
// Verify endpoint is public (for Caddy forward auth)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/verify", nil)
router.ServeHTTP(w, req)
// Should not be 404 (route exists) - will return 401 without valid session
assert.NotEqual(t, http.StatusNotFound, w.Code)
}
// TestRegister_SMTPRoutes verifies SMTP configuration routes
func TestRegister_SMTPRoutes(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared&_test_smtp_routes"), &gorm.Config{})
require.NoError(t, err)
cfg := config.Config{JWTSecret: "test-secret"}
require.NoError(t, Register(router, db, cfg))
routes := router.Routes()
routeMap := make(map[string]bool)
for _, r := range routes {
routeMap[r.Path] = true
}
// SMTP routes
assert.True(t, routeMap["/api/v1/settings/smtp"])
assert.True(t, routeMap["/api/v1/settings/smtp/test"])
assert.True(t, routeMap["/api/v1/settings/smtp/test-email"])
assert.True(t, routeMap["/api/v1/settings/validate-url"])
assert.True(t, routeMap["/api/v1/settings/test-url"])
}
// TestRegisterImportHandler_RoutesExist verifies import handler routes
func TestRegisterImportHandler_RoutesExist(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared&_test_import_routes"), &gorm.Config{})
require.NoError(t, err)
RegisterImportHandler(router, db, "/usr/bin/caddy", "/tmp/imports", "/tmp/mount")
routes := router.Routes()
routeMap := make(map[string]bool)
for _, r := range routes {
routeMap[r.Path] = true
}
// Import routes
assert.True(t, routeMap["/api/v1/import/status"] || routeMap["/api/v1/import/preview"] || routeMap["/api/v1/import/upload"],
"At least one import route should be registered")
}
// TestRegister_EncryptionRoutesWithValidKey verifies encryption management routes
func TestRegister_EncryptionRoutesWithValidKey(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared&_test_encryption_routes_valid"), &gorm.Config{})
require.NoError(t, err)
// Set the env var needed for rotation service
t.Setenv("CHARON_ENCRYPTION_KEY", "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
// Valid 32-byte key in base64
cfg := config.Config{
JWTSecret: "test-secret",
EncryptionKey: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
}
require.NoError(t, Register(router, db, cfg))
routes := router.Routes()
routeMap := make(map[string]bool)
for _, r := range routes {
routeMap[r.Path] = true
}
// Encryption management routes should be registered (depends on rotation service init)
// Note: If rotation service init fails, these routes won't be registered
// We check if DNS provider routes are registered (which don't depend on rotation service)
assert.True(t, routeMap["/api/v1/dns-providers"])
assert.True(t, routeMap["/api/v1/dns-providers/types"])
// Encryption routes may or may not be registered depending on env setup
// Just verify the DNS providers are there when encryption key is valid
}
// TestRegister_WAFExclusionRoutes verifies WAF exclusion management routes
func TestRegister_WAFExclusionRoutes(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared&_test_waf_exclusion_routes"), &gorm.Config{})
require.NoError(t, err)
cfg := config.Config{JWTSecret: "test-secret"}
require.NoError(t, Register(router, db, cfg))
routes := router.Routes()
routeMap := make(map[string]bool)
for _, r := range routes {
routeMap[r.Path] = true
}
// WAF exclusion routes
assert.True(t, routeMap["/api/v1/security/waf/exclusions"])
assert.True(t, routeMap["/api/v1/security/waf/exclusions/:rule_id"])
}
// TestRegister_BreakGlassRoute verifies break glass endpoint is registered
func TestRegister_BreakGlassRoute(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared&_test_breakglass_route"), &gorm.Config{})
require.NoError(t, err)
cfg := config.Config{JWTSecret: "test-secret"}
require.NoError(t, Register(router, db, cfg))
routes := router.Routes()
routeMap := make(map[string]bool)
for _, r := range routes {
routeMap[r.Path] = true
}
// Break glass route
assert.True(t, routeMap["/api/v1/security/breakglass/generate"])
}
// TestRegister_RateLimitPresetsRoute verifies rate limit presets endpoint
func TestRegister_RateLimitPresetsRoute(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared&_test_ratelimit_presets"), &gorm.Config{})
require.NoError(t, err)
cfg := config.Config{JWTSecret: "test-secret"}
require.NoError(t, Register(router, db, cfg))
routes := router.Routes()
routeMap := make(map[string]bool)
for _, r := range routes {
routeMap[r.Path] = true
}
// Rate limit presets route
assert.True(t, routeMap["/api/v1/security/rate-limit/presets"])
}

View File

@@ -220,3 +220,308 @@ func TestHasWildcard_TrueFalse(t *testing.T) {
require.True(t, hasWildcard([]string{"*.example.com"}))
require.False(t, hasWildcard([]string{"example.com"}))
}
// TestGenerateConfig_MultiCredential_ZoneSpecificPolicies verifies that multi-credential DNS providers
// create separate TLS automation policies per zone with zone-specific credentials.
func TestGenerateConfig_MultiCredential_ZoneSpecificPolicies(t *testing.T) {
providerID := uint(10)
host := models.ProxyHost{
Enabled: true,
DomainNames: "*.zone1.com,zone1.com,*.zone2.com,zone2.com",
DNSProvider: &models.DNSProvider{ID: providerID, ProviderType: "cloudflare"},
DNSProviderID: func() *uint { v := providerID; return &v }(),
}
conf, err := GenerateConfig(
[]models.ProxyHost{host},
t.TempDir(),
"acme@example.com",
"",
"letsencrypt",
false,
false, false, false, false,
"",
nil,
nil,
nil,
&models.SecurityConfig{},
[]DNSProviderConfig{{
ID: providerID,
ProviderType: "cloudflare",
UseMultiCredentials: true,
ZoneCredentials: map[string]map[string]string{
"zone1.com": {"api_token": "token-zone1"},
"zone2.com": {"api_token": "token-zone2"},
},
Credentials: map[string]string{"api_token": "fallback-token"},
}},
)
require.NoError(t, err)
require.NotNil(t, conf)
require.NotNil(t, conf.Apps.TLS)
require.NotEmpty(t, conf.Apps.TLS.Automation.Policies)
// Should have at least 2 policies for the 2 zones
policyCount := 0
for _, p := range conf.Apps.TLS.Automation.Policies {
if p == nil {
continue
}
for _, s := range p.Subjects {
if s == "*.zone1.com" || s == "zone1.com" || s == "*.zone2.com" || s == "zone2.com" {
policyCount++
break
}
}
}
require.GreaterOrEqual(t, policyCount, 2, "expected at least 2 policies for multi-credential zones")
}
// TestGenerateConfig_MultiCredential_ZeroSSL_Issuer verifies multi-credential with ZeroSSL issuer.
func TestGenerateConfig_MultiCredential_ZeroSSL_Issuer(t *testing.T) {
providerID := uint(11)
host := models.ProxyHost{
Enabled: true,
DomainNames: "*.zerossl-test.com",
DNSProvider: &models.DNSProvider{ID: providerID, ProviderType: "cloudflare"},
DNSProviderID: func() *uint { v := providerID; return &v }(),
}
conf, err := GenerateConfig(
[]models.ProxyHost{host},
t.TempDir(),
"acme@example.com",
"",
"zerossl", // Use ZeroSSL provider
false,
false, false, false, false,
"",
nil,
nil,
nil,
&models.SecurityConfig{},
[]DNSProviderConfig{{
ID: providerID,
ProviderType: "cloudflare",
UseMultiCredentials: true,
ZoneCredentials: map[string]map[string]string{
"zerossl-test.com": {"api_token": "zerossl-token"},
},
}},
)
require.NoError(t, err)
require.NotNil(t, conf)
// Find ZeroSSL issuer in policies
foundZeroSSL := false
for _, p := range conf.Apps.TLS.Automation.Policies {
if p == nil {
continue
}
for _, it := range p.IssuersRaw {
if m, ok := it.(map[string]any); ok {
if m["module"] == "zerossl" {
foundZeroSSL = true
break
}
}
}
}
require.True(t, foundZeroSSL, "expected ZeroSSL issuer in multi-credential policy")
}
// TestGenerateConfig_MultiCredential_BothIssuers verifies multi-credential with both ACME and ZeroSSL issuers.
func TestGenerateConfig_MultiCredential_BothIssuers(t *testing.T) {
providerID := uint(12)
host := models.ProxyHost{
Enabled: true,
DomainNames: "*.both-test.com",
DNSProvider: &models.DNSProvider{ID: providerID, ProviderType: "cloudflare"},
DNSProviderID: func() *uint { v := providerID; return &v }(),
}
conf, err := GenerateConfig(
[]models.ProxyHost{host},
t.TempDir(),
"acme@example.com",
"",
"both", // Use both providers
false,
false, false, false, false,
"",
nil,
nil,
nil,
&models.SecurityConfig{},
[]DNSProviderConfig{{
ID: providerID,
ProviderType: "cloudflare",
UseMultiCredentials: true,
ZoneCredentials: map[string]map[string]string{
"both-test.com": {"api_token": "both-token"},
},
}},
)
require.NoError(t, err)
require.NotNil(t, conf)
// Find both ACME and ZeroSSL issuers in policies
foundACME := false
foundZeroSSL := false
for _, p := range conf.Apps.TLS.Automation.Policies {
if p == nil {
continue
}
for _, it := range p.IssuersRaw {
if m, ok := it.(map[string]any); ok {
switch m["module"] {
case "acme":
foundACME = true
case "zerossl":
foundZeroSSL = true
}
}
}
}
require.True(t, foundACME, "expected ACME issuer in multi-credential policy")
require.True(t, foundZeroSSL, "expected ZeroSSL issuer in multi-credential policy")
}
// TestGenerateConfig_MultiCredential_ACMEStaging verifies multi-credential with ACME staging CA.
func TestGenerateConfig_MultiCredential_ACMEStaging(t *testing.T) {
providerID := uint(13)
host := models.ProxyHost{
Enabled: true,
DomainNames: "*.staging-test.com",
DNSProvider: &models.DNSProvider{ID: providerID, ProviderType: "cloudflare"},
DNSProviderID: func() *uint { v := providerID; return &v }(),
}
conf, err := GenerateConfig(
[]models.ProxyHost{host},
t.TempDir(),
"acme@example.com",
"",
"letsencrypt",
true, // ACME staging
false, false, false, false,
"",
nil,
nil,
nil,
&models.SecurityConfig{},
[]DNSProviderConfig{{
ID: providerID,
ProviderType: "cloudflare",
UseMultiCredentials: true,
ZoneCredentials: map[string]map[string]string{
"staging-test.com": {"api_token": "staging-token"},
},
}},
)
require.NoError(t, err)
require.NotNil(t, conf)
// Find ACME issuer with staging CA
foundStagingCA := false
for _, p := range conf.Apps.TLS.Automation.Policies {
if p == nil {
continue
}
for _, it := range p.IssuersRaw {
if m, ok := it.(map[string]any); ok {
if m["module"] == "acme" {
if ca, ok := m["ca"].(string); ok && ca == "https://acme-staging-v02.api.letsencrypt.org/directory" {
foundStagingCA = true
break
}
}
}
}
}
require.True(t, foundStagingCA, "expected ACME staging CA in multi-credential policy")
}
// TestGenerateConfig_MultiCredential_NoMatchingDomains verifies that zones with no matching domains are skipped.
func TestGenerateConfig_MultiCredential_NoMatchingDomains(t *testing.T) {
providerID := uint(14)
host := models.ProxyHost{
Enabled: true,
DomainNames: "*.actual.com",
DNSProvider: &models.DNSProvider{ID: providerID, ProviderType: "cloudflare"},
DNSProviderID: func() *uint { v := providerID; return &v }(),
}
conf, err := GenerateConfig(
[]models.ProxyHost{host},
t.TempDir(),
"acme@example.com",
"",
"letsencrypt",
false,
false, false, false, false,
"",
nil,
nil,
nil,
&models.SecurityConfig{},
[]DNSProviderConfig{{
ID: providerID,
ProviderType: "cloudflare",
UseMultiCredentials: true,
ZoneCredentials: map[string]map[string]string{
"unmatched.com": {"api_token": "unmatched-token"}, // This zone won't match any domains
"actual.com": {"api_token": "actual-token"}, // This zone will match
},
}},
)
require.NoError(t, err)
require.NotNil(t, conf)
// Should only have policy for actual.com, not unmatched.com
for _, p := range conf.Apps.TLS.Automation.Policies {
if p == nil {
continue
}
for _, s := range p.Subjects {
require.NotContains(t, s, "unmatched", "unmatched domain should not appear in policies")
}
}
}
// TestGenerateConfig_MultiCredential_ProviderTypeNotFound verifies graceful handling when provider type is not in registry.
func TestGenerateConfig_MultiCredential_ProviderTypeNotFound(t *testing.T) {
providerID := uint(15)
host := models.ProxyHost{
Enabled: true,
DomainNames: "*.unknown-provider.com",
DNSProvider: &models.DNSProvider{ID: providerID, ProviderType: "nonexistent_provider"},
DNSProviderID: func() *uint { v := providerID; return &v }(),
}
conf, err := GenerateConfig(
[]models.ProxyHost{host},
t.TempDir(),
"acme@example.com",
"",
"letsencrypt",
false,
false, false, false, false,
"",
nil,
nil,
nil,
&models.SecurityConfig{},
[]DNSProviderConfig{{
ID: providerID,
ProviderType: "nonexistent_provider", // Not in registry
UseMultiCredentials: true,
ZoneCredentials: map[string]map[string]string{
"unknown-provider.com": {"api_token": "token"},
},
}},
)
// Should not error, just skip the provider
require.NoError(t, err)
require.NotNil(t, conf)
}

View File

@@ -1402,3 +1402,419 @@ func TestGenerateConfig_WithWAFPerHostDisabled(t *testing.T) {
require.NotEqual(t, "waf", h["handler"], "WAF handler should NOT be present for waf-disabled host")
}
}
// TestGenerateConfig_WithDisabledHost verifies disabled hosts are skipped
func TestGenerateConfig_WithDisabledHost(t *testing.T) {
hosts := []models.ProxyHost{
{
UUID: "uuid-enabled",
DomainNames: "enabled.example.com",
ForwardHost: "app1",
ForwardPort: 8080,
Enabled: true,
},
{
UUID: "uuid-disabled",
DomainNames: "disabled.example.com",
ForwardHost: "app2",
ForwardPort: 8081,
Enabled: false, // Disabled
},
}
config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, false, "", nil, nil, nil, nil, nil)
require.NoError(t, err)
server := config.Apps.HTTP.Servers["charon_server"]
require.NotNil(t, server)
// Only 1 route for the enabled host
require.Len(t, server.Routes, 1)
require.Equal(t, []string{"enabled.example.com"}, server.Routes[0].Match[0].Host)
}
// TestGenerateConfig_WithFrontendDir verifies catch-all route with frontend
func TestGenerateConfig_WithFrontendDir(t *testing.T) {
hosts := []models.ProxyHost{
{
UUID: "uuid-1",
DomainNames: "app.example.com",
ForwardHost: "app",
ForwardPort: 8080,
Enabled: true,
},
}
config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "/var/www/html", "", false, false, false, false, false, "", nil, nil, nil, nil, nil)
require.NoError(t, err)
server := config.Apps.HTTP.Servers["charon_server"]
require.NotNil(t, server)
// Should have 2 routes: 1 for the host + 1 catch-all for frontend
require.Len(t, server.Routes, 2)
// Last route should be catch-all with file_server
catchAll := server.Routes[1]
require.Nil(t, catchAll.Match)
require.True(t, catchAll.Terminal)
// Check handlers include rewrite and file_server
var foundRewrite, foundFileServer bool
for _, h := range catchAll.Handle {
if h["handler"] == "rewrite" {
foundRewrite = true
}
if h["handler"] == "file_server" {
foundFileServer = true
}
}
require.True(t, foundRewrite, "catch-all should have rewrite handler")
require.True(t, foundFileServer, "catch-all should have file_server handler")
}
// TestGenerateConfig_CustomCertificate verifies custom certificates are loaded
func TestGenerateConfig_CustomCertificate(t *testing.T) {
certUUID := "cert-uuid-123"
cert := models.SSLCertificate{
UUID: certUUID,
Name: "Custom Cert",
Provider: "custom",
Certificate: "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----",
PrivateKey: "-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----",
}
certID := uint(1)
hosts := []models.ProxyHost{
{
UUID: "uuid-1",
DomainNames: "secure.example.com",
ForwardHost: "app",
ForwardPort: 8080,
Enabled: true,
CertificateID: &certID,
Certificate: &cert,
},
}
config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, false, "", nil, nil, nil, nil, nil)
require.NoError(t, err)
// Check TLS certificates are loaded
require.NotNil(t, config.Apps.TLS)
require.NotNil(t, config.Apps.TLS.Certificates)
require.NotNil(t, config.Apps.TLS.Certificates.LoadPEM)
require.Len(t, config.Apps.TLS.Certificates.LoadPEM, 1)
loadPEM := config.Apps.TLS.Certificates.LoadPEM[0]
require.Equal(t, cert.Certificate, loadPEM.Certificate)
require.Equal(t, cert.PrivateKey, loadPEM.Key)
require.Contains(t, loadPEM.Tags, certUUID)
}
// TestGenerateConfig_CustomCertificateMissingData verifies invalid custom certs are skipped
func TestGenerateConfig_CustomCertificateMissingData(t *testing.T) {
// Certificate missing private key
cert := models.SSLCertificate{
UUID: "cert-uuid-123",
Name: "Bad Cert",
Provider: "custom",
Certificate: "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----",
PrivateKey: "", // Missing
}
certID := uint(1)
hosts := []models.ProxyHost{
{
UUID: "uuid-1",
DomainNames: "secure.example.com",
ForwardHost: "app",
ForwardPort: 8080,
Enabled: true,
CertificateID: &certID,
Certificate: &cert,
},
}
config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, false, "", nil, nil, nil, nil, nil)
require.NoError(t, err)
// TLS should be configured but without the invalid custom cert
if config.Apps.TLS != nil && config.Apps.TLS.Certificates != nil {
require.Empty(t, config.Apps.TLS.Certificates.LoadPEM)
}
}
// TestGenerateConfig_LetsEncryptCertificateNotLoaded verifies ACME certs aren't loaded via LoadPEM
func TestGenerateConfig_LetsEncryptCertificateNotLoaded(t *testing.T) {
cert := models.SSLCertificate{
UUID: "cert-uuid-123",
Name: "Let's Encrypt Cert",
Provider: "letsencrypt", // Not custom
Certificate: "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----",
PrivateKey: "-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----",
}
certID := uint(1)
hosts := []models.ProxyHost{
{
UUID: "uuid-1",
DomainNames: "secure.example.com",
ForwardHost: "app",
ForwardPort: 8080,
Enabled: true,
CertificateID: &certID,
Certificate: &cert,
},
}
config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, false, "", nil, nil, nil, nil, nil)
require.NoError(t, err)
// Let's Encrypt certs should NOT be loaded via LoadPEM (ACME handles them)
if config.Apps.TLS != nil && config.Apps.TLS.Certificates != nil {
require.Empty(t, config.Apps.TLS.Certificates.LoadPEM)
}
}
// TestGenerateConfig_NormalizeAdvancedConfig verifies advanced config normalization
func TestGenerateConfig_NormalizeAdvancedConfig(t *testing.T) {
hosts := []models.ProxyHost{
{
UUID: "uuid-advanced",
DomainNames: "advanced.example.com",
ForwardHost: "app",
ForwardPort: 8080,
Enabled: true,
AdvancedConfig: `{"handler": "headers", "response": {"set": {"X-Custom": "value"}}}`,
},
}
config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, false, "", nil, nil, nil, nil, nil)
require.NoError(t, err)
server := config.Apps.HTTP.Servers["charon_server"]
require.NotNil(t, server)
require.Len(t, server.Routes, 1)
route := server.Routes[0]
// Should have headers handler + reverse_proxy
require.GreaterOrEqual(t, len(route.Handle), 2)
var foundHeaders bool
for _, h := range route.Handle {
if h["handler"] == "headers" {
foundHeaders = true
break
}
}
require.True(t, foundHeaders, "advanced config handler should be present")
}
// TestGenerateConfig_NoACMEEmailNoTLS verifies no TLS config when no ACME email
func TestGenerateConfig_NoACMEEmailNoTLS(t *testing.T) {
hosts := []models.ProxyHost{
{
UUID: "uuid-1",
DomainNames: "app.example.com",
ForwardHost: "app",
ForwardPort: 8080,
Enabled: true,
},
}
// No ACME email
config, err := GenerateConfig(hosts, "/tmp/caddy-data", "", "", "", false, false, false, false, false, "", nil, nil, nil, nil, nil)
require.NoError(t, err)
// TLS automation policies should not be set
require.Nil(t, config.Apps.TLS)
}
// TestGenerateConfig_SecurityDecisionsWithAdminWhitelist verifies admin bypass for blocks
func TestGenerateConfig_SecurityDecisionsWithAdminWhitelist(t *testing.T) {
hosts := []models.ProxyHost{
{
UUID: "test-uuid",
DomainNames: "test.example.com",
ForwardHost: "app",
ForwardPort: 8080,
Enabled: true,
},
}
decisions := []models.SecurityDecision{
{IP: "1.2.3.4", Action: "block"},
}
// With admin whitelist
config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, false, "10.0.0.1/32", nil, nil, decisions, nil, nil)
require.NoError(t, err)
server := config.Apps.HTTP.Servers["charon_server"]
require.NotNil(t, server)
route := server.Routes[0]
b, _ := json.Marshal(route.Handle)
s := string(b)
// Should contain blocked IP and admin whitelist exclusion
require.Contains(t, s, "1.2.3.4")
require.Contains(t, s, "10.0.0.1/32")
}
// TestBuildSecurityHeadersHandler_DefaultProfile verifies default profile when enabled
func TestBuildSecurityHeadersHandler_DefaultProfile(t *testing.T) {
host := &models.ProxyHost{
SecurityHeadersEnabled: true,
SecurityHeaderProfile: nil, // Use default
}
h, err := buildSecurityHeadersHandler(host)
require.NoError(t, err)
require.NotNil(t, h)
response := h["response"].(map[string]any)
headers := response["set"].(map[string][]string)
// Should have default HSTS
require.Contains(t, headers, "Strict-Transport-Security")
// Should have X-Frame-Options
require.Contains(t, headers, "X-Frame-Options")
// Should have X-Content-Type-Options
require.Contains(t, headers, "X-Content-Type-Options")
}
// TestHasWildcard verifies wildcard detection
func TestHasWildcard(t *testing.T) {
tests := []struct {
name string
domains []string
expected bool
}{
{"no_wildcard", []string{"example.com", "test.com"}, false},
{"with_wildcard", []string{"example.com", "*.test.com"}, true},
{"only_wildcard", []string{"*.example.com"}, true},
{"empty", []string{}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := hasWildcard(tt.domains)
require.Equal(t, tt.expected, result)
})
}
}
// TestDedupeDomains verifies domain deduplication
func TestDedupeDomains(t *testing.T) {
tests := []struct {
name string
input []string
expected []string
}{
{"no_dupes", []string{"a.com", "b.com"}, []string{"a.com", "b.com"}},
{"with_dupes", []string{"a.com", "b.com", "a.com"}, []string{"a.com", "b.com"}},
{"all_dupes", []string{"a.com", "a.com", "a.com"}, []string{"a.com"}},
{"empty", []string{}, []string{}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := dedupeDomains(tt.input)
require.Equal(t, tt.expected, result)
})
}
}
// TestNormalizeAdvancedConfig_NestedRoutes verifies nested route normalization
func TestNormalizeAdvancedConfig_NestedRoutes(t *testing.T) {
// Test with nested routes structure
input := map[string]any{
"handler": "subroute",
"routes": []any{
map[string]any{
"handle": []any{
map[string]any{
"handler": "headers",
"response": map[string]any{
"set": map[string]any{
"X-Test": "value", // String should become []string
},
},
},
},
},
},
}
result := NormalizeAdvancedConfig(input)
require.NotNil(t, result)
// The nested headers should be normalized
m := result.(map[string]any)
routes := m["routes"].([]any)
routeMap := routes[0].(map[string]any)
handles := routeMap["handle"].([]any)
handlerMap := handles[0].(map[string]any)
response := handlerMap["response"].(map[string]any)
setHeaders := response["set"].(map[string]any)
// String should be converted to []string
xTest := setHeaders["X-Test"]
require.IsType(t, []string{}, xTest)
require.Equal(t, []string{"value"}, xTest)
}
// TestNormalizeAdvancedConfig_ArrayInput verifies array normalization
func TestNormalizeAdvancedConfig_ArrayInput(t *testing.T) {
input := []any{
map[string]any{
"handler": "headers",
"response": map[string]any{
"set": map[string]any{
"X-Test": "value",
},
},
},
}
result := NormalizeAdvancedConfig(input)
require.NotNil(t, result)
arr := result.([]any)
require.Len(t, arr, 1)
}
// TestGetCrowdSecAPIKey verifies API key retrieval from environment
func TestGetCrowdSecAPIKey(t *testing.T) {
// Save original values
origVars := map[string]string{}
envVars := []string{"CROWDSEC_API_KEY", "CROWDSEC_BOUNCER_API_KEY", "CERBERUS_SECURITY_CROWDSEC_API_KEY", "CHARON_SECURITY_CROWDSEC_API_KEY", "CPM_SECURITY_CROWDSEC_API_KEY"}
for _, v := range envVars {
origVars[v] = os.Getenv(v)
os.Unsetenv(v)
}
defer func() {
for k, v := range origVars {
if v != "" {
os.Setenv(k, v)
} else {
os.Unsetenv(k)
}
}
}()
// No keys set - should return empty
result := getCrowdSecAPIKey()
require.Equal(t, "", result)
// Set primary key
os.Setenv("CROWDSEC_API_KEY", "primary-key")
result = getCrowdSecAPIKey()
require.Equal(t, "primary-key", result)
// Test fallback priority
os.Unsetenv("CROWDSEC_API_KEY")
os.Setenv("CROWDSEC_BOUNCER_API_KEY", "bouncer-key")
result = getCrowdSecAPIKey()
require.Equal(t, "bouncer-key", result)
}

View File

@@ -0,0 +1,389 @@
package caddy
import (
"context"
"fmt"
"os"
"strings"
"testing"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"github.com/Wikid82/charon/backend/internal/config"
"github.com/Wikid82/charon/backend/internal/models"
)
// TestExtractBaseDomain_EmptyInput verifies empty input returns empty string
func TestExtractBaseDomain_EmptyInput(t *testing.T) {
result := extractBaseDomain("")
require.Equal(t, "", result)
}
// TestExtractBaseDomain_OnlyCommas verifies handling of comma-only input
func TestExtractBaseDomain_OnlyCommas(t *testing.T) {
// When input is only commas, first element is empty string after split
result := extractBaseDomain(",,,")
require.Equal(t, "", result)
}
// TestExtractBaseDomain_SingleDomain verifies single domain extraction
func TestExtractBaseDomain_SingleDomain(t *testing.T) {
result := extractBaseDomain("example.com")
require.Equal(t, "example.com", result)
}
// TestExtractBaseDomain_WildcardDomain verifies wildcard stripping
func TestExtractBaseDomain_WildcardDomain(t *testing.T) {
result := extractBaseDomain("*.example.com")
require.Equal(t, "example.com", result)
}
// TestExtractBaseDomain_MultipleDomains verifies first domain is used
func TestExtractBaseDomain_MultipleDomains(t *testing.T) {
result := extractBaseDomain("first.com, second.com, third.com")
require.Equal(t, "first.com", result)
}
// TestExtractBaseDomain_MultipleDomainsWithWildcard verifies wildcard stripping with multiple domains
func TestExtractBaseDomain_MultipleDomainsWithWildcard(t *testing.T) {
result := extractBaseDomain("*.example.com, sub.example.com")
require.Equal(t, "example.com", result)
}
// TestExtractBaseDomain_WithWhitespace verifies whitespace trimming
func TestExtractBaseDomain_WithWhitespace(t *testing.T) {
result := extractBaseDomain(" example.com ")
require.Equal(t, "example.com", result)
}
// TestExtractBaseDomain_CaseNormalization verifies lowercase normalization
func TestExtractBaseDomain_CaseNormalization(t *testing.T) {
result := extractBaseDomain("EXAMPLE.COM")
require.Equal(t, "example.com", result)
}
// TestExtractBaseDomain_Subdomain verifies subdomain handling
func TestExtractBaseDomain_Subdomain(t *testing.T) {
// Note: extractBaseDomain returns the first domain as-is (after wildcard removal)
// It does NOT extract the registrable domain (like public suffix)
result := extractBaseDomain("sub.example.com")
require.Equal(t, "sub.example.com", result)
}
// TestExtractBaseDomain_MultiLevelSubdomain verifies multi-level subdomain handling
func TestExtractBaseDomain_MultiLevelSubdomain(t *testing.T) {
result := extractBaseDomain("deep.sub.example.com")
require.Equal(t, "deep.sub.example.com", result)
}
// TestMatchesZoneFilter_EmptyFilter verifies empty filter returns false (catch-all handled separately)
func TestMatchesZoneFilter_EmptyFilter(t *testing.T) {
result := matchesZoneFilter("", "example.com", false)
require.False(t, result)
result = matchesZoneFilter(" ", "example.com", false)
require.False(t, result)
}
// TestMatchesZoneFilter_EmptyZonesInList verifies empty zones in list are skipped
func TestMatchesZoneFilter_EmptyZonesInList(t *testing.T) {
// Empty zone entries should be skipped
result := matchesZoneFilter(",example.com,", "example.com", false)
require.True(t, result)
result = matchesZoneFilter(",,,", "example.com", false)
require.False(t, result)
}
// TestMatchesZoneFilter_ExactMatch verifies exact domain matching
func TestMatchesZoneFilter_ExactMatch(t *testing.T) {
result := matchesZoneFilter("example.com", "example.com", false)
require.True(t, result)
result = matchesZoneFilter("example.com", "other.com", false)
require.False(t, result)
}
// TestMatchesZoneFilter_ExactMatchOnly verifies exact-only mode
func TestMatchesZoneFilter_ExactMatchOnly(t *testing.T) {
// With exactOnly=true, wildcard patterns should not match
result := matchesZoneFilter("*.example.com", "sub.example.com", true)
require.False(t, result)
// But exact matches should still work
result = matchesZoneFilter("sub.example.com", "sub.example.com", true)
require.True(t, result)
}
// TestMatchesZoneFilter_WildcardMatch verifies wildcard pattern matching
func TestMatchesZoneFilter_WildcardMatch(t *testing.T) {
// Subdomain should match wildcard
result := matchesZoneFilter("*.example.com", "sub.example.com", false)
require.True(t, result)
// Base domain should match wildcard
result = matchesZoneFilter("*.example.com", "example.com", false)
require.True(t, result)
// Different domain should not match
result = matchesZoneFilter("*.example.com", "other.com", false)
require.False(t, result)
}
// TestMatchesZoneFilter_MultipleZones verifies comma-separated zone matching
func TestMatchesZoneFilter_MultipleZones(t *testing.T) {
result := matchesZoneFilter("example.com, other.com", "other.com", false)
require.True(t, result)
result = matchesZoneFilter("example.com, other.com", "third.com", false)
require.False(t, result)
}
// TestMatchesZoneFilter_MultipleZonesWithWildcard verifies mixed zone list
func TestMatchesZoneFilter_MultipleZonesWithWildcard(t *testing.T) {
result := matchesZoneFilter("example.com, *.other.com", "sub.other.com", false)
require.True(t, result)
result2 := matchesZoneFilter("example.com, *.other.com", "example.com", false)
require.True(t, result2)
}
// TestMatchesZoneFilter_WhitespaceTrimming verifies whitespace handling (different from manager_multicred_test)
func TestMatchesZoneFilter_WhitespaceTrimming_Detailed(t *testing.T) {
result := matchesZoneFilter(" example.com , other.com ", "example.com", false)
require.True(t, result)
}
// TestMatchesZoneFilter_DeepSubdomain verifies deep subdomain matching
func TestMatchesZoneFilter_DeepSubdomain(t *testing.T) {
result := matchesZoneFilter("*.example.com", "deep.sub.example.com", false)
require.True(t, result)
}
// TestGetCredentialForDomain_NoEncryptionKey verifies error when no encryption key
func TestGetCredentialForDomain_NoEncryptionKey(t *testing.T) {
// Save original env vars
origKeys := map[string]string{}
for _, key := range []string{"CHARON_ENCRYPTION_KEY", "ENCRYPTION_KEY", "CERBERUS_ENCRYPTION_KEY"} {
origKeys[key] = os.Getenv(key)
os.Unsetenv(key)
}
defer func() {
for key, val := range origKeys {
if val != "" {
os.Setenv(key, val)
}
}
}()
// Setup DB
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
require.NoError(t, err)
manager := NewManager(nil, db, "", "", false, config.SecurityConfig{})
provider := &models.DNSProvider{
UseMultiCredentials: false,
}
_, err = manager.getCredentialForDomain(1, "example.com", provider)
require.Error(t, err)
require.Contains(t, err.Error(), "no encryption key available")
}
// TestGetCredentialForDomain_MultiCredential_NoMatch verifies error when no credential matches
func TestGetCredentialForDomain_MultiCredential_NoMatch(t *testing.T) {
// Save original env vars
origKey := os.Getenv("CHARON_ENCRYPTION_KEY")
os.Setenv("CHARON_ENCRYPTION_KEY", "test-key-32-characters-long!!!!!")
defer func() {
if origKey != "" {
os.Setenv("CHARON_ENCRYPTION_KEY", origKey)
} else {
os.Unsetenv("CHARON_ENCRYPTION_KEY")
}
}()
// Setup DB
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
require.NoError(t, err)
manager := NewManager(nil, db, "", "", false, config.SecurityConfig{})
provider := &models.DNSProvider{
UseMultiCredentials: true,
Credentials: []models.DNSProviderCredential{
{
UUID: "cred-1",
Label: "Zone1",
ZoneFilter: "zone1.com",
Enabled: true,
},
{
UUID: "cred-2",
Label: "Zone2",
ZoneFilter: "zone2.com",
Enabled: true,
},
},
}
_, err = manager.getCredentialForDomain(1, "unmatched.com", provider)
require.Error(t, err)
require.Contains(t, err.Error(), "no matching credential found")
}
// TestGetCredentialForDomain_MultiCredential_DisabledSkipped verifies disabled credentials are skipped
func TestGetCredentialForDomain_MultiCredential_DisabledSkipped(t *testing.T) {
// Save original env vars
origKey := os.Getenv("CHARON_ENCRYPTION_KEY")
os.Setenv("CHARON_ENCRYPTION_KEY", "test-key-32-characters-long!!!!!")
defer func() {
if origKey != "" {
os.Setenv("CHARON_ENCRYPTION_KEY", origKey)
} else {
os.Unsetenv("CHARON_ENCRYPTION_KEY")
}
}()
// Setup DB
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
require.NoError(t, err)
manager := NewManager(nil, db, "", "", false, config.SecurityConfig{})
provider := &models.DNSProvider{
UseMultiCredentials: true,
Credentials: []models.DNSProviderCredential{
{
UUID: "cred-1",
Label: "Disabled Zone",
ZoneFilter: "example.com",
Enabled: false, // Disabled - should be skipped
},
},
}
// Should fail because the only matching credential is disabled
_, err = manager.getCredentialForDomain(1, "example.com", provider)
require.Error(t, err)
require.Contains(t, err.Error(), "no matching credential found")
}
// TestGetCredentialForDomain_MultiCredential_CatchAllMatch verifies empty zone_filter as catch-all
func TestGetCredentialForDomain_MultiCredential_CatchAllMatch(t *testing.T) {
// Save original env vars
origKey := os.Getenv("CHARON_ENCRYPTION_KEY")
os.Setenv("CHARON_ENCRYPTION_KEY", "test-key-32-characters-long!!!!!")
defer func() {
if origKey != "" {
os.Setenv("CHARON_ENCRYPTION_KEY", origKey)
} else {
os.Unsetenv("CHARON_ENCRYPTION_KEY")
}
}()
// Setup DB
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
require.NoError(t, err)
manager := NewManager(nil, db, "", "", false, config.SecurityConfig{})
provider := &models.DNSProvider{
UseMultiCredentials: true,
Credentials: []models.DNSProviderCredential{
{
UUID: "cred-catch-all",
Label: "Catch-All",
ZoneFilter: "", // Empty = catch-all
Enabled: true,
CredentialsEncrypted: "invalid-encrypted-data", // Will fail decryption
},
},
}
// Should match catch-all but fail on decryption or encryption key processing
_, err = manager.getCredentialForDomain(1, "any-domain.com", provider)
require.Error(t, err)
// The error could be from encryptor creation or decryption
require.True(t, strings.Contains(err.Error(), "failed to decrypt") || strings.Contains(err.Error(), "failed to create encryptor"),
"expected encryption/decryption error, got: %s", err.Error())
}
// TestComputeEffectiveFlags_DB_SecurityConfigWAFDisabled verifies WAF disabled in SecurityConfig
func TestComputeEffectiveFlags_DB_SecurityConfigWAFDisabled(t *testing.T) {
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.Setting{}, &models.SecurityConfig{}))
secCfg := config.SecurityConfig{CerberusEnabled: true, WAFMode: "enabled"}
manager := NewManager(nil, db, "", "", false, secCfg)
// Set WAF mode to disabled in DB
res := db.Create(&models.SecurityConfig{Name: "default", Enabled: true, WAFMode: "disabled"})
require.NoError(t, res.Error)
_, _, waf, _, _ := manager.computeEffectiveFlags(context.Background())
require.False(t, waf)
}
// TestComputeEffectiveFlags_DB_RateLimitFromBooleanField verifies backward compat with boolean field
func TestComputeEffectiveFlags_DB_RateLimitFromBooleanField(t *testing.T) {
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.Setting{}, &models.SecurityConfig{}))
secCfg := config.SecurityConfig{CerberusEnabled: true, RateLimitMode: ""}
manager := NewManager(nil, db, "", "", false, secCfg)
// Set rate limit via boolean field (backward compatibility)
res := db.Create(&models.SecurityConfig{Name: "default", Enabled: true, RateLimitEnable: true, RateLimitMode: ""})
require.NoError(t, res.Error)
_, _, _, rl, _ := manager.computeEffectiveFlags(context.Background())
require.True(t, rl)
}
// TestComputeEffectiveFlags_DB_CrowdSecModeFromSecurityConfig verifies CrowdSec mode from DB
func TestComputeEffectiveFlags_DB_CrowdSecModeFromSecurityConfig(t *testing.T) {
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.Setting{}, &models.SecurityConfig{}))
secCfg := config.SecurityConfig{CerberusEnabled: true, CrowdSecMode: ""}
manager := NewManager(nil, db, "", "", false, secCfg)
// Set CrowdSec mode in SecurityConfig table
res := db.Create(&models.SecurityConfig{Name: "default", Enabled: true, CrowdSecMode: "local"})
require.NoError(t, res.Error)
_, _, _, _, cs := manager.computeEffectiveFlags(context.Background())
require.True(t, cs)
}
// TestComputeEffectiveFlags_DB_LegacyCerberusKey verifies legacy security.cerberus.enabled key
func TestComputeEffectiveFlags_DB_LegacyCerberusKey(t *testing.T) {
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.Setting{}))
secCfg := config.SecurityConfig{CerberusEnabled: false} // Start with false
manager := NewManager(nil, db, "", "", false, secCfg)
// Set via legacy key
res := db.Create(&models.Setting{Key: "security.cerberus.enabled", Value: "true"})
require.NoError(t, res.Error)
cerb, _, _, _, _ := manager.computeEffectiveFlags(context.Background())
require.True(t, cerb)
}

View File

@@ -22,12 +22,11 @@ import (
func encryptCredentials(t *testing.T, credentials map[string]string) string {
t.Helper()
// Use a valid 32-byte base64-encoded key (decodes to exactly 32 bytes)
encryptionKey := os.Getenv("CHARON_ENCRYPTION_KEY")
if encryptionKey == "" {
encryptionKey = "MTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTI="
os.Setenv("CHARON_ENCRYPTION_KEY", encryptionKey)
}
// Always use a valid 32-byte base64-encoded key (decodes to exactly 32 bytes)
// base64.StdEncoding.EncodeToString([]byte("12345678901234567890123456789012"))
// = "MTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTI="
encryptionKey := "MTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTI="
os.Setenv("CHARON_ENCRYPTION_KEY", encryptionKey)
encryptor, err := crypto.NewEncryptionService(encryptionKey)
require.NoError(t, err)