test: add SMTP configuration tests and multi-credential DNS provider support

This commit is contained in:
GitHub Actions
2026-01-09 07:02:36 +00:00
parent 04532efa05
commit b28f3b8bcc
6 changed files with 882 additions and 2668 deletions
-2
View File
@@ -207,8 +207,6 @@ golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -1,10 +1,15 @@
package handlers_test
import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"net"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"github.com/gin-gonic/gin"
@@ -16,6 +21,101 @@ import (
"github.com/Wikid82/charon/backend/internal/models"
)
func startTestSMTPServer(t *testing.T) (host string, port int) {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("failed to listen for smtp test server: %v", err)
}
var wg sync.WaitGroup
acceptDone := make(chan struct{})
go func() {
defer close(acceptDone)
for {
conn, err := ln.Accept()
if err != nil {
return
}
wg.Add(1)
go func(c net.Conn) {
defer wg.Done()
defer func() { _ = c.Close() }()
handleSMTPConnection(c)
}(conn)
}
}()
t.Cleanup(func() {
_ = ln.Close()
<-acceptDone
wg.Wait()
})
host, portStr, err := net.SplitHostPort(ln.Addr().String())
if err != nil {
t.Fatalf("failed to split smtp listener addr: %v", err)
}
if _, err := fmt.Sscanf(portStr, "%d", &port); err != nil {
t.Fatalf("failed to parse smtp listener port: %v", err)
}
return host, port
}
func handleSMTPConnection(conn net.Conn) {
r := bufio.NewReader(conn)
w := bufio.NewWriter(conn)
writeLine := func(line string) {
_, _ = w.WriteString(line + "\r\n")
_ = w.Flush()
}
writeLine("220 localhost ESMTP test")
for {
line, err := r.ReadString('\n')
if err != nil {
return
}
cmd := strings.TrimSpace(line)
upper := strings.ToUpper(cmd)
switch {
case strings.HasPrefix(upper, "EHLO") || strings.HasPrefix(upper, "HELO"):
writeLine("250-localhost")
writeLine("250 OK")
case strings.HasPrefix(upper, "MAIL FROM:"):
writeLine("250 OK")
case strings.HasPrefix(upper, "RCPT TO:"):
writeLine("250 OK")
case strings.HasPrefix(upper, "DATA"):
writeLine("354 End data with <CR><LF>.<CR><LF>")
for {
dataLine, err := r.ReadString('\n')
if err != nil {
return
}
if strings.TrimRight(dataLine, "\r\n") == "." {
break
}
}
writeLine("250 OK")
case strings.HasPrefix(upper, "RSET"):
writeLine("250 OK")
case strings.HasPrefix(upper, "NOOP"):
writeLine("250 OK")
case strings.HasPrefix(upper, "QUIT"):
writeLine("221 Bye")
return
default:
writeLine("250 OK")
}
}
}
func setupSettingsTestDB(t *testing.T) *gorm.DB {
dsn := "file:" + t.Name() + "?mode=memory&cache=shared"
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
@@ -400,6 +500,35 @@ func TestSettingsHandler_TestSMTPConfig_NotConfigured(t *testing.T) {
assert.Equal(t, false, resp["success"])
}
func TestSettingsHandler_TestSMTPConfig_Success(t *testing.T) {
gin.SetMode(gin.TestMode)
handler, db := setupSettingsHandlerWithMail(t)
host, port := startTestSMTPServer(t)
// Seed SMTP config for local test server.
db.Create(&models.Setting{Key: "smtp_host", Value: host, Category: "smtp", Type: "string"})
db.Create(&models.Setting{Key: "smtp_port", Value: fmt.Sprintf("%d", port), Category: "smtp", Type: "number"})
db.Create(&models.Setting{Key: "smtp_encryption", Value: "none", Category: "smtp", Type: "string"})
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
router.POST("/settings/smtp/test", handler.TestSMTPConfig)
req, _ := http.NewRequest("POST", "/settings/smtp/test", http.NoBody)
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["success"])
}
func TestSettingsHandler_SendTestEmail_NonAdmin(t *testing.T) {
gin.SetMode(gin.TestMode)
handler, _ := setupSettingsHandlerWithMail(t)
@@ -464,6 +593,38 @@ func TestSettingsHandler_SendTestEmail_NotConfigured(t *testing.T) {
assert.Equal(t, false, resp["success"])
}
func TestSettingsHandler_SendTestEmail_Success(t *testing.T) {
gin.SetMode(gin.TestMode)
handler, db := setupSettingsHandlerWithMail(t)
host, port := startTestSMTPServer(t)
// Seed SMTP config for local test server.
db.Create(&models.Setting{Key: "smtp_host", Value: host, Category: "smtp", Type: "string"})
db.Create(&models.Setting{Key: "smtp_port", Value: fmt.Sprintf("%d", port), Category: "smtp", Type: "number"})
db.Create(&models.Setting{Key: "smtp_from_address", Value: "noreply@example.com", Category: "smtp", Type: "string"})
db.Create(&models.Setting{Key: "smtp_encryption", Value: "none", Category: "smtp", Type: "string"})
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
router.POST("/settings/smtp/send-test", handler.SendTestEmail)
body := map[string]string{"to": "test@example.com"}
jsonBody, _ := json.Marshal(body)
req, _ := http.NewRequest("POST", "/settings/smtp/send-test", 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["success"])
}
func TestMaskPassword(t *testing.T) {
// Empty password
assert.Equal(t, "", handlers.MaskPasswordForTest(""))
@@ -768,6 +929,35 @@ func TestSettingsHandler_TestPublicURL_DNSFailure(t *testing.T) {
"Expected DNS error message, got: %s", errorMsg)
}
func TestSettingsHandler_TestPublicURL_ConnectivityError(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)
// 192.0.2.0/24 is reserved for documentation/testing and is not considered private by
// network.IsPrivateIP(). Using a closed port should trigger a deterministic connect error
// after passing SSRF validation.
body := map[string]string{"url": "http://192.0.2.1: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.Equal(t, false, resp["reachable"])
_, ok := resp["error"].(string)
assert.True(t, ok)
}
// ============= SSRF Protection Tests =============
func TestSettingsHandler_TestPublicURL_SSRFProtection(t *testing.T) {
+32
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"github.com/stretchr/testify/require"
@@ -193,3 +194,34 @@ func TestClient_Ping_TransportError(t *testing.T) {
require.Error(t, err)
require.Contains(t, err.Error(), "caddy unreachable")
}
func TestClient_GetConfig_BaseURLNil_ReturnsError(t *testing.T) {
client := &Client{
baseURL: nil,
httpClient: http.DefaultClient,
initErr: nil,
}
_, err := client.GetConfig(context.Background())
require.Error(t, err)
require.Contains(t, err.Error(), "base URL is not configured")
}
func TestClient_RequestCreationErrors_FromInvalidResolvedURL(t *testing.T) {
client := &Client{
baseURL: &url.URL{Scheme: "http", Host: "example.com\n"},
initErr: nil,
}
err := client.Load(context.Background(), &Config{})
require.Error(t, err)
require.Contains(t, err.Error(), "create request")
_, err = client.GetConfig(context.Background())
require.Error(t, err)
require.Contains(t, err.Error(), "create request")
err = client.Ping(context.Background())
require.Error(t, err)
require.Contains(t, err.Error(), "create request")
}
@@ -3,20 +3,50 @@ package caddy
import (
"os"
"testing"
"time"
"github.com/Wikid82/charon/backend/internal/models"
"github.com/Wikid82/charon/backend/pkg/dnsprovider"
"github.com/stretchr/testify/require"
_ "github.com/Wikid82/charon/backend/pkg/dnsprovider/builtin" // Auto-register DNS providers
)
type multiCredTestProvider struct{}
func (p *multiCredTestProvider) Type() string { return "testmulti" }
func (p *multiCredTestProvider) Metadata() dnsprovider.ProviderMetadata {
return dnsprovider.ProviderMetadata{Type: p.Type(), Name: "Test Multi", IsBuiltIn: true}
}
func (p *multiCredTestProvider) Init() error { return nil }
func (p *multiCredTestProvider) Cleanup() error { return nil }
func (p *multiCredTestProvider) RequiredCredentialFields() []dnsprovider.CredentialFieldSpec {
return nil
}
func (p *multiCredTestProvider) OptionalCredentialFields() []dnsprovider.CredentialFieldSpec {
return nil
}
func (p *multiCredTestProvider) ValidateCredentials(creds map[string]string) error { return nil }
func (p *multiCredTestProvider) TestCredentials(creds map[string]string) error { return nil }
func (p *multiCredTestProvider) SupportsMultiCredential() bool { return true }
func (p *multiCredTestProvider) BuildCaddyConfig(creds map[string]string) map[string]any {
return map[string]any{"name": p.Type(), "token": creds["token"]}
}
func (p *multiCredTestProvider) BuildCaddyConfigForZone(baseDomain string, creds map[string]string) map[string]any {
return map[string]any{"name": p.Type(), "zone": baseDomain, "token": creds["token"]}
}
func (p *multiCredTestProvider) PropagationTimeout() time.Duration { return 2 * time.Second }
func (p *multiCredTestProvider) PollingInterval() time.Duration { return 1 * time.Second }
func mustProviderID(v uint) *uint { return &v }
func TestGenerateConfig_DNSChallenge_LetsEncrypt_StagingCAAndPropagationTimeout(t *testing.T) {
providerID := uint(1)
host := models.ProxyHost{
Enabled: true,
DomainNames: "*.example.com,example.com",
DNSProvider: &models.DNSProvider{ID: providerID, ProviderType: "cloudflare"},
DNSProviderID: func() *uint { v := providerID; return &v }(),
DNSProviderID: mustProviderID(providerID),
}
conf, err := GenerateConfig(
@@ -86,7 +116,7 @@ func TestGenerateConfig_DNSChallenge_ZeroSSL_IssuerShape(t *testing.T) {
Enabled: true,
DomainNames: "*.example.net",
DNSProvider: &models.DNSProvider{ID: providerID, ProviderType: "cloudflare"},
DNSProviderID: func() *uint { v := providerID; return &v }(),
DNSProviderID: mustProviderID(providerID),
}
conf, err := GenerateConfig(
@@ -137,7 +167,7 @@ func TestGenerateConfig_DNSChallenge_SkipsPolicyWhenProviderConfigMissing(t *tes
Enabled: true,
DomainNames: "*.example.org",
DNSProvider: &models.DNSProvider{ID: providerID, ProviderType: "cloudflare"},
DNSProviderID: func() *uint { v := providerID; return &v }(),
DNSProviderID: mustProviderID(providerID),
}
conf, err := GenerateConfig(
@@ -229,7 +259,7 @@ func TestGenerateConfig_MultiCredential_ZoneSpecificPolicies(t *testing.T) {
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 }(),
DNSProviderID: mustProviderID(providerID),
}
conf, err := GenerateConfig(
@@ -284,7 +314,7 @@ func TestGenerateConfig_MultiCredential_ZeroSSL_Issuer(t *testing.T) {
Enabled: true,
DomainNames: "*.zerossl-test.com",
DNSProvider: &models.DNSProvider{ID: providerID, ProviderType: "cloudflare"},
DNSProviderID: func() *uint { v := providerID; return &v }(),
DNSProviderID: mustProviderID(providerID),
}
conf, err := GenerateConfig(
@@ -337,7 +367,7 @@ func TestGenerateConfig_MultiCredential_BothIssuers(t *testing.T) {
Enabled: true,
DomainNames: "*.both-test.com",
DNSProvider: &models.DNSProvider{ID: providerID, ProviderType: "cloudflare"},
DNSProviderID: func() *uint { v := providerID; return &v }(),
DNSProviderID: mustProviderID(providerID),
}
conf, err := GenerateConfig(
@@ -394,7 +424,7 @@ func TestGenerateConfig_MultiCredential_ACMEStaging(t *testing.T) {
Enabled: true,
DomainNames: "*.staging-test.com",
DNSProvider: &models.DNSProvider{ID: providerID, ProviderType: "cloudflare"},
DNSProviderID: func() *uint { v := providerID; return &v }(),
DNSProviderID: mustProviderID(providerID),
}
conf, err := GenerateConfig(
@@ -449,7 +479,7 @@ func TestGenerateConfig_MultiCredential_NoMatchingDomains(t *testing.T) {
Enabled: true,
DomainNames: "*.actual.com",
DNSProvider: &models.DNSProvider{ID: providerID, ProviderType: "cloudflare"},
DNSProviderID: func() *uint { v := providerID; return &v }(),
DNSProviderID: mustProviderID(providerID),
}
conf, err := GenerateConfig(
@@ -496,7 +526,7 @@ func TestGenerateConfig_MultiCredential_ProviderTypeNotFound(t *testing.T) {
Enabled: true,
DomainNames: "*.unknown-provider.com",
DNSProvider: &models.DNSProvider{ID: providerID, ProviderType: "nonexistent_provider"},
DNSProviderID: func() *uint { v := providerID; return &v }(),
DNSProviderID: mustProviderID(providerID),
}
conf, err := GenerateConfig(
@@ -525,3 +555,338 @@ func TestGenerateConfig_MultiCredential_ProviderTypeNotFound(t *testing.T) {
require.NoError(t, err)
require.NotNil(t, conf)
}
func TestGenerateConfig_MultiCredential_SupportsMultiCredential_UsesZoneConfigAndStagingBothIssuers(t *testing.T) {
if err := dnsprovider.Global().Register(&multiCredTestProvider{}); err == nil {
t.Cleanup(func() { dnsprovider.Global().Unregister("testmulti") })
}
providerID := uint(16)
host := models.ProxyHost{
Enabled: true,
DomainNames: "*.z1.com,z1.com",
DNSProvider: &models.DNSProvider{ID: providerID, ProviderType: "testmulti"},
DNSProviderID: mustProviderID(providerID),
}
conf, err := GenerateConfig(
[]models.ProxyHost{host},
t.TempDir(),
"acme@example.com",
"",
"both",
true,
false, false, false, false,
"",
nil,
nil,
nil,
&models.SecurityConfig{},
[]DNSProviderConfig{{
ID: providerID,
ProviderType: "testmulti",
UseMultiCredentials: true,
ZoneCredentials: map[string]map[string]string{
"z1.com": {"token": "tok-z1"},
},
}},
)
require.NoError(t, err)
require.NotNil(t, conf)
require.NotNil(t, conf.Apps.TLS)
require.NotNil(t, conf.Apps.TLS.Automation)
require.NotEmpty(t, conf.Apps.TLS.Automation.Policies)
var foundCA string
var foundProvider map[string]any
for _, p := range conf.Apps.TLS.Automation.Policies {
if p == nil {
continue
}
for _, it := range p.IssuersRaw {
issuer, ok := it.(map[string]any)
if !ok || issuer["module"] != "acme" {
continue
}
if ca, ok := issuer["ca"].(string); ok {
foundCA = ca
}
ch, _ := issuer["challenges"].(map[string]any)
dnsCh, _ := ch["dns"].(map[string]any)
prov, _ := dnsCh["provider"].(map[string]any)
foundProvider = prov
break
}
if foundProvider != nil {
break
}
}
require.Equal(t, "https://acme-staging-v02.api.letsencrypt.org/directory", foundCA)
require.NotNil(t, foundProvider)
require.Equal(t, "z1.com", foundProvider["zone"], "expected zone-specific provider config")
}
func TestGenerateConfig_DNSChallenge_SingleCredential_BothIssuers_ACMEStaging(t *testing.T) {
providerID := uint(17)
host := models.ProxyHost{
Enabled: true,
DomainNames: "*.both-staging.com",
DNSProvider: &models.DNSProvider{ID: providerID, ProviderType: "cloudflare"},
DNSProviderID: mustProviderID(providerID),
}
conf, err := GenerateConfig(
[]models.ProxyHost{host},
t.TempDir(),
"acme@example.com",
"",
"both",
true,
false, false, false, false,
"",
nil,
nil,
nil,
&models.SecurityConfig{},
[]DNSProviderConfig{{
ID: providerID,
ProviderType: "cloudflare",
PropagationTimeout: 1,
Credentials: map[string]string{"api_token": "tok"},
}},
)
require.NoError(t, err)
require.NotNil(t, conf)
var foundIssuer map[string]any
for _, p := range conf.Apps.TLS.Automation.Policies {
if p == nil {
continue
}
for _, s := range p.Subjects {
if s != "*.both-staging.com" {
continue
}
for _, it := range p.IssuersRaw {
if m, ok := it.(map[string]any); ok && m["module"] == "acme" {
foundIssuer = m
break
}
}
}
if foundIssuer != nil {
break
}
}
require.NotNil(t, foundIssuer)
require.Equal(t, "https://acme-staging-v02.api.letsencrypt.org/directory", foundIssuer["ca"])
}
func TestGenerateConfig_DNSChallenge_SingleCredential_ProviderTypeNotFound_SkipsPolicy(t *testing.T) {
providerID := uint(18)
host := models.ProxyHost{
Enabled: true,
DomainNames: "*.missing-registry.com",
DNSProvider: &models.DNSProvider{ID: providerID, ProviderType: "not_registered"},
DNSProviderID: mustProviderID(providerID),
}
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: "not_registered",
PropagationTimeout: 1,
Credentials: map[string]string{"token": "x"},
}},
)
require.NoError(t, err)
require.NotNil(t, conf)
}
func TestGenerateConfig_DefaultPolicy_LetsEncrypt_StagingCA(t *testing.T) {
host := models.ProxyHost{Enabled: true, DomainNames: "192.0.2.1"}
conf, err := GenerateConfig(
[]models.ProxyHost{host},
t.TempDir(),
"acme@example.com",
"",
"letsencrypt",
true,
false, false, false, false,
"",
nil,
nil,
nil,
&models.SecurityConfig{},
nil,
)
require.NoError(t, err)
require.NotNil(t, conf)
require.NotNil(t, conf.Apps.TLS)
require.NotNil(t, conf.Apps.TLS.Automation)
require.NotEmpty(t, conf.Apps.TLS.Automation.Policies)
found := 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 && m["module"] == "acme" {
require.Equal(t, "https://acme-staging-v02.api.letsencrypt.org/directory", m["ca"])
found = true
break
}
}
if found {
break
}
}
require.True(t, found)
}
func TestGenerateConfig_DefaultPolicy_ZeroSSL_Issuer(t *testing.T) {
host := models.ProxyHost{Enabled: true, DomainNames: "192.0.2.2"}
conf, err := GenerateConfig(
[]models.ProxyHost{host},
t.TempDir(),
"acme@example.com",
"",
"zerossl",
false,
false, false, false, false,
"",
nil,
nil,
nil,
&models.SecurityConfig{},
nil,
)
require.NoError(t, err)
require.NotNil(t, conf)
require.NotNil(t, conf.Apps.TLS)
require.NotNil(t, conf.Apps.TLS.Automation)
found := 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 && m["module"] == "zerossl" {
found = true
break
}
}
if found {
break
}
}
require.True(t, found)
}
func TestGenerateConfig_DefaultPolicy_BothIssuers_StagingCA(t *testing.T) {
host := models.ProxyHost{Enabled: true, DomainNames: "192.0.2.3"}
conf, err := GenerateConfig(
[]models.ProxyHost{host},
t.TempDir(),
"acme@example.com",
"",
"",
true,
false, false, false, false,
"",
nil,
nil,
nil,
&models.SecurityConfig{},
nil,
)
require.NoError(t, err)
require.NotNil(t, conf)
caFound := false
zeroFound := false
for _, p := range conf.Apps.TLS.Automation.Policies {
if p == nil {
continue
}
for _, it := range p.IssuersRaw {
m, ok := it.(map[string]any)
if !ok {
continue
}
switch m["module"] {
case "acme":
if m["ca"] == "https://acme-staging-v02.api.letsencrypt.org/directory" {
caFound = true
}
case "zerossl":
zeroFound = true
}
}
}
require.True(t, caFound)
require.True(t, zeroFound)
}
func TestGenerateConfig_IPSubjects_InitializesTLSAppAndAutomation(t *testing.T) {
providerID := uint(19)
host := models.ProxyHost{
Enabled: true,
UUID: "ip-host",
DomainNames: "1.2.3.4",
ForwardHost: "app",
ForwardPort: 8080,
DNSProvider: &models.DNSProvider{ID: providerID, ProviderType: "cloudflare"},
}
conf, err := GenerateConfig(
[]models.ProxyHost{host},
t.TempDir(),
"",
"",
"",
false,
false, false, false, false,
"",
nil,
nil,
nil,
&models.SecurityConfig{},
nil,
)
require.NoError(t, err)
require.NotNil(t, conf)
require.NotNil(t, conf.Apps.TLS)
require.NotNil(t, conf.Apps.TLS.Automation)
require.NotEmpty(t, conf.Apps.TLS.Automation.Policies)
}
func TestGetAccessLogPath_DockerEnv_UsesCrowdSecPath(t *testing.T) {
const dockerMarker = "/.dockerenv"
if err := os.WriteFile(dockerMarker, []byte("test"), 0o600); err != nil {
t.Skipf("cannot create %s: %v", dockerMarker, err)
}
t.Cleanup(func() { _ = os.Remove(dockerMarker) })
path := getAccessLogPath(t.TempDir(), false)
require.Equal(t, "/var/log/caddy/access.log", path)
}
+213 -2438
View File
File diff suppressed because it is too large Load Diff
+73 -219
View File
@@ -1,261 +1,115 @@
# QA Report: Test Failure Resolution and Coverage Boost
# QA/Security DoD Validation Report
**Date**: January 7, 2026
**PR**: #461 - DNS Challenge Support for Wildcard Certificates
**Branch**: feature/beta-release
**Status**: ✅ PASS
**Date**: 2026-01-09
**Scope**: DoD validation rerun (backend tests + lint + security scans)
**Overall Status**: ❌ FAIL
---
## Summary
## Executive Summary
All requested tasks completed successfully (no task execution failures). However, DoD fails due to **HIGH/CRITICAL security findings** in CodeQL and Trivy outputs.
All 30 originally failing tests have been fixed, backend coverage boosted from 82.7% to 85.2%, and all security scans passed with zero HIGH/CRITICAL findings. The codebase is ready for merge.
## Frontend Change Check
---
**Result**: No frontend files detected as changed (no paths under `frontend/` in current workspace changes).
## Test Coverage Results
**Action**: Per request, skipped:
- Test: Frontend with Coverage
- Lint: TypeScript Check
### Backend Coverage: 85.2% ✅
Note: the pre-commit run includes a frontend TypeScript check hook, but it is not a substitute for the explicit “Frontend with Coverage” task if frontend source changes are present.
- **Target**: 85%
- **Achieved**: 85.2% (+0.2% margin)
- **Tests Run**: All backend packages
- **Status**: PASSED
## Task Results (Required)
**Improvements Made**:
- Excluded `pkg/dnsprovider/builtin` from coverage (integration-tested, not unit-tested)
- Added comprehensive tests to `internal/services` and `internal/api/handlers`
- Focus on error paths, edge cases, and validation logic
### 1) Test: Backend with Coverage
**Key Package Coverage**:
- `internal/api/handlers`: 85%+ (was 81.9%)
- `internal/services`: 85%+ (was 80.7%)
- `internal/caddy`: 94.4%
- `internal/cerberus`: 100%
- `internal/config`: 100%
- `internal/models`: 96.4%
**Pass/Fail Criteria**:
- PASS if task exits successfully and produces a coverage result.
### Frontend Coverage: 85.65% ✅
**Result**: ✅ PASS (task completed)
- **Target**: 85%
- **Achieved**: 85.65% (+0.65% margin)
- **Tests Run**: 119 tests across 5 test files
- **Status**: PASSED
**Coverage**:
- Backend total coverage (from `go tool cover -func backend/coverage.txt`): **86.6%**
- Task output included: `coverage: 63.2% of statements` (package `backend/cmd/seed`)
---
### 2) Lint: Pre-commit (All Files)
## Test Fixes Summary
**Pass/Fail Criteria**:
- PASS if all hooks complete successfully.
### Phase 1: DNS Provider Registry Initialization (18 tests)
**Files Modified**:
- `backend/internal/api/handlers/credential_handler_test.go`
- `backend/internal/caddy/manager_multicred_integration_test.go`
- `backend/internal/caddy/config_patch_coverage_test.go`
- `backend/internal/services/dns_provider_service_test.go`
**Result**: ✅ PASS
**Fix**: Added blank import `_ "github.com/Wikid82/charon/backend/pkg/dnsprovider/builtin"` to trigger DNS provider registry initialization
### 3) Security: CodeQL All (CI-Aligned)
### Phase 2: Credential Field Name Corrections (4 tests)
**File**: `backend/internal/services/dns_provider_service_test.go`
**Pass/Fail Criteria**:
- PASS if no HIGH/CRITICAL findings are present.
**Fixes**:
- Hetzner: `api_key``api_token`
- DigitalOcean: `auth_token``api_token`
- DNSimple: `oauth_token``api_token`
**Result**: ❌ FAIL
### Phase 3: Security Handler Input Validation (1 test)
**File**: `backend/internal/api/handlers/security_handler.go`
**Findings**:
- Go SARIF (`codeql-results-go.sarif`): **3 CRITICAL** (security severity 9.8)
- Rule: `go/email-injection` (“Email content injection”)
- Location: `backend/internal/services/mail_service.go` (lines ~222, ~340, ~393)
- JS SARIF (`codeql-results-js.sarif`): **1 HIGH** (security severity 7.8)
- Rule: `js/incomplete-hostname-regexp` (“Incomplete regular expression for hostnames”)
- Location: `frontend/src/pages/__tests__/ProxyHosts-extra.test.tsx` (line ~252)
**Fix**: Added comprehensive input validation:
- `isValidIP()` - IP format validation
- `isValidCIDR()` - CIDR notation validation
- `isValidAction()` - Action enum validation (block/allow/captcha)
- `sanitizeString()` - Input sanitization
### 4) Security: Trivy Scan
### Phase 4: Security Settings Database Override (5 tests)
**File**: `backend/internal/testutil/db.go`
**Pass/Fail Criteria**:
- PASS if no HIGH/CRITICAL findings are present.
**Fix**: Added SQLite `_txlock=immediate` parameter to prevent database lock contention
**Result**: ❌ FAIL
### Phase 5: Certificate Deletion Race Condition (1 test)
**File**: Already fixed in previous PR
**Counts (from existing artifacts)**:
- `trivy-scan-output.txt`: **CRITICAL=1**, **HIGH=7**
- `trivy-image-scan.txt`: **CRITICAL=0**, **HIGH=1**
### Phase 6: Frontend LiveLogViewer Timeout (1 test)
**Status**: Already fixed in previous PR
## Root Cause (Why DoD Failed)
### Coverage Boost Tests
**Files Created/Modified**:
- `backend/internal/services/coverage_boost_test.go` - Service accessor and error path tests
- `backend/internal/api/handlers/plugin_handler_test.go` - Complete plugin handler coverage
### CodeQL
**New Tests Added**: 40+ test cases covering:
- Service accessors (DB(), Get*(), List*())
- Error handling for missing resources
- Plugin enable/disable/reload operations
- Notification provider lifecycle
- Security service configuration
- Mail service SMTP error paths
- GeoIP service validation
1) **CRITICAL** `go/email-injection` in `backend/internal/services/mail_service.go`
---
**Likely cause**: user-controlled or otherwise untrusted values are being used to build email content (and potentially headers) without robust validation/normalization, enabling header/body injection (e.g., newline injection).
## Security Scan Results
2) **HIGH** `js/incomplete-hostname-regexp` in a frontend test
### CodeQL Analysis ✅
**Likely cause**: a regex used for host matching in tests does not escape `.`, so it matches more than intended.
**Go Scan**:
- Queries Run: 61
- Errors: 0
- Warnings: 0
- Notes: 0
- **Status**: PASSED
### Trivy
**JavaScript Scan**:
- Queries Run: 88
- Errors: 0
- Warnings: 0
- Notes: 1 (regex pattern in test file - non-blocking)
- **Status**: PASSED
**Total Findings**: 0 blocking issues
### Trivy Container Scan
**Status**: Not run (Docker build verified locally, no containers built for this QA run)
**Likely cause**: one or more dependencies in the repo (Go modules and/or image contents) are pinned to vulnerable versions.
### Go Vulnerability Check (govulncheck)
**Status**: Not run (can be run in CI)
---
## Pre-commit Hooks ✅
**Status**: PASSED
**Hooks Verified**:
- ✅ Fix end of files
- ✅ Trim trailing whitespace
- ✅ Check YAML
- ✅ Check for added large files
- ✅ Dockerfile validation
- ✅ Go Vet
- ✅ Check .version matches Git tag
- ✅ Prevent large files not tracked by LFS
- ✅ Prevent committing CodeQL DB artifacts
- ✅ Prevent committing data/backups files
- ✅ Frontend TypeScript Check
- ✅ Frontend Lint (Fix)
Examples extracted from `trivy-scan-output.txt` / `trivy-image-scan.txt` include (non-exhaustive):
- `golang.org/x/crypto` (CVE-2024-45337 CRITICAL; CVE-2025-22869 HIGH)
- `golang.org/x/net` (CVE-2023-39325 HIGH)
- `golang.org/x/oauth2` (CVE-2025-22868 HIGH)
- `gopkg.in/yaml.v3` (CVE-2022-28948 HIGH)
- `github.com/quic-go/quic-go` (CVE-2025-59530 HIGH)
- `github.com/expr-lang/expr` (CVE-2025-68156 HIGH)
---
## Proposed Remediation (No changes applied)
## Type Safety ✅
Per instruction: **no fixes were made**. Suggested remediation steps:
### Backend (Go)
- **Status**: PASSED
- All packages compile successfully
- No type errors
### For CodeQL `go/email-injection`
### Frontend (TypeScript)
- **Status**: PASSED
- TypeScript 5.x type check passed
- All imports resolve correctly
- No type errors
- Validate/normalize any untrusted values used in mail headers/body (especially ensuring values do not contain `\r`/`\n`).
- Use strict email address parsing/validation (e.g., Go `net/mail`) and explicit header encoding.
- Ensure subject/from/to/reply-to fields are constructed via safe libraries and reject control characters.
---
### For CodeQL `js/incomplete-hostname-regexp`
## Issues Found and Resolved
- Update the test regex to escape `.` and/or use a safer matcher; rerun CodeQL JS scan.
### Issue 1: Mock DNS Provider Missing Interface Methods
**Severity**: High (compilation error)
**Location**: `backend/internal/api/handlers/plugin_handler_test.go`
**Root Cause**: `mockDNSProvider` was missing `Init()`, `Cleanup()`, and other interface methods
**Resolution**: Added all required `ProviderPlugin` interface methods to mock
**Status**: FIXED
### For Trivy findings
### Issue 2: Time Package Import Missing
**Severity**: Low (compilation error)
**Location**: `backend/internal/api/handlers/plugin_handler_test.go`
**Root Cause**: Mock methods return `time.Duration` but package not imported
**Resolution**: Added `time` to imports
**Status**: FIXED
- Upgrade impacted Go modules to versions containing fixes (follow Trivy “Fixed Version” guidance) and run `go mod tidy`.
- Re-run Trivy scan after dependency upgrades.
- If image findings remain: rebuild the image after base image upgrades and/or OS package updates.
---
## Artifacts
## Files Modified
### Configuration Files
- `.codecov.yml` - Added DNS provider builtin package exclusion
- `scripts/go-test-coverage.sh` - Added DNS provider to exclusion list
### Test Files
- `backend/internal/api/handlers/credential_handler_test.go` - Added blank import
- `backend/internal/caddy/manager_multicred_integration_test.go` - Added blank import
- `backend/internal/caddy/config_patch_coverage_test.go` - Added blank import
- `backend/internal/services/dns_provider_service_test.go` - Fixed credential fields + blank import
- `backend/internal/services/coverage_boost_test.go` - NEW (service tests)
- `backend/internal/api/handlers/plugin_handler_test.go` - NEW (handler tests)
### Source Files
- `backend/internal/api/handlers/security_handler.go` - Added input validation
- `backend/internal/api/handlers/security_handler_audit_test.go` - Fixed test action value
- `backend/internal/testutil/db.go` - Added SQLite txlock parameter
---
## Test Execution Summary
### Backend
- **Total Packages Tested**: 25+
- **Coverage**: 85.2%
- **All Tests**: PASSED
- **Execution Time**: ~30s
### Frontend
- **Test Files**: 5
- **Tests Run**: 119
- **Tests Passed**: 119
- **Tests Failed**: 0
- **Coverage**: 85.65%
- **Execution Time**: ~12 minutes
---
## Deployment Readiness Checklist
- [x] All original failing tests fixed (30/30)
- [x] Backend coverage >= 85% (85.2%)
- [x] Frontend coverage >= 85% (85.65%)
- [x] Security scans passed (0 HIGH/CRITICAL)
- [x] Pre-commit hooks passed
- [x] Type checks passed (Go + TypeScript)
- [x] No compilation errors
- [x] Code follows project conventions
- [x] Tests are meaningful and maintainable
---
## Recommendations
1. **Merge Ready**: All blocking issues resolved, code is production-ready
2. **Monitor CI**: Verify Docker build passes in CI (tested locally)
3. **Follow-up**: Consider adding more integration tests for DNS provider implementations in a future PR
4. **Documentation**: Update user-facing docs to mention DNS challenge support for wildcards
---
## Conclusion
**FINAL VERDICT**: ✅ PASS
All Definition of Done criteria met:
- ✅ Coverage tests passed (backend 85.2%, frontend 85.65%)
- ✅ Type safety verified
- ✅ Pre-commit hooks passed
- ✅ Security scans clean (0 HIGH/CRITICAL findings)
- ✅ All tests passing
The PR is approved for merge from a quality assurance perspective.
---
**QA Engineer**: Engineering Director (Management Mode)
**Sign-off Date**: January 7, 2026
- Backend coverage profile: `backend/coverage.txt`
- CodeQL results: `codeql-results-go.sarif`, `codeql-results-js.sarif`, `codeql-results-javascript.sarif`
- Trivy results: `trivy-scan-output.txt`, `trivy-image-scan.txt`