package caddy import ( "context" "encoding/json" "os" "testing" "github.com/Wikid82/charon/backend/internal/config" "github.com/Wikid82/charon/backend/internal/crypto" "github.com/Wikid82/charon/backend/internal/models" "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gorm.io/driver/sqlite" "gorm.io/gorm" _ "github.com/Wikid82/charon/backend/pkg/dnsprovider/builtin" // Auto-register DNS providers ) // encryptCredentials is a helper to encrypt credentials for test fixtures func encryptCredentials(t *testing.T, credentials map[string]string) string { t.Helper() // 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) credJSON, err := json.Marshal(credentials) require.NoError(t, err) encrypted, err := encryptor.Encrypt(credJSON) require.NoError(t, err) return encrypted } // setupTestDB creates an in-memory database for testing func setupTestDB(t *testing.T) *gorm.DB { t.Helper() db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) require.NoError(t, err) // Auto-migrate all models including related ones err = db.AutoMigrate( &models.ProxyHost{}, &models.Location{}, &models.DNSProvider{}, &models.DNSProviderCredential{}, &models.SSLCertificate{}, &models.Setting{}, &models.SecurityConfig{}, &models.AccessList{}, &models.SecurityHeaderProfile{}, ) require.NoError(t, err) return db } // TestApplyConfig_SingleCredential_BackwardCompatibility tests that single-credential // providers continue to work as before (backward compatibility) func TestApplyConfig_SingleCredential_BackwardCompatibility(t *testing.T) { db := setupTestDB(t) // Create a single-credential provider provider := models.DNSProvider{ ProviderType: "cloudflare", UseMultiCredentials: false, CredentialsEncrypted: encryptCredentials(t, map[string]string{ "api_token": "test-single-token", }), PropagationTimeout: 60, Enabled: true, } require.NoError(t, db.Create(&provider).Error) // Create a proxy host with wildcard domain host := models.ProxyHost{ DomainNames: "*.example.com", DNSProviderID: &provider.ID, ForwardHost: "localhost", ForwardPort: 8080, Enabled: true, } require.NoError(t, db.Create(&host).Error) // Create ACME email setting setting := models.Setting{ Key: "caddy.acme_email", Value: "test@example.com", } require.NoError(t, db.Create(&setting).Error) // Create manager with mock client mockClient := &MockClient{} manager := NewManager(mockClient, db, t.TempDir(), "", false, config.SecurityConfig{}) // Apply config err := manager.ApplyConfig(context.Background()) require.NoError(t, err) // Verify the generated config has DNS challenge with single credential assert.True(t, mockClient.LoadCalled, "Load should have been called") assert.NotNil(t, mockClient.LastLoadedConfig, "Config should have been loaded") // Verify TLS automation policies exist require.NotNil(t, mockClient.LastLoadedConfig.Apps.TLS) require.NotNil(t, mockClient.LastLoadedConfig.Apps.TLS.Automation) require.Greater(t, len(mockClient.LastLoadedConfig.Apps.TLS.Automation.Policies), 0) // Find the DNS challenge policy var dnsPolicy *AutomationPolicy for _, policy := range mockClient.LastLoadedConfig.Apps.TLS.Automation.Policies { if len(policy.Subjects) > 0 && policy.Subjects[0] == "*.example.com" { dnsPolicy = policy break } } require.NotNil(t, dnsPolicy, "DNS challenge policy should exist for *.example.com") // Verify it uses the single credential require.Greater(t, len(dnsPolicy.IssuersRaw), 0) issuer := dnsPolicy.IssuersRaw[0].(map[string]any) require.NotNil(t, issuer["challenges"]) challenges := issuer["challenges"].(map[string]any) require.NotNil(t, challenges["dns"]) dnsChallenge := challenges["dns"].(map[string]any) require.NotNil(t, dnsChallenge["provider"]) providerConfig := dnsChallenge["provider"].(map[string]any) assert.Equal(t, "cloudflare", providerConfig["name"]) assert.Equal(t, "test-single-token", providerConfig["api_token"]) } // TestApplyConfig_MultiCredential_ExactMatch tests that multi-credential providers // correctly match credentials by exact zone match func TestApplyConfig_MultiCredential_ExactMatch(t *testing.T) { db := setupTestDB(t) // Create a multi-credential provider provider := models.DNSProvider{ ProviderType: "cloudflare", UseMultiCredentials: true, PropagationTimeout: 60, Enabled: true, } require.NoError(t, db.Create(&provider).Error) // Create zone-specific credentials exampleComCred := models.DNSProviderCredential{ UUID: uuid.New().String(), DNSProviderID: provider.ID, Label: "Example.com Credential", ZoneFilter: "example.com", CredentialsEncrypted: encryptCredentials(t, map[string]string{ "api_token": "token-example-com", }), Enabled: true, } require.NoError(t, db.Create(&exampleComCred).Error) exampleOrgCred := models.DNSProviderCredential{ UUID: uuid.New().String(), DNSProviderID: provider.ID, Label: "Example.org Credential", ZoneFilter: "example.org", CredentialsEncrypted: encryptCredentials(t, map[string]string{ "api_token": "token-example-org", }), Enabled: true, } require.NoError(t, db.Create(&exampleOrgCred).Error) // Create proxy hosts for different domains hostCom := models.ProxyHost{ UUID: uuid.New().String(), DomainNames: "*.example.com", DNSProviderID: &provider.ID, ForwardHost: "localhost", ForwardPort: 8080, Enabled: true, } require.NoError(t, db.Create(&hostCom).Error) hostOrg := models.ProxyHost{ UUID: uuid.New().String(), DomainNames: "*.example.org", DNSProviderID: &provider.ID, ForwardHost: "localhost", ForwardPort: 8081, Enabled: true, } require.NoError(t, db.Create(&hostOrg).Error) // Create ACME email setting setting := models.Setting{ Key: "caddy.acme_email", Value: "test@example.com", } require.NoError(t, db.Create(&setting).Error) // Create manager with mock client mockClient := &MockClient{} manager := NewManager(mockClient, db, t.TempDir(), "", false, config.SecurityConfig{}) // Apply config err := manager.ApplyConfig(context.Background()) require.NoError(t, err) // Verify the generated config has separate DNS challenge policies assert.True(t, mockClient.LoadCalled) require.NotNil(t, mockClient.LastLoadedConfig.Apps.TLS) require.NotNil(t, mockClient.LastLoadedConfig.Apps.TLS.Automation) policies := mockClient.LastLoadedConfig.Apps.TLS.Automation.Policies require.Greater(t, len(policies), 1, "Should have separate policies for each domain") // Find policies for each domain var comPolicy, orgPolicy *AutomationPolicy for _, policy := range policies { if len(policy.Subjects) > 0 { switch policy.Subjects[0] { case "*.example.com": comPolicy = policy case "*.example.org": orgPolicy = policy } } } require.NotNil(t, comPolicy, "Policy for *.example.com should exist") require.NotNil(t, orgPolicy, "Policy for *.example.org should exist") // Verify each policy uses the correct credential assertDNSChallengeCredential(t, comPolicy, "cloudflare", "token-example-com") assertDNSChallengeCredential(t, orgPolicy, "cloudflare", "token-example-org") } // TestApplyConfig_MultiCredential_WildcardMatch tests wildcard zone matching func TestApplyConfig_MultiCredential_WildcardMatch(t *testing.T) { db := setupTestDB(t) // Create a multi-credential provider provider := models.DNSProvider{ ProviderType: "cloudflare", UseMultiCredentials: true, PropagationTimeout: 60, Enabled: true, } require.NoError(t, db.Create(&provider).Error) // Create wildcard credential for *.example.com (matches app.example.com, api.example.com, etc.) wildcardCred := models.DNSProviderCredential{ UUID: uuid.New().String(), DNSProviderID: provider.ID, Label: "Wildcard Example.com", ZoneFilter: "*.example.com", CredentialsEncrypted: encryptCredentials(t, map[string]string{ "api_token": "token-wildcard", }), Enabled: true, } require.NoError(t, db.Create(&wildcardCred).Error) // Create proxy host for subdomain host := models.ProxyHost{ UUID: uuid.New().String(), DomainNames: "*.app.example.com", DNSProviderID: &provider.ID, ForwardHost: "localhost", ForwardPort: 8080, Enabled: true, } require.NoError(t, db.Create(&host).Error) // Create ACME email setting setting := models.Setting{ Key: "caddy.acme_email", Value: "test@example.com", } require.NoError(t, db.Create(&setting).Error) // Create manager with mock client mockClient := &MockClient{} manager := NewManager(mockClient, db, t.TempDir(), "", false, config.SecurityConfig{}) // Apply config err := manager.ApplyConfig(context.Background()) require.NoError(t, err) // Verify config was generated assert.True(t, mockClient.LoadCalled) require.NotNil(t, mockClient.LastLoadedConfig.Apps.TLS) require.NotNil(t, mockClient.LastLoadedConfig.Apps.TLS.Automation) // Find the DNS challenge policy var dnsPolicy *AutomationPolicy for _, policy := range mockClient.LastLoadedConfig.Apps.TLS.Automation.Policies { if len(policy.Subjects) > 0 && policy.Subjects[0] == "*.app.example.com" { dnsPolicy = policy break } } require.NotNil(t, dnsPolicy, "DNS challenge policy should exist") // Verify it uses the wildcard credential assertDNSChallengeCredential(t, dnsPolicy, "cloudflare", "token-wildcard") } // TestApplyConfig_MultiCredential_CatchAll tests catch-all credential (empty zone_filter) func TestApplyConfig_MultiCredential_CatchAll(t *testing.T) { db := setupTestDB(t) // Create a multi-credential provider provider := models.DNSProvider{ ProviderType: "cloudflare", UseMultiCredentials: true, PropagationTimeout: 60, Enabled: true, } require.NoError(t, db.Create(&provider).Error) // Create catch-all credential (empty zone_filter) catchAllCred := models.DNSProviderCredential{ UUID: uuid.New().String(), DNSProviderID: provider.ID, Label: "Catch-All", ZoneFilter: "", CredentialsEncrypted: encryptCredentials(t, map[string]string{ "api_token": "token-catch-all", }), Enabled: true, } require.NoError(t, db.Create(&catchAllCred).Error) // Create proxy host for a domain with no specific credential host := models.ProxyHost{ UUID: uuid.New().String(), DomainNames: "*.random.net", DNSProviderID: &provider.ID, ForwardHost: "localhost", ForwardPort: 8080, Enabled: true, } require.NoError(t, db.Create(&host).Error) // Create ACME email setting setting := models.Setting{ Key: "caddy.acme_email", Value: "test@example.com", } require.NoError(t, db.Create(&setting).Error) // Create manager with mock client mockClient := &MockClient{} manager := NewManager(mockClient, db, t.TempDir(), "", false, config.SecurityConfig{}) // Apply config err := manager.ApplyConfig(context.Background()) require.NoError(t, err) // Verify config was generated assert.True(t, mockClient.LoadCalled) require.NotNil(t, mockClient.LastLoadedConfig.Apps.TLS) require.NotNil(t, mockClient.LastLoadedConfig.Apps.TLS.Automation) // Find the DNS challenge policy var dnsPolicy *AutomationPolicy for _, policy := range mockClient.LastLoadedConfig.Apps.TLS.Automation.Policies { if len(policy.Subjects) > 0 && policy.Subjects[0] == "*.random.net" { dnsPolicy = policy break } } require.NotNil(t, dnsPolicy, "DNS challenge policy should exist") // Verify it uses the catch-all credential assertDNSChallengeCredential(t, dnsPolicy, "cloudflare", "token-catch-all") } // assertDNSChallengeCredential is a helper to verify DNS challenge uses correct credentials func assertDNSChallengeCredential(t *testing.T, policy *AutomationPolicy, providerType, expectedToken string) { t.Helper() require.Greater(t, len(policy.IssuersRaw), 0, "Policy should have issuers") issuer := policy.IssuersRaw[0].(map[string]any) require.NotNil(t, issuer["challenges"], "Issuer should have challenges") challenges := issuer["challenges"].(map[string]any) require.NotNil(t, challenges["dns"], "Challenges should have DNS") dnsChallenge := challenges["dns"].(map[string]any) require.NotNil(t, dnsChallenge["provider"], "DNS challenge should have provider") providerConfig := dnsChallenge["provider"].(map[string]any) assert.Equal(t, providerType, providerConfig["name"], "Provider type should match") assert.Equal(t, expectedToken, providerConfig["api_token"], "API token should match") } // MockClient is a mock Caddy client for testing type MockClient struct { LoadCalled bool LastLoadedConfig *Config PingError error LoadError error GetConfigResult *Config GetConfigError error } func (m *MockClient) Load(ctx context.Context, config *Config) error { m.LoadCalled = true m.LastLoadedConfig = config return m.LoadError } func (m *MockClient) Ping(ctx context.Context) error { return m.PingError } func (m *MockClient) GetConfig(ctx context.Context) (*Config, error) { return m.GetConfigResult, m.GetConfigError }