test: add SMTP configuration tests and multi-credential DNS provider support
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+73
-219
@@ -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`
|
||||
|
||||
Reference in New Issue
Block a user