- Implemented `useManualChallenge`, `useChallengePoll`, and `useManualChallengeMutations` hooks for managing manual DNS challenges. - Created tests for the `useManualChallenge` hooks to ensure correct fetching and mutation behavior. - Added `ManualDNSChallenge` component for displaying challenge details and actions. - Developed end-to-end tests for the Manual DNS Provider feature, covering provider selection, challenge UI, and accessibility compliance. - Included error handling tests for verification failures and network errors.
428 lines
13 KiB
Go
428 lines
13 KiB
Go
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
|
|
}
|