fix: resolve CI test failures and close patch coverage gaps

This commit is contained in:
GitHub Actions
2026-04-14 12:42:22 +00:00
parent 149a2071c3
commit 81a083a634
12 changed files with 2560 additions and 10 deletions

View File

@@ -0,0 +1,341 @@
package handlers
import (
"bytes"
"fmt"
"mime/multipart"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"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/internal/models"
"github.com/Wikid82/charon/backend/internal/services"
)
// --- Delete UUID path with backup service ---
func TestDelete_UUID_WithBackup_Success(t *testing.T) {
tmpDir := t.TempDir()
db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}))
certUUID := uuid.New().String()
db.Create(&models.SSLCertificate{UUID: certUUID, Name: "backup-uuid", Provider: "custom", Domains: "backup.test"})
svc := services.NewCertificateService(tmpDir, db, nil)
mock := &mockBackupService{
createFunc: func() (string, error) { return "/tmp/backup.tar.gz", nil },
availableSpaceFunc: func() (int64, error) { return 1024 * 1024 * 1024, nil },
}
h := NewCertificateHandler(svc, mock, nil)
r := gin.New()
r.Use(mockAuthMiddleware())
r.DELETE("/api/certificates/:uuid", h.Delete)
req := httptest.NewRequest(http.MethodDelete, "/api/certificates/"+certUUID, http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
}
func TestDelete_UUID_NotFound(t *testing.T) {
tmpDir := t.TempDir()
db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}))
svc := services.NewCertificateService(tmpDir, db, nil)
h := NewCertificateHandler(svc, nil, nil)
r := gin.New()
r.Use(mockAuthMiddleware())
r.DELETE("/api/certificates/:uuid", h.Delete)
nonExistentUUID := uuid.New().String()
req := httptest.NewRequest(http.MethodDelete, "/api/certificates/"+nonExistentUUID, http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusNotFound, w.Code)
}
func TestDelete_UUID_InUse(t *testing.T) {
tmpDir := t.TempDir()
db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}))
certUUID := uuid.New().String()
cert := models.SSLCertificate{UUID: certUUID, Name: "inuse-uuid", Provider: "custom", Domains: "inuse.test"}
db.Create(&cert)
db.Create(&models.ProxyHost{UUID: "ph-uuid-inuse", Name: "ph", DomainNames: "inuse.test", ForwardHost: "localhost", ForwardPort: 8080, CertificateID: &cert.ID})
svc := services.NewCertificateService(tmpDir, db, nil)
h := NewCertificateHandler(svc, nil, nil)
r := gin.New()
r.Use(mockAuthMiddleware())
r.DELETE("/api/certificates/:uuid", h.Delete)
req := httptest.NewRequest(http.MethodDelete, "/api/certificates/"+certUUID, http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusConflict, w.Code)
}
func TestDelete_UUID_BackupLowSpace(t *testing.T) {
tmpDir := t.TempDir()
db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}))
certUUID := uuid.New().String()
db.Create(&models.SSLCertificate{UUID: certUUID, Name: "low-space", Provider: "custom", Domains: "lowspace.test"})
svc := services.NewCertificateService(tmpDir, db, nil)
mock := &mockBackupService{
availableSpaceFunc: func() (int64, error) { return 1024, nil }, // 1KB - too low
}
h := NewCertificateHandler(svc, mock, nil)
r := gin.New()
r.Use(mockAuthMiddleware())
r.DELETE("/api/certificates/:uuid", h.Delete)
req := httptest.NewRequest(http.MethodDelete, "/api/certificates/"+certUUID, http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusInsufficientStorage, w.Code)
}
func TestDelete_UUID_BackupSpaceCheckError(t *testing.T) {
tmpDir := t.TempDir()
db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}))
certUUID := uuid.New().String()
db.Create(&models.SSLCertificate{UUID: certUUID, Name: "space-err", Provider: "custom", Domains: "spaceerr.test"})
svc := services.NewCertificateService(tmpDir, db, nil)
mock := &mockBackupService{
availableSpaceFunc: func() (int64, error) { return 0, fmt.Errorf("disk error") },
createFunc: func() (string, error) { return "/tmp/backup.tar.gz", nil },
}
h := NewCertificateHandler(svc, mock, nil)
r := gin.New()
r.Use(mockAuthMiddleware())
r.DELETE("/api/certificates/:uuid", h.Delete)
req := httptest.NewRequest(http.MethodDelete, "/api/certificates/"+certUUID, http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
// Space check error → proceeds with backup → succeeds
assert.Equal(t, http.StatusOK, w.Code)
}
func TestDelete_UUID_BackupCreateError(t *testing.T) {
tmpDir := t.TempDir()
db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}))
certUUID := uuid.New().String()
db.Create(&models.SSLCertificate{UUID: certUUID, Name: "backup-fail", Provider: "custom", Domains: "backupfail.test"})
svc := services.NewCertificateService(tmpDir, db, nil)
mock := &mockBackupService{
availableSpaceFunc: func() (int64, error) { return 1024 * 1024 * 1024, nil },
createFunc: func() (string, error) { return "", fmt.Errorf("backup creation failed") },
}
h := NewCertificateHandler(svc, mock, nil)
r := gin.New()
r.Use(mockAuthMiddleware())
r.DELETE("/api/certificates/:uuid", h.Delete)
req := httptest.NewRequest(http.MethodDelete, "/api/certificates/"+certUUID, http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusInternalServerError, w.Code)
}
// --- Delete UUID with notification service ---
func TestDelete_UUID_WithNotification(t *testing.T) {
tmpDir := t.TempDir()
db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}, &models.Setting{}, &models.Notification{}, &models.NotificationProvider{}))
certUUID := uuid.New().String()
db.Create(&models.SSLCertificate{UUID: certUUID, Name: "notify-cert", Provider: "custom", Domains: "notify.test"})
svc := services.NewCertificateService(tmpDir, db, nil)
notifSvc := services.NewNotificationService(db, nil)
h := NewCertificateHandler(svc, nil, notifSvc)
r := gin.New()
r.Use(mockAuthMiddleware())
r.DELETE("/api/certificates/:uuid", h.Delete)
req := httptest.NewRequest(http.MethodDelete, "/api/certificates/"+certUUID, http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
}
// --- Validate handler ---
func TestValidate_Success(t *testing.T) {
tmpDir := t.TempDir()
db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}))
svc := services.NewCertificateService(tmpDir, db, nil)
h := NewCertificateHandler(svc, nil, nil)
certPEM, _, err := generateSelfSignedCertPEM()
require.NoError(t, err)
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, err := writer.CreateFormFile("certificate_file", "cert.pem")
require.NoError(t, err)
_, err = part.Write([]byte(certPEM))
require.NoError(t, err)
require.NoError(t, writer.Close())
r := gin.New()
r.Use(mockAuthMiddleware())
r.POST("/api/certificates/validate", h.Validate)
req := httptest.NewRequest(http.MethodPost, "/api/certificates/validate", body)
req.Header.Set("Content-Type", writer.FormDataContentType())
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
}
func TestValidate_InvalidCert(t *testing.T) {
tmpDir := t.TempDir()
db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}))
svc := services.NewCertificateService(tmpDir, db, nil)
h := NewCertificateHandler(svc, nil, nil)
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, err := writer.CreateFormFile("certificate_file", "cert.pem")
require.NoError(t, err)
_, err = part.Write([]byte("not a certificate"))
require.NoError(t, err)
require.NoError(t, writer.Close())
r := gin.New()
r.Use(mockAuthMiddleware())
r.POST("/api/certificates/validate", h.Validate)
req := httptest.NewRequest(http.MethodPost, "/api/certificates/validate", body)
req.Header.Set("Content-Type", writer.FormDataContentType())
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Body.String(), "unrecognized certificate format")
}
func TestValidate_NoCertFile(t *testing.T) {
tmpDir := t.TempDir()
db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}))
svc := services.NewCertificateService(tmpDir, db, nil)
h := NewCertificateHandler(svc, nil, nil)
r := gin.New()
r.Use(mockAuthMiddleware())
r.POST("/api/certificates/validate", h.Validate)
req := httptest.NewRequest(http.MethodPost, "/api/certificates/validate", http.NoBody)
req.Header.Set("Content-Type", "multipart/form-data; boundary=boundary")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
}
func TestValidate_WithKeyAndChain(t *testing.T) {
tmpDir := t.TempDir()
db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}))
svc := services.NewCertificateService(tmpDir, db, nil)
h := NewCertificateHandler(svc, nil, nil)
certPEM, keyPEM, err := generateSelfSignedCertPEM()
require.NoError(t, err)
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
certPart, err := writer.CreateFormFile("certificate_file", "cert.pem")
require.NoError(t, err)
_, err = certPart.Write([]byte(certPEM))
require.NoError(t, err)
keyPart, err := writer.CreateFormFile("key_file", "key.pem")
require.NoError(t, err)
_, err = keyPart.Write([]byte(keyPEM))
require.NoError(t, err)
chainPart, err := writer.CreateFormFile("chain_file", "chain.pem")
require.NoError(t, err)
_, err = chainPart.Write([]byte(certPEM)) // self-signed chain
require.NoError(t, err)
require.NoError(t, writer.Close())
r := gin.New()
r.Use(mockAuthMiddleware())
r.POST("/api/certificates/validate", h.Validate)
req := httptest.NewRequest(http.MethodPost, "/api/certificates/validate", body)
req.Header.Set("Content-Type", writer.FormDataContentType())
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
}
// --- Get handler DB error (non-NotFound) ---
func TestGet_DBError(t *testing.T) {
db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
require.NoError(t, err)
// Deliberately don't migrate - any query will fail with "no such table"
svc := services.NewCertificateService(t.TempDir(), db, nil)
h := NewCertificateHandler(svc, nil, nil)
r := gin.New()
r.Use(mockAuthMiddleware())
r.GET("/api/certificates/:uuid", h.Get)
req := httptest.NewRequest(http.MethodGet, "/api/certificates/"+uuid.New().String(), http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
// Should be 500 since the table doesn't exist
assert.Equal(t, http.StatusInternalServerError, w.Code)
}

View File

@@ -0,0 +1,382 @@
package handlers
import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"mime/multipart"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"github.com/Wikid82/charon/backend/internal/crypto"
"github.com/Wikid82/charon/backend/internal/models"
"github.com/Wikid82/charon/backend/internal/services"
)
// --- Upload: with chain file (covers chain_file multipart branch) ---
func TestCertificateHandler_Upload_WithChainFile(t *testing.T) {
db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}))
tmpDir := t.TempDir()
svc := services.NewCertificateService(tmpDir, db, nil)
h := NewCertificateHandler(svc, nil, nil)
r := gin.New()
r.Use(mockAuthMiddleware())
r.POST("/api/certificates", h.Upload)
certPEM, keyPEM, err := generateSelfSignedCertPEM()
require.NoError(t, err)
var body bytes.Buffer
writer := multipart.NewWriter(&body)
_ = writer.WriteField("name", "chain-cert")
part, _ := writer.CreateFormFile("certificate_file", "cert.pem")
_, _ = part.Write([]byte(certPEM))
part2, _ := writer.CreateFormFile("key_file", "key.pem")
_, _ = part2.Write([]byte(keyPEM))
part3, _ := writer.CreateFormFile("chain_file", "chain.pem")
_, _ = part3.Write([]byte(certPEM))
_ = writer.Close()
req := httptest.NewRequest(http.MethodPost, "/api/certificates", &body)
req.Header.Set("Content-Type", writer.FormDataContentType())
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusCreated, w.Code, "body: %s", w.Body.String())
}
// --- Upload: invalid cert data ---
func TestCertificateHandler_Upload_InvalidCertData(t *testing.T) {
db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}))
tmpDir := t.TempDir()
svc := services.NewCertificateService(tmpDir, db, nil)
h := NewCertificateHandler(svc, nil, nil)
r := gin.New()
r.Use(mockAuthMiddleware())
r.POST("/api/certificates", h.Upload)
var body bytes.Buffer
writer := multipart.NewWriter(&body)
_ = writer.WriteField("name", "bad-cert")
part, _ := writer.CreateFormFile("certificate_file", "cert.pem")
_, _ = part.Write([]byte("not-a-cert"))
part2, _ := writer.CreateFormFile("key_file", "key.pem")
_, _ = part2.Write([]byte("not-a-key"))
_ = writer.Close()
req := httptest.NewRequest(http.MethodPost, "/api/certificates", &body)
req.Header.Set("Content-Type", writer.FormDataContentType())
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
}
// --- Export re-authentication flow ---
func setupExportRouter(t *testing.T, db *gorm.DB) (*gin.Engine, *CertificateHandler) {
t.Helper()
tmpDir := t.TempDir()
svc := services.NewCertificateService(tmpDir, db, nil)
h := NewCertificateHandler(svc, nil, nil)
h.SetDB(db)
r := gin.New()
return r, h
}
func newTestEncSvc(t *testing.T) *crypto.EncryptionService {
t.Helper()
key := make([]byte, 32)
for i := range key {
key[i] = byte(i)
}
svc, err := crypto.NewEncryptionService(base64.StdEncoding.EncodeToString(key))
require.NoError(t, err)
return svc
}
func TestCertificateHandler_Export_IncludeKeySuccess(t *testing.T) {
db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}, &models.User{}))
user := models.User{UUID: "export-user-1", Email: "export@test.com", Name: "Exporter"}
require.NoError(t, user.SetPassword("correctpassword"))
require.NoError(t, db.Create(&user).Error)
encSvc := newTestEncSvc(t)
tmpDir := t.TempDir()
svc := services.NewCertificateService(tmpDir, db, encSvc)
h := NewCertificateHandler(svc, nil, nil)
h.SetDB(db)
certPEM, keyPEM, err := generateSelfSignedCertPEM()
require.NoError(t, err)
info, err := svc.UploadCertificate("export-cert", certPEM, keyPEM, "")
require.NoError(t, err)
r := gin.New()
r.Use(func(c *gin.Context) {
c.Set("user", map[string]any{"id": user.ID})
c.Next()
})
r.POST("/api/certificates/:uuid/export", h.Export)
payload, _ := json.Marshal(map[string]any{
"format": "pem",
"include_key": true,
"password": "correctpassword",
})
req := httptest.NewRequest(http.MethodPost, "/api/certificates/"+info.UUID+"/export", bytes.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String())
assert.Contains(t, w.Header().Get("Content-Disposition"), "export-cert.pem")
}
func TestCertificateHandler_Export_IncludeKeyWrongPassword(t *testing.T) {
db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}, &models.User{}))
r, h := setupExportRouter(t, db)
user := models.User{UUID: "wrong-pw-user", Email: "wrong@test.com", Name: "Wrong"}
require.NoError(t, user.SetPassword("rightpass"))
require.NoError(t, db.Create(&user).Error)
r.Use(func(c *gin.Context) {
c.Set("user", map[string]any{"id": user.ID})
c.Next()
})
r.POST("/api/certificates/:uuid/export", h.Export)
payload, _ := json.Marshal(map[string]any{
"format": "pem",
"include_key": true,
"password": "wrongpass",
})
req := httptest.NewRequest(http.MethodPost, "/api/certificates/fake-uuid/export", bytes.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusForbidden, w.Code)
assert.Contains(t, w.Body.String(), "incorrect password")
}
func TestCertificateHandler_Export_NoUserInContext(t *testing.T) {
db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}, &models.User{}))
r, h := setupExportRouter(t, db)
r.POST("/api/certificates/:uuid/export", h.Export)
payload, _ := json.Marshal(map[string]any{
"format": "pem",
"include_key": true,
"password": "anything",
})
req := httptest.NewRequest(http.MethodPost, "/api/certificates/fake-uuid/export", bytes.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusForbidden, w.Code)
assert.Contains(t, w.Body.String(), "authentication required")
}
func TestCertificateHandler_Export_InvalidSession(t *testing.T) {
db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}, &models.User{}))
r, h := setupExportRouter(t, db)
r.Use(func(c *gin.Context) {
c.Set("user", "not-a-map")
c.Next()
})
r.POST("/api/certificates/:uuid/export", h.Export)
payload, _ := json.Marshal(map[string]any{
"format": "pem",
"include_key": true,
"password": "anything",
})
req := httptest.NewRequest(http.MethodPost, "/api/certificates/fake-uuid/export", bytes.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusForbidden, w.Code)
assert.Contains(t, w.Body.String(), "invalid session")
}
func TestCertificateHandler_Export_MissingUserID(t *testing.T) {
db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}, &models.User{}))
r, h := setupExportRouter(t, db)
r.Use(func(c *gin.Context) {
c.Set("user", map[string]any{"name": "test"})
c.Next()
})
r.POST("/api/certificates/:uuid/export", h.Export)
payload, _ := json.Marshal(map[string]any{
"format": "pem",
"include_key": true,
"password": "anything",
})
req := httptest.NewRequest(http.MethodPost, "/api/certificates/fake-uuid/export", bytes.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusForbidden, w.Code)
assert.Contains(t, w.Body.String(), "invalid session")
}
func TestCertificateHandler_Export_UserNotFound(t *testing.T) {
db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}, &models.User{}))
r, h := setupExportRouter(t, db)
r.Use(func(c *gin.Context) {
c.Set("user", map[string]any{"id": uint(9999)})
c.Next()
})
r.POST("/api/certificates/:uuid/export", h.Export)
payload, _ := json.Marshal(map[string]any{
"format": "pem",
"include_key": true,
"password": "anything",
})
req := httptest.NewRequest(http.MethodPost, "/api/certificates/fake-uuid/export", bytes.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusForbidden, w.Code)
assert.Contains(t, w.Body.String(), "user not found")
}
// --- Validate handler with key and chain ---
func TestCertificateHandler_Validate_WithKeyAndChain(t *testing.T) {
db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}))
tmpDir := t.TempDir()
svc := services.NewCertificateService(tmpDir, db, nil)
h := NewCertificateHandler(svc, nil, nil)
r := gin.New()
r.Use(mockAuthMiddleware())
r.POST("/api/certificates/validate", h.Validate)
certPEM, keyPEM, err := generateSelfSignedCertPEM()
require.NoError(t, err)
var body bytes.Buffer
writer := multipart.NewWriter(&body)
part, _ := writer.CreateFormFile("certificate_file", "cert.pem")
_, _ = part.Write([]byte(certPEM))
part2, _ := writer.CreateFormFile("key_file", "key.pem")
_, _ = part2.Write([]byte(keyPEM))
part3, _ := writer.CreateFormFile("chain_file", "chain.pem")
_, _ = part3.Write([]byte(certPEM))
_ = writer.Close()
req := httptest.NewRequest(http.MethodPost, "/api/certificates/validate", &body)
req.Header.Set("Content-Type", writer.FormDataContentType())
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String())
}
func TestCertificateHandler_Validate_InvalidCert(t *testing.T) {
db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}))
tmpDir := t.TempDir()
svc := services.NewCertificateService(tmpDir, db, nil)
h := NewCertificateHandler(svc, nil, nil)
r := gin.New()
r.Use(mockAuthMiddleware())
r.POST("/api/certificates/validate", h.Validate)
var body bytes.Buffer
writer := multipart.NewWriter(&body)
part, _ := writer.CreateFormFile("certificate_file", "cert.pem")
_, _ = part.Write([]byte("not-a-cert"))
_ = writer.Close()
req := httptest.NewRequest(http.MethodPost, "/api/certificates/validate", &body)
req.Header.Set("Content-Type", writer.FormDataContentType())
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var resp map[string]any
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
errList, ok := resp["errors"].([]any)
assert.True(t, ok)
assert.Greater(t, len(errList), 0, "expected validation errors in response")
}
func TestCertificateHandler_Validate_MissingCertFile(t *testing.T) {
db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}))
tmpDir := t.TempDir()
svc := services.NewCertificateService(tmpDir, db, nil)
h := NewCertificateHandler(svc, nil, nil)
r := gin.New()
r.Use(mockAuthMiddleware())
r.POST("/api/certificates/validate", h.Validate)
var body bytes.Buffer
writer := multipart.NewWriter(&body)
_ = writer.WriteField("name", "test")
_ = writer.Close()
req := httptest.NewRequest(http.MethodPost, "/api/certificates/validate", &body)
req.Header.Set("Content-Type", writer.FormDataContentType())
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, w.Body.String(), "certificate_file is required")
}

View File

@@ -0,0 +1,166 @@
package caddy
import (
"encoding/base64"
"testing"
"github.com/Wikid82/charon/backend/internal/crypto"
"github.com/Wikid82/charon/backend/internal/models"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func newTestEncSvc(t *testing.T) *crypto.EncryptionService {
t.Helper()
key := make([]byte, 32)
for i := range key {
key[i] = byte(i)
}
svc, err := crypto.NewEncryptionService(base64.StdEncoding.EncodeToString(key))
require.NoError(t, err)
return svc
}
// Test: encrypted key with encryption service → decrypt success → cert loaded
func TestGenerateConfig_CustomCert_EncryptedKey(t *testing.T) {
encSvc := newTestEncSvc(t)
encKey, err := encSvc.Encrypt([]byte("-----BEGIN PRIVATE KEY-----\nfake-key-data\n-----END PRIVATE KEY-----"))
require.NoError(t, err)
certID := uint(10)
hosts := []models.ProxyHost{
{
UUID: "h-enc", DomainNames: "enc.test", ForwardHost: "127.0.0.1", ForwardPort: 8080, Enabled: true,
CertificateID: &certID,
Certificate: &models.SSLCertificate{
ID: certID, UUID: "c-enc", Name: "EncCert", Provider: "custom",
Certificate: "-----BEGIN CERTIFICATE-----\nfake-cert\n-----END CERTIFICATE-----",
PrivateKeyEncrypted: encKey,
},
},
}
cfg, err := GenerateConfig(hosts, "/data", "admin@test.com", "/dist", "letsencrypt", true, false, false, false, false, "", nil, nil, nil, nil, nil, encSvc)
require.NoError(t, err)
require.NotNil(t, cfg)
require.NotNil(t, cfg.Apps.TLS)
require.NotNil(t, cfg.Apps.TLS.Certificates)
assert.NotEmpty(t, cfg.Apps.TLS.Certificates.LoadPEM)
}
// Test: encrypted key with no encryption service → skip
func TestGenerateConfig_CustomCert_EncryptedKeyNoEncSvc(t *testing.T) {
certID := uint(11)
hosts := []models.ProxyHost{
{
UUID: "h-noenc", DomainNames: "noenc.test", ForwardHost: "127.0.0.1", ForwardPort: 8080, Enabled: true,
CertificateID: &certID,
Certificate: &models.SSLCertificate{
ID: certID, UUID: "c-noenc", Name: "NoEncSvcCert", Provider: "custom",
Certificate: "-----BEGIN CERTIFICATE-----\nfake-cert\n-----END CERTIFICATE-----",
PrivateKeyEncrypted: "encrypted-data-here",
},
},
}
cfg, err := GenerateConfig(hosts, "/data", "admin@test.com", "/dist", "letsencrypt", true, false, false, false, false, "", nil, nil, nil, nil, nil)
require.NoError(t, err)
require.NotNil(t, cfg)
// Cert should be skipped - no TLS certs loaded
if cfg.Apps.TLS != nil && cfg.Apps.TLS.Certificates != nil {
assert.Empty(t, cfg.Apps.TLS.Certificates.LoadPEM)
}
}
// Test: no key at all → skip
func TestGenerateConfig_CustomCert_NoKey(t *testing.T) {
certID := uint(12)
hosts := []models.ProxyHost{
{
UUID: "h-nokey", DomainNames: "nokey.test", ForwardHost: "127.0.0.1", ForwardPort: 8080, Enabled: true,
CertificateID: &certID,
Certificate: &models.SSLCertificate{
ID: certID, UUID: "c-nokey", Name: "NoKeyCert", Provider: "custom",
Certificate: "-----BEGIN CERTIFICATE-----\nfake-cert\n-----END CERTIFICATE-----",
},
},
}
cfg, err := GenerateConfig(hosts, "/data", "admin@test.com", "/dist", "letsencrypt", true, false, false, false, false, "", nil, nil, nil, nil, nil)
require.NoError(t, err)
require.NotNil(t, cfg)
if cfg.Apps.TLS != nil && cfg.Apps.TLS.Certificates != nil {
assert.Empty(t, cfg.Apps.TLS.Certificates.LoadPEM)
}
}
// Test: missing cert PEM → skip
func TestGenerateConfig_CustomCert_NoCertPEM(t *testing.T) {
certID := uint(13)
hosts := []models.ProxyHost{
{
UUID: "h-nocert", DomainNames: "nocert.test", ForwardHost: "127.0.0.1", ForwardPort: 8080, Enabled: true,
CertificateID: &certID,
Certificate: &models.SSLCertificate{
ID: certID, UUID: "c-nocert", Name: "NoCertPEM", Provider: "custom",
PrivateKey: "some-key",
},
},
}
cfg, err := GenerateConfig(hosts, "/data", "admin@test.com", "/dist", "letsencrypt", true, false, false, false, false, "", nil, nil, nil, nil, nil)
require.NoError(t, err)
require.NotNil(t, cfg)
if cfg.Apps.TLS != nil && cfg.Apps.TLS.Certificates != nil {
assert.Empty(t, cfg.Apps.TLS.Certificates.LoadPEM)
}
}
// Test: cert with chain → chain concatenated
func TestGenerateConfig_CustomCert_WithChain(t *testing.T) {
certID := uint(14)
hosts := []models.ProxyHost{
{
UUID: "h-chain", DomainNames: "chain.test", ForwardHost: "127.0.0.1", ForwardPort: 8080, Enabled: true,
CertificateID: &certID,
Certificate: &models.SSLCertificate{
ID: certID, UUID: "c-chain", Name: "ChainCert", Provider: "custom",
Certificate: "-----BEGIN CERTIFICATE-----\nleaf-cert\n-----END CERTIFICATE-----",
PrivateKey: "-----BEGIN PRIVATE KEY-----\nkey-data\n-----END PRIVATE KEY-----",
CertificateChain: "-----BEGIN CERTIFICATE-----\nca-cert\n-----END CERTIFICATE-----",
},
},
}
cfg, err := GenerateConfig(hosts, "/data", "admin@test.com", "/dist", "letsencrypt", true, false, false, false, false, "", nil, nil, nil, nil, nil)
require.NoError(t, err)
require.NotNil(t, cfg)
require.NotNil(t, cfg.Apps.TLS)
require.NotNil(t, cfg.Apps.TLS.Certificates)
require.NotEmpty(t, cfg.Apps.TLS.Certificates.LoadPEM)
assert.Contains(t, cfg.Apps.TLS.Certificates.LoadPEM[0].Certificate, "ca-cert")
}
// Test: decrypt failure → skip
func TestGenerateConfig_CustomCert_DecryptFailure(t *testing.T) {
encSvc := newTestEncSvc(t)
certID := uint(15)
hosts := []models.ProxyHost{
{
UUID: "h-decfail", DomainNames: "decfail.test", ForwardHost: "127.0.0.1", ForwardPort: 8080, Enabled: true,
CertificateID: &certID,
Certificate: &models.SSLCertificate{
ID: certID, UUID: "c-decfail", Name: "DecryptFail", Provider: "custom",
Certificate: "-----BEGIN CERTIFICATE-----\nfake-cert\n-----END CERTIFICATE-----",
PrivateKeyEncrypted: "not-valid-encrypted-data",
},
},
}
cfg, err := GenerateConfig(hosts, "/data", "admin@test.com", "/dist", "letsencrypt", true, false, false, false, false, "", nil, nil, nil, nil, nil, encSvc)
require.NoError(t, err)
require.NotNil(t, cfg)
if cfg.Apps.TLS != nil && cfg.Apps.TLS.Certificates != nil {
assert.Empty(t, cfg.Apps.TLS.Certificates.LoadPEM)
}
}

View File

@@ -0,0 +1,38 @@
package services
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"time"
)
func generateSelfSignedCertPEM() (string, string, error) {
priv, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return "", "", err
}
template := x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "test.example.com"},
NotBefore: time.Now(),
NotAfter: time.Now().Add(365 * 24 * time.Hour),
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
}
derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv)
if err != nil {
return "", "", err
}
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: derBytes})
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)})
return string(certPEM), string(keyPEM), nil
}

View File

@@ -0,0 +1,292 @@
package services
import (
"context"
"fmt"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"github.com/Wikid82/charon/backend/internal/models"
)
// --- buildChainEntries ---
func TestBuildChainEntries(t *testing.T) {
certPEM := string(generateTestCert(t, "leaf.example.com", time.Now().Add(24*time.Hour)))
chainPEM := string(generateTestCert(t, "ca.example.com", time.Now().Add(365*24*time.Hour)))
t.Run("leaf only", func(t *testing.T) {
entries := buildChainEntries(certPEM, "")
require.Len(t, entries, 1)
assert.Equal(t, "leaf.example.com", entries[0].Subject)
})
t.Run("leaf and chain", func(t *testing.T) {
entries := buildChainEntries(certPEM, chainPEM)
require.Len(t, entries, 2)
assert.Equal(t, "leaf.example.com", entries[0].Subject)
assert.Equal(t, "ca.example.com", entries[1].Subject)
})
t.Run("empty cert", func(t *testing.T) {
entries := buildChainEntries("", chainPEM)
require.Len(t, entries, 1)
assert.Equal(t, "ca.example.com", entries[0].Subject)
})
t.Run("both empty", func(t *testing.T) {
entries := buildChainEntries("", "")
assert.Empty(t, entries)
})
t.Run("invalid PEM ignored", func(t *testing.T) {
entries := buildChainEntries("not-pem", "also-not-pem")
assert.Empty(t, entries)
})
}
// --- certStatus ---
func TestCertStatus(t *testing.T) {
now := time.Now()
t.Run("valid", func(t *testing.T) {
expiry := now.Add(60 * 24 * time.Hour)
cert := models.SSLCertificate{ExpiresAt: &expiry, Provider: "custom"}
assert.Equal(t, "valid", certStatus(cert))
})
t.Run("expired", func(t *testing.T) {
expiry := now.Add(-time.Hour)
cert := models.SSLCertificate{ExpiresAt: &expiry, Provider: "custom"}
assert.Equal(t, "expired", certStatus(cert))
})
t.Run("expiring soon", func(t *testing.T) {
expiry := now.Add(15 * 24 * time.Hour) // within 30d window
cert := models.SSLCertificate{ExpiresAt: &expiry, Provider: "custom"}
assert.Equal(t, "expiring", certStatus(cert))
})
t.Run("staging provider", func(t *testing.T) {
expiry := now.Add(60 * 24 * time.Hour)
cert := models.SSLCertificate{ExpiresAt: &expiry, Provider: "letsencrypt-staging"}
assert.Equal(t, "untrusted", certStatus(cert))
})
t.Run("nil expiry", func(t *testing.T) {
cert := models.SSLCertificate{Provider: "custom"}
assert.Equal(t, "valid", certStatus(cert))
})
}
// --- ListCertificates cache paths ---
func TestListCertificates_InitializedAndStale(t *testing.T) {
tmpDir := t.TempDir()
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}))
cs := newTestCertificateService(tmpDir, db)
// First call initializes
certs1, err := cs.ListCertificates()
require.NoError(t, err)
assert.Empty(t, certs1)
// Force stale but initialized
cs.cacheMu.Lock()
cs.initialized = true
cs.lastScan = time.Time{} // zero → stale
cs.cacheMu.Unlock()
// Should still return (stale) cache and trigger background sync
certs2, err := cs.ListCertificates()
require.NoError(t, err)
assert.NotNil(t, certs2)
}
func TestListCertificates_CacheFresh(t *testing.T) {
tmpDir := t.TempDir()
dsn := fmt.Sprintf("file:%s_fresh?mode=memory&cache=shared", t.Name())
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}))
cs := newTestCertificateService(tmpDir, db)
cs.cacheMu.Lock()
cs.initialized = true
cs.lastScan = time.Now()
cs.cache = []CertificateInfo{{Name: "cached"}}
cs.scanTTL = 5 * time.Minute
cs.cacheMu.Unlock()
certs, err := cs.ListCertificates()
require.NoError(t, err)
require.Len(t, certs, 1)
assert.Equal(t, "cached", certs[0].Name)
}
// --- ValidateCertificate extra branches ---
func TestValidateCertificate_KeyMismatch(t *testing.T) {
tmpDir := t.TempDir()
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}))
cs := newTestCertificateService(tmpDir, db)
// Generate two separate cert/key pairs so key doesn't match cert
certPEM, _ := generateTestCertAndKey(t, "mismatch.example.com", time.Now().Add(24*time.Hour))
_, keyPEM := generateTestCertAndKey(t, "other.example.com", time.Now().Add(24*time.Hour))
result, err := cs.ValidateCertificate(string(certPEM), string(keyPEM), "")
require.NoError(t, err)
// Key mismatch goes to Errors
found := false
for _, e := range result.Errors {
if strings.Contains(e, "mismatch") {
found = true
}
}
assert.True(t, found, "expected key mismatch error, got errors: %v, warnings: %v", result.Errors, result.Warnings)
}
// --- UploadCertificate with encryption ---
func TestUploadCertificate_WithEncryption(t *testing.T) {
tmpDir := t.TempDir()
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}))
cs := newTestCertServiceWithEnc(t, tmpDir, db)
certPEM, keyPEM := generateTestCertAndKey(t, "enc.example.com", time.Now().Add(24*time.Hour))
info, err := cs.UploadCertificate("encrypted-cert", string(certPEM), string(keyPEM), "")
require.NoError(t, err)
assert.Equal(t, "encrypted-cert", info.Name)
// Verify private key was encrypted in DB
var stored models.SSLCertificate
require.NoError(t, db.Where("uuid = ?", info.UUID).First(&stored).Error)
assert.NotEmpty(t, stored.PrivateKeyEncrypted)
assert.Empty(t, stored.PrivateKey) // should not store plaintext
}
// --- checkExpiry additional branches ---
func TestCheckExpiry_NoNotificationService(t *testing.T) {
tmpDir := t.TempDir()
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}, &models.Setting{}, &models.NotificationProvider{}))
cs := &CertificateService{
dataDir: tmpDir,
db: db,
scanTTL: 5 * time.Minute,
}
// No notification service set — should not panic
cs.checkExpiry(context.Background(), nil, 30)
}
// --- DeleteCertificate with backup service ---
func TestDeleteCertificate_Success(t *testing.T) {
tmpDir := t.TempDir()
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}))
cs := newTestCertificateService(tmpDir, db)
certPEM, keyPEM := generateTestCertAndKey(t, "delete.example.com", time.Now().Add(24*time.Hour))
info, err := cs.UploadCertificate("to-delete", string(certPEM), string(keyPEM), "")
require.NoError(t, err)
err = cs.DeleteCertificate(info.UUID)
assert.NoError(t, err)
// Verify deleted
_, err = cs.GetCertificate(info.UUID)
assert.ErrorIs(t, err, ErrCertNotFound)
}
func TestDeleteCertificate_InUse(t *testing.T) {
tmpDir := t.TempDir()
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}))
cs := newTestCertificateService(tmpDir, db)
certPEM, keyPEM := generateTestCertAndKey(t, "inuse.example.com", time.Now().Add(24*time.Hour))
info, err := cs.UploadCertificate("in-use-cert", string(certPEM), string(keyPEM), "")
require.NoError(t, err)
// Find the cert and assign to a host
var stored models.SSLCertificate
require.NoError(t, db.Where("uuid = ?", info.UUID).First(&stored).Error)
ph := models.ProxyHost{
UUID: "ph-inuse",
Name: "InUse Host",
DomainNames: "inuse.example.com",
ForwardHost: "localhost",
ForwardPort: 8080,
CertificateID: &stored.ID,
}
require.NoError(t, db.Create(&ph).Error)
err = cs.DeleteCertificate(info.UUID)
assert.Error(t, err)
assert.Contains(t, err.Error(), "in use")
}
// --- IsCertificateInUse ---
func TestIsCertificateInUse(t *testing.T) {
tmpDir := t.TempDir()
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}))
cs := newTestCertificateService(tmpDir, db)
cert := models.SSLCertificate{
UUID: "inuse-test", Name: "In Use Test", Provider: "custom",
Domains: "test.example.com", CommonName: "test.example.com",
}
require.NoError(t, db.Create(&cert).Error)
t.Run("not in use", func(t *testing.T) {
inUse, err := cs.IsCertificateInUse(cert.ID)
require.NoError(t, err)
assert.False(t, inUse)
})
t.Run("in use", func(t *testing.T) {
ph := models.ProxyHost{
UUID: "ph-check", Name: "Check Host", DomainNames: "test.example.com",
ForwardHost: "localhost", ForwardPort: 8080, CertificateID: &cert.ID,
}
require.NoError(t, db.Create(&ph).Error)
inUse, err := cs.IsCertificateInUse(cert.ID)
require.NoError(t, err)
assert.True(t, inUse)
})
}

View File

@@ -0,0 +1,596 @@
package services
import (
"context"
"fmt"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"github.com/Wikid82/charon/backend/internal/models"
)
// --- ExportCertificate DER format ---
func TestExportCertificate_DER(t *testing.T) {
tmpDir := t.TempDir()
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}))
cs := newTestCertificateService(tmpDir, db)
certPEM, keyPEM := generateTestCertAndKey(t, "der-export.example.com", time.Now().Add(24*time.Hour))
info, err := cs.UploadCertificate("der-export", string(certPEM), string(keyPEM), "")
require.NoError(t, err)
data, filename, err := cs.ExportCertificate(info.UUID, "der", false, "")
require.NoError(t, err)
assert.NotEmpty(t, data)
assert.Contains(t, filename, ".der")
}
// --- ExportCertificate PFX format ---
func TestExportCertificate_PFX(t *testing.T) {
tmpDir := t.TempDir()
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}))
cs := newTestCertServiceWithEnc(t, tmpDir, db)
certPEM, keyPEM := generateTestCertAndKey(t, "pfx-export.example.com", time.Now().Add(24*time.Hour))
info, err := cs.UploadCertificate("pfx-export", string(certPEM), string(keyPEM), "")
require.NoError(t, err)
data, filename, err := cs.ExportCertificate(info.UUID, "pfx", true, "test-password")
require.NoError(t, err)
assert.NotEmpty(t, data)
assert.Contains(t, filename, ".pfx")
}
func TestExportCertificate_P12(t *testing.T) {
tmpDir := t.TempDir()
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}))
cs := newTestCertServiceWithEnc(t, tmpDir, db)
certPEM, keyPEM := generateTestCertAndKey(t, "p12-export.example.com", time.Now().Add(24*time.Hour))
info, err := cs.UploadCertificate("p12-export", string(certPEM), string(keyPEM), "")
require.NoError(t, err)
data, filename, err := cs.ExportCertificate(info.UUID, "p12", true, "password")
require.NoError(t, err)
assert.NotEmpty(t, data)
assert.Contains(t, filename, ".pfx")
}
func TestExportCertificate_UnsupportedFormat(t *testing.T) {
tmpDir := t.TempDir()
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}))
cs := newTestCertificateService(tmpDir, db)
certPEM, keyPEM := generateTestCertAndKey(t, "unsupported.example.com", time.Now().Add(24*time.Hour))
info, err := cs.UploadCertificate("unsupported-fmt", string(certPEM), string(keyPEM), "")
require.NoError(t, err)
_, _, err = cs.ExportCertificate(info.UUID, "xml", false, "")
assert.Error(t, err)
assert.Contains(t, err.Error(), "unsupported export format")
}
func TestExportCertificate_PEMWithKey(t *testing.T) {
tmpDir := t.TempDir()
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}))
cs := newTestCertServiceWithEnc(t, tmpDir, db)
certPEM, keyPEM := generateTestCertAndKey(t, "pem-key.example.com", time.Now().Add(24*time.Hour))
info, err := cs.UploadCertificate("pem-key-export", string(certPEM), string(keyPEM), "")
require.NoError(t, err)
data, filename, err := cs.ExportCertificate(info.UUID, "pem", true, "")
require.NoError(t, err)
assert.Contains(t, string(data), "PRIVATE KEY")
assert.Contains(t, filename, ".pem")
}
func TestExportCertificate_NotFound(t *testing.T) {
tmpDir := t.TempDir()
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}))
cs := newTestCertificateService(tmpDir, db)
_, _, err = cs.ExportCertificate("nonexistent-uuid", "pem", false, "")
assert.ErrorIs(t, err, ErrCertNotFound)
}
// --- GetDecryptedPrivateKey ---
func TestGetDecryptedPrivateKey_NoEncryptedKey(t *testing.T) {
tmpDir := t.TempDir()
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}))
cs := newTestCertificateService(tmpDir, db)
cert := &models.SSLCertificate{PrivateKeyEncrypted: ""}
_, err = cs.GetDecryptedPrivateKey(cert)
assert.Error(t, err)
assert.Contains(t, err.Error(), "no encrypted private key")
}
func TestGetDecryptedPrivateKey_NoEncryptionService(t *testing.T) {
tmpDir := t.TempDir()
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}))
cs := newTestCertificateService(tmpDir, db) // no encSvc
cert := &models.SSLCertificate{PrivateKeyEncrypted: "some-encrypted-data"}
_, err = cs.GetDecryptedPrivateKey(cert)
assert.Error(t, err)
assert.Contains(t, err.Error(), "encryption service not configured")
}
// --- MigratePrivateKeys ---
func TestMigratePrivateKeys_NoEncryptionService(t *testing.T) {
tmpDir := t.TempDir()
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}))
cs := newTestCertificateService(tmpDir, db)
err = cs.MigratePrivateKeys()
assert.NoError(t, err) // should return nil without error
}
func TestMigratePrivateKeys_NoCertsToMigrate(t *testing.T) {
tmpDir := t.TempDir()
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}))
// MigratePrivateKeys uses raw SQL against private_key column (gorm:"-"), so add it manually
db.Exec("ALTER TABLE ssl_certificates ADD COLUMN private_key TEXT DEFAULT ''")
cs := newTestCertServiceWithEnc(t, tmpDir, db)
err = cs.MigratePrivateKeys()
assert.NoError(t, err)
}
func TestMigratePrivateKeys_WithPlaintextKey(t *testing.T) {
tmpDir := t.TempDir()
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}))
// MigratePrivateKeys uses raw SQL against private_key column (gorm:"-"), so add it manually
db.Exec("ALTER TABLE ssl_certificates ADD COLUMN private_key TEXT DEFAULT ''")
cs := newTestCertServiceWithEnc(t, tmpDir, db)
_, keyPEM := generateTestCertAndKey(t, "migrate.example.com", time.Now().Add(24*time.Hour))
// Insert a cert with plaintext private_key via raw SQL
db.Exec("INSERT INTO ssl_certificates (uuid, name, provider, domains, common_name, private_key) VALUES (?, ?, ?, ?, ?, ?)",
"migrate-uuid", "Migrate Test", "custom", "migrate.example.com", "migrate.example.com", string(keyPEM))
err = cs.MigratePrivateKeys()
assert.NoError(t, err)
// Verify the key was encrypted
var encKey string
db.Raw("SELECT private_key_enc FROM ssl_certificates WHERE uuid = ?", "migrate-uuid").Scan(&encKey)
assert.NotEmpty(t, encKey)
// Verify plaintext key was cleared
var plainKey string
db.Raw("SELECT private_key FROM ssl_certificates WHERE uuid = ?", "migrate-uuid").Scan(&plainKey)
assert.Empty(t, plainKey)
}
// --- DeleteCertificateByID ---
func TestDeleteCertificateByID_Success(t *testing.T) {
tmpDir := t.TempDir()
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}))
cs := newTestCertificateService(tmpDir, db)
certPEM, keyPEM := generateTestCertAndKey(t, "byid.example.com", time.Now().Add(24*time.Hour))
info, err := cs.UploadCertificate("by-id-delete", string(certPEM), string(keyPEM), "")
require.NoError(t, err)
var stored models.SSLCertificate
require.NoError(t, db.Where("uuid = ?", info.UUID).First(&stored).Error)
err = cs.DeleteCertificateByID(stored.ID)
assert.NoError(t, err)
}
func TestDeleteCertificateByID_NotFound(t *testing.T) {
tmpDir := t.TempDir()
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}))
cs := newTestCertificateService(tmpDir, db)
err = cs.DeleteCertificateByID(99999)
assert.Error(t, err)
}
// --- UpdateCertificate ---
func TestUpdateCertificate_Success(t *testing.T) {
tmpDir := t.TempDir()
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}))
cs := newTestCertificateService(tmpDir, db)
certPEM, keyPEM := generateTestCertAndKey(t, "update.example.com", time.Now().Add(24*time.Hour))
info, err := cs.UploadCertificate("old-name", string(certPEM), string(keyPEM), "")
require.NoError(t, err)
updated, err := cs.UpdateCertificate(info.UUID, "new-name")
require.NoError(t, err)
assert.Equal(t, "new-name", updated.Name)
}
func TestUpdateCertificate_NotFound(t *testing.T) {
tmpDir := t.TempDir()
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}))
cs := newTestCertificateService(tmpDir, db)
_, err = cs.UpdateCertificate("nonexistent", "name")
assert.ErrorIs(t, err, ErrCertNotFound)
}
// --- IsCertificateInUseByUUID ---
func TestIsCertificateInUseByUUID_NotFound(t *testing.T) {
tmpDir := t.TempDir()
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}))
cs := newTestCertificateService(tmpDir, db)
_, err = cs.IsCertificateInUseByUUID("nonexistent-uuid")
assert.ErrorIs(t, err, ErrCertNotFound)
}
func TestIsCertificateInUseByUUID_NotInUse(t *testing.T) {
tmpDir := t.TempDir()
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}))
cs := newTestCertificateService(tmpDir, db)
certPEM, keyPEM := generateTestCertAndKey(t, "inuse-uuid.example.com", time.Now().Add(24*time.Hour))
info, err := cs.UploadCertificate("uuid-inuse-test", string(certPEM), string(keyPEM), "")
require.NoError(t, err)
inUse, err := cs.IsCertificateInUseByUUID(info.UUID)
require.NoError(t, err)
assert.False(t, inUse)
}
// --- CheckExpiringCertificates ---
func TestCheckExpiringCertificates_WithExpiring(t *testing.T) {
tmpDir := t.TempDir()
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}))
cs := newTestCertificateService(tmpDir, db)
// Create a cert expiring in 10 days
expiry := time.Now().Add(10 * 24 * time.Hour)
notBefore := time.Now().Add(-24 * time.Hour)
db.Create(&models.SSLCertificate{
UUID: "expiring-uuid", Name: "Expiring Cert", Provider: "custom",
Domains: "expiring.example.com", CommonName: "expiring.example.com",
ExpiresAt: &expiry, NotBefore: &notBefore,
})
certs, err := cs.CheckExpiringCertificates(30)
require.NoError(t, err)
assert.Len(t, certs, 1)
assert.Equal(t, "Expiring Cert", certs[0].Name)
assert.Equal(t, "expiring", certs[0].Status)
}
func TestCheckExpiringCertificates_WithExpired(t *testing.T) {
tmpDir := t.TempDir()
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}))
cs := newTestCertificateService(tmpDir, db)
expiry := time.Now().Add(-24 * time.Hour)
db.Create(&models.SSLCertificate{
UUID: "expired-uuid", Name: "Expired Cert", Provider: "custom",
Domains: "expired.example.com", CommonName: "expired.example.com",
ExpiresAt: &expiry,
})
certs, err := cs.CheckExpiringCertificates(30)
require.NoError(t, err)
assert.Len(t, certs, 1)
assert.Equal(t, "expired", certs[0].Status)
}
func TestCheckExpiringCertificates_NoneExpiring(t *testing.T) {
tmpDir := t.TempDir()
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}))
cs := newTestCertificateService(tmpDir, db)
// Cert expiring in 90 days - outside 30 day window
expiry := time.Now().Add(90 * 24 * time.Hour)
db.Create(&models.SSLCertificate{
UUID: "valid-uuid", Name: "Valid Cert", Provider: "custom",
Domains: "valid.example.com", CommonName: "valid.example.com",
ExpiresAt: &expiry,
})
certs, err := cs.CheckExpiringCertificates(30)
require.NoError(t, err)
assert.Empty(t, certs)
}
// --- checkExpiry with notification service ---
func TestCheckExpiry_WithExpiringCerts(t *testing.T) {
tmpDir := t.TempDir()
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(
&models.SSLCertificate{}, &models.ProxyHost{},
&models.Setting{}, &models.NotificationProvider{},
&models.Notification{},
))
cs := newTestCertificateService(tmpDir, db)
// Create expiring cert
expiry := time.Now().Add(10 * 24 * time.Hour)
db.Create(&models.SSLCertificate{
UUID: "notify-expiring", Name: "Notify Cert", Provider: "custom",
Domains: "notify.example.com", CommonName: "notify.example.com",
ExpiresAt: &expiry,
})
notifSvc := NewNotificationService(db, nil)
cs.checkExpiry(context.Background(), notifSvc, 30)
// Verify a notification was created
var count int64
db.Model(&models.Notification{}).Count(&count)
assert.Greater(t, count, int64(0))
}
func TestCheckExpiry_WithExpiredCerts(t *testing.T) {
tmpDir := t.TempDir()
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(
&models.SSLCertificate{}, &models.ProxyHost{},
&models.Setting{}, &models.NotificationProvider{},
&models.Notification{},
))
cs := newTestCertificateService(tmpDir, db)
expiry := time.Now().Add(-24 * time.Hour)
db.Create(&models.SSLCertificate{
UUID: "notify-expired", Name: "Expired Notify", Provider: "custom",
Domains: "expired-notify.example.com", CommonName: "expired-notify.example.com",
ExpiresAt: &expiry,
})
notifSvc := NewNotificationService(db, nil)
cs.checkExpiry(context.Background(), notifSvc, 30)
var count int64
db.Model(&models.Notification{}).Count(&count)
assert.Greater(t, count, int64(0))
}
// --- ListCertificates with chain and proxy host ---
func TestListCertificates_WithChainAndProxyHost(t *testing.T) {
tmpDir := t.TempDir()
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}))
cs := newTestCertificateService(tmpDir, db)
certPEM, _, err := generateSelfSignedCertPEM()
require.NoError(t, err)
chainPEM := certPEM + "\n" + certPEM
expiry := time.Now().Add(90 * 24 * time.Hour)
notBefore := time.Now().Add(-1 * time.Hour)
certID := uint(99)
db.Create(&models.SSLCertificate{
ID: certID,
UUID: "chain-test-uuid",
Name: "Chain Test",
Provider: "custom",
Domains: "chain.example.com",
CommonName: "chain.example.com",
Certificate: certPEM,
CertificateChain: chainPEM,
ExpiresAt: &expiry,
NotBefore: &notBefore,
})
db.Create(&models.ProxyHost{
Name: "My Proxy",
DomainNames: "chain.example.com",
CertificateID: &certID,
})
certs, err := cs.ListCertificates()
require.NoError(t, err)
require.Len(t, certs, 1)
assert.Equal(t, 2, certs[0].ChainDepth)
assert.True(t, certs[0].InUse)
assert.Equal(t, "chain-test-uuid", certs[0].UUID)
}
// --- UploadCertificate with key ---
func TestUploadCertificate_WithKey(t *testing.T) {
tmpDir := t.TempDir()
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}))
cs := newTestCertServiceWithEnc(t, tmpDir, db)
certPEM, keyPEM, err := generateSelfSignedCertPEM()
require.NoError(t, err)
info, err := cs.UploadCertificate("My Upload", certPEM, keyPEM, "")
require.NoError(t, err)
require.NotNil(t, info)
assert.Equal(t, "My Upload", info.Name)
assert.True(t, info.HasKey)
assert.NotEmpty(t, info.UUID)
assert.Equal(t, "custom", info.Provider)
}
// --- ValidateCertificate with key match ---
func TestValidateCertificate_WithKeyMatch(t *testing.T) {
tmpDir := t.TempDir()
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}))
cs := newTestCertificateService(tmpDir, db)
certPEM, keyPEM, err := generateSelfSignedCertPEM()
require.NoError(t, err)
result, err := cs.ValidateCertificate(certPEM, keyPEM, "")
require.NoError(t, err)
assert.True(t, result.Valid)
assert.True(t, result.KeyMatch)
assert.Empty(t, result.Errors)
assert.Contains(t, result.Warnings, "certificate could not be verified against system roots")
}
// --- UpdateCertificate with chain depth ---
func TestUpdateCertificate_WithChainDepth(t *testing.T) {
tmpDir := t.TempDir()
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}))
cs := newTestCertificateService(tmpDir, db)
certPEM, _, err := generateSelfSignedCertPEM()
require.NoError(t, err)
chainPEM := certPEM + "\n" + certPEM + "\n" + certPEM
expiry := time.Now().Add(90 * 24 * time.Hour)
db.Create(&models.SSLCertificate{
UUID: "update-chain-uuid",
Name: "Chain Update",
Provider: "custom",
Domains: "update-chain.example.com",
CommonName: "update-chain.example.com",
Certificate: certPEM,
CertificateChain: chainPEM,
ExpiresAt: &expiry,
})
info, err := cs.UpdateCertificate("update-chain-uuid", "Renamed Chain")
require.NoError(t, err)
assert.Equal(t, "Renamed Chain", info.Name)
assert.Equal(t, 3, info.ChainDepth)
}
// --- ExportCertificate PEM with chain ---
func TestExportCertificate_PEMWithChain(t *testing.T) {
tmpDir := t.TempDir()
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}))
cs := newTestCertServiceWithEnc(t, tmpDir, db)
certPEM, keyPEM, err := generateSelfSignedCertPEM()
require.NoError(t, err)
encSvc := newTestEncryptionService(t)
encKey, err := encSvc.Encrypt([]byte(keyPEM))
require.NoError(t, err)
chainPEM := certPEM
db.Create(&models.SSLCertificate{
UUID: "export-chain-uuid",
Name: "Export Chain",
Provider: "custom",
Domains: "export-chain.example.com",
CommonName: "export-chain.example.com",
Certificate: certPEM,
CertificateChain: chainPEM,
PrivateKeyEncrypted: encKey,
})
data, filename, err := cs.ExportCertificate("export-chain-uuid", "pem", true, "")
require.NoError(t, err)
assert.Equal(t, "Export Chain.pem", filename)
assert.Contains(t, string(data), "BEGIN CERTIFICATE")
assert.Contains(t, string(data), "BEGIN")
}

View File

@@ -0,0 +1,324 @@
package services
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"software.sslmate.com/src/go-pkcs12"
)
// --- parsePFXInput ---
func TestParsePFXInput(t *testing.T) {
cert, priv, _, _ := makeRSACertAndKey(t, "pfx.test", time.Now().Add(time.Hour))
pfxData, err := pkcs12.Modern.Encode(priv, cert, nil, pkcs12.DefaultPassword)
require.NoError(t, err)
t.Run("valid PFX", func(t *testing.T) {
parsed, err := parsePFXInput(pfxData, pkcs12.DefaultPassword)
require.NoError(t, err)
assert.NotNil(t, parsed.Leaf)
assert.NotNil(t, parsed.PrivateKey)
assert.Equal(t, FormatPFX, parsed.Format)
assert.Contains(t, parsed.CertPEM, "BEGIN CERTIFICATE")
assert.Contains(t, parsed.KeyPEM, "PRIVATE KEY")
})
t.Run("PFX with chain", func(t *testing.T) {
caKey, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)
caTmpl := &x509.Certificate{
SerialNumber: big.NewInt(100),
Subject: pkix.Name{CommonName: "Test CA"},
NotBefore: time.Now(),
NotAfter: time.Now().Add(24 * time.Hour),
IsCA: true,
BasicConstraintsValid: true,
KeyUsage: x509.KeyUsageCertSign,
}
caDER, err := x509.CreateCertificate(rand.Reader, caTmpl, caTmpl, &caKey.PublicKey, caKey)
require.NoError(t, err)
caCert, err := x509.ParseCertificate(caDER)
require.NoError(t, err)
pfxWithChain, err := pkcs12.Modern.Encode(priv, cert, []*x509.Certificate{caCert}, pkcs12.DefaultPassword)
require.NoError(t, err)
parsed, err := parsePFXInput(pfxWithChain, pkcs12.DefaultPassword)
require.NoError(t, err)
assert.NotEmpty(t, parsed.ChainPEM)
assert.Contains(t, parsed.ChainPEM, "BEGIN CERTIFICATE")
})
t.Run("invalid PFX data", func(t *testing.T) {
_, err := parsePFXInput([]byte("not-pfx"), "password")
assert.Error(t, err)
assert.Contains(t, err.Error(), "PFX")
})
t.Run("wrong password", func(t *testing.T) {
_, err := parsePFXInput(pfxData, "wrong-password")
assert.Error(t, err)
})
}
// --- parseDERInput ---
func TestParseDERInput(t *testing.T) {
cert, priv, _, keyPEM := makeRSACertAndKey(t, "der.test", time.Now().Add(time.Hour))
t.Run("DER cert only", func(t *testing.T) {
parsed, err := parseDERInput(cert.Raw, nil)
require.NoError(t, err)
assert.NotNil(t, parsed.Leaf)
assert.Equal(t, FormatDER, parsed.Format)
assert.Contains(t, parsed.CertPEM, "BEGIN CERTIFICATE")
assert.Nil(t, parsed.PrivateKey)
})
t.Run("DER cert with PEM key", func(t *testing.T) {
parsed, err := parseDERInput(cert.Raw, keyPEM)
require.NoError(t, err)
assert.NotNil(t, parsed.PrivateKey)
assert.Contains(t, parsed.KeyPEM, "PRIVATE KEY")
})
t.Run("DER cert with DER PKCS8 key", func(t *testing.T) {
derKey, err := x509.MarshalPKCS8PrivateKey(priv)
require.NoError(t, err)
parsed, err := parseDERInput(cert.Raw, derKey)
require.NoError(t, err)
assert.NotNil(t, parsed.PrivateKey)
})
t.Run("DER cert with DER EC key", func(t *testing.T) {
ecCert, ecPriv, _, _ := makeECDSACertAndKey(t, "ec-der.test")
ecDERKey, err := x509.MarshalECPrivateKey(ecPriv)
require.NoError(t, err)
parsed, err := parseDERInput(ecCert.Raw, ecDERKey)
require.NoError(t, err)
assert.NotNil(t, parsed.PrivateKey)
})
t.Run("DER cert with invalid key", func(t *testing.T) {
_, err := parseDERInput(cert.Raw, []byte("bad-key-data"))
assert.Error(t, err)
assert.Contains(t, err.Error(), "private key")
})
t.Run("invalid DER cert data", func(t *testing.T) {
_, err := parseDERInput([]byte("not-der"), nil)
assert.Error(t, err)
assert.Contains(t, err.Error(), "DER certificate")
})
}
// --- parsePEMInput chain building ---
func TestParsePEMInput_ChainBuilding(t *testing.T) {
t.Run("cert with intermediates in cert data", func(t *testing.T) {
_, _, certPEM1, _ := makeRSACertAndKey(t, "leaf.test", time.Now().Add(time.Hour))
_, _, certPEM2, _ := makeRSACertAndKey(t, "intermediate.test", time.Now().Add(time.Hour))
combined := append(certPEM1, certPEM2...)
parsed, err := parsePEMInput(combined, nil, nil)
require.NoError(t, err)
assert.NotNil(t, parsed.Leaf)
assert.Len(t, parsed.Intermediates, 1)
assert.NotEmpty(t, parsed.ChainPEM)
assert.Contains(t, parsed.ChainPEM, "BEGIN CERTIFICATE")
})
t.Run("cert with chain file", func(t *testing.T) {
_, _, certPEM, keyPEM := makeRSACertAndKey(t, "leaf.test", time.Now().Add(time.Hour))
_, _, chainPEM, _ := makeRSACertAndKey(t, "chain.test", time.Now().Add(time.Hour))
parsed, err := parsePEMInput(certPEM, keyPEM, chainPEM)
require.NoError(t, err)
assert.NotNil(t, parsed.PrivateKey)
assert.Len(t, parsed.Intermediates, 1)
assert.Equal(t, string(chainPEM), parsed.ChainPEM)
})
t.Run("invalid chain data ignored", func(t *testing.T) {
_, _, certPEM, _ := makeRSACertAndKey(t, "leaf.test", time.Now().Add(time.Hour))
parsed, err := parsePEMInput(certPEM, nil, []byte("not-pem"))
require.NoError(t, err)
assert.Empty(t, parsed.Intermediates, "invalid PEM chain should be silently ignored")
})
t.Run("invalid cert data", func(t *testing.T) {
_, err := parsePEMInput([]byte("not-pem"), nil, nil)
assert.Error(t, err)
})
t.Run("empty PEM block", func(t *testing.T) {
emptyPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: []byte("garbage")})
_, err := parsePEMInput(emptyPEM, nil, nil)
assert.Error(t, err)
})
}
// --- ConvertPFXToPEM ---
func TestConvertPFXToPEM(t *testing.T) {
cert, priv, _, _ := makeRSACertAndKey(t, "pfx-convert.test", time.Now().Add(time.Hour))
pfxData, err := pkcs12.Modern.Encode(priv, cert, nil, pkcs12.DefaultPassword)
require.NoError(t, err)
t.Run("valid PFX", func(t *testing.T) {
certPEM, keyPEM, chainPEM, err := ConvertPFXToPEM(pfxData, pkcs12.DefaultPassword)
require.NoError(t, err)
assert.Contains(t, certPEM, "BEGIN CERTIFICATE")
assert.Contains(t, keyPEM, "PRIVATE KEY")
assert.Empty(t, chainPEM)
})
t.Run("PFX with chain", func(t *testing.T) {
caKey, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)
caTmpl := &x509.Certificate{
SerialNumber: big.NewInt(200),
Subject: pkix.Name{CommonName: "PFX Test CA"},
NotBefore: time.Now(),
NotAfter: time.Now().Add(24 * time.Hour),
IsCA: true,
BasicConstraintsValid: true,
KeyUsage: x509.KeyUsageCertSign,
}
caDER, err := x509.CreateCertificate(rand.Reader, caTmpl, caTmpl, &caKey.PublicKey, caKey)
require.NoError(t, err)
caCert, err := x509.ParseCertificate(caDER)
require.NoError(t, err)
pfxWithChain, err := pkcs12.Modern.Encode(priv, cert, []*x509.Certificate{caCert}, pkcs12.DefaultPassword)
require.NoError(t, err)
certPEM, keyPEM, chainPEM, err := ConvertPFXToPEM(pfxWithChain, pkcs12.DefaultPassword)
require.NoError(t, err)
assert.Contains(t, certPEM, "BEGIN CERTIFICATE")
assert.Contains(t, keyPEM, "PRIVATE KEY")
assert.Contains(t, chainPEM, "BEGIN CERTIFICATE")
})
t.Run("invalid PFX", func(t *testing.T) {
_, _, _, err := ConvertPFXToPEM([]byte("bad"), "password")
assert.Error(t, err)
assert.Contains(t, err.Error(), "PFX")
})
}
// --- encodeKeyToPEM ---
func TestEncodeKeyToPEM(t *testing.T) {
t.Run("RSA key", func(t *testing.T) {
priv, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)
pemStr, err := encodeKeyToPEM(priv)
require.NoError(t, err)
assert.Contains(t, pemStr, "PRIVATE KEY")
})
t.Run("ECDSA key", func(t *testing.T) {
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
pemStr, err := encodeKeyToPEM(priv)
require.NoError(t, err)
assert.Contains(t, pemStr, "PRIVATE KEY")
})
}
// --- ParseCertificateInput for PFX ---
func TestParseCertificateInput_PFX(t *testing.T) {
cert, priv, _, _ := makeRSACertAndKey(t, "pfx-parse.test", time.Now().Add(time.Hour))
pfxData, err := pkcs12.Modern.Encode(priv, cert, nil, pkcs12.DefaultPassword)
require.NoError(t, err)
t.Run("PFX format detected and parsed", func(t *testing.T) {
parsed, err := ParseCertificateInput(pfxData, nil, nil, pkcs12.DefaultPassword)
require.NoError(t, err)
assert.NotNil(t, parsed.Leaf)
assert.NotNil(t, parsed.PrivateKey)
assert.Equal(t, FormatPFX, parsed.Format)
})
}
// --- detectKeyType additional branches ---
func TestDetectKeyType_P384(t *testing.T) {
priv, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
require.NoError(t, err)
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(99),
Subject: pkix.Name{CommonName: "p384.test"},
NotBefore: time.Now(),
NotAfter: time.Now().Add(time.Hour),
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &priv.PublicKey, priv)
require.NoError(t, err)
cert, err := x509.ParseCertificate(der)
require.NoError(t, err)
assert.Equal(t, "ECDSA-P384", detectKeyType(cert))
}
// --- parsePEMPrivateKey additional formats ---
func TestParsePEMPrivateKey_PKCS1(t *testing.T) {
priv, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)})
key, err := parsePEMPrivateKey(keyPEM)
require.NoError(t, err)
assert.NotNil(t, key)
}
func TestParsePEMPrivateKey_EC(t *testing.T) {
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
ecDER, err := x509.MarshalECPrivateKey(priv)
require.NoError(t, err)
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: ecDER})
key, err := parsePEMPrivateKey(keyPEM)
require.NoError(t, err)
assert.NotNil(t, key)
}
func TestParsePEMPrivateKey_Invalid(t *testing.T) {
t.Run("no PEM data", func(t *testing.T) {
_, err := parsePEMPrivateKey([]byte("not pem"))
assert.Error(t, err)
assert.Contains(t, err.Error(), "no PEM data")
})
t.Run("unsupported key format", func(t *testing.T) {
badPEM := pem.EncodeToMemory(&pem.Block{Type: "UNKNOWN KEY", Bytes: []byte("junk")})
_, err := parsePEMPrivateKey(badPEM)
assert.Error(t, err)
assert.Contains(t, err.Error(), "unsupported")
})
}
// --- DetectFormat for PFX ---
func TestDetectFormat_PFX(t *testing.T) {
cert, priv, _, _ := makeRSACertAndKey(t, "detect-pfx.test", time.Now().Add(time.Hour))
pfxData, err := pkcs12.Modern.Encode(priv, cert, nil, pkcs12.DefaultPassword)
require.NoError(t, err)
format := DetectFormat(pfxData)
assert.Equal(t, FormatPFX, format, "PFX data should be detected as FormatPFX")
}

View File

@@ -0,0 +1,256 @@
package services
import (
"crypto/ecdsa"
"crypto/ed25519"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// --- ValidateKeyMatch ECDSA ---
func TestValidateKeyMatch_ECDSA_Success(t *testing.T) {
cert, _, _, _ := makeECDSACertAndKey(t, "ecdsa-match.test")
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
// Use the actual key that signed the cert
ecCert, ecKey, _, _ := makeECDSACertAndKey(t, "ecdsa-ok.test")
err = ValidateKeyMatch(ecCert, ecKey)
assert.NoError(t, err)
// Mismatch: different ECDSA key
err = ValidateKeyMatch(cert, priv)
assert.Error(t, err)
assert.Contains(t, err.Error(), "ECDSA key mismatch")
}
func TestValidateKeyMatch_ECDSA_WrongKeyType(t *testing.T) {
cert, _, _, _ := makeECDSACertAndKey(t, "ecdsa-wrong.test")
rsaKey, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)
err = ValidateKeyMatch(cert, rsaKey)
assert.Error(t, err)
assert.Contains(t, err.Error(), "key type mismatch")
}
// --- ValidateKeyMatch Ed25519 ---
func TestValidateKeyMatch_Ed25519_Success(t *testing.T) {
cert, priv, _, _ := makeEd25519CertAndKey(t, "ed25519-ok.test")
err := ValidateKeyMatch(cert, priv)
assert.NoError(t, err)
}
func TestValidateKeyMatch_Ed25519_Mismatch(t *testing.T) {
cert, _, _, _ := makeEd25519CertAndKey(t, "ed25519-mismatch.test")
_, otherPriv, err := ed25519.GenerateKey(rand.Reader)
require.NoError(t, err)
err = ValidateKeyMatch(cert, otherPriv)
assert.Error(t, err)
assert.Contains(t, err.Error(), "Ed25519 key mismatch")
}
func TestValidateKeyMatch_Ed25519_WrongKeyType(t *testing.T) {
cert, _, _, _ := makeEd25519CertAndKey(t, "ed25519-wrong.test")
rsaKey, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)
err = ValidateKeyMatch(cert, rsaKey)
assert.Error(t, err)
assert.Contains(t, err.Error(), "key type mismatch")
}
func TestValidateKeyMatch_UnsupportedKeyType(t *testing.T) {
// Create a cert with a nil public key type to trigger the default branch
cert := &x509.Certificate{PublicKey: "not-a-real-key"}
key, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)
err = ValidateKeyMatch(cert, key)
assert.Error(t, err)
assert.Contains(t, err.Error(), "unsupported public key type")
}
// --- ConvertDERToPEM ---
func TestConvertDERToPEM_Valid(t *testing.T) {
cert, _, _, _ := makeRSACertAndKey(t, "der-to-pem.test", time.Now().Add(time.Hour))
pemStr, err := ConvertDERToPEM(cert.Raw)
require.NoError(t, err)
assert.Contains(t, pemStr, "BEGIN CERTIFICATE")
}
func TestConvertDERToPEM_Invalid(t *testing.T) {
_, err := ConvertDERToPEM([]byte("not-der-data"))
assert.Error(t, err)
assert.Contains(t, err.Error(), "invalid DER")
}
// --- ConvertPEMToDER ---
func TestConvertPEMToDER_Valid(t *testing.T) {
_, _, certPEM, _ := makeRSACertAndKey(t, "pem-to-der.test", time.Now().Add(time.Hour))
derData, err := ConvertPEMToDER(string(certPEM))
require.NoError(t, err)
assert.NotEmpty(t, derData)
// Verify it's valid DER
parsed, err := x509.ParseCertificate(derData)
require.NoError(t, err)
assert.Equal(t, "pem-to-der.test", parsed.Subject.CommonName)
}
func TestConvertPEMToDER_NoPEMBlock(t *testing.T) {
_, err := ConvertPEMToDER("not-pem-data")
assert.Error(t, err)
assert.Contains(t, err.Error(), "failed to decode PEM")
}
func TestConvertPEMToDER_InvalidCert(t *testing.T) {
fakePEM := string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: []byte("garbage")}))
_, err := ConvertPEMToDER(fakePEM)
assert.Error(t, err)
assert.Contains(t, err.Error(), "invalid certificate PEM")
}
// --- ConvertPEMToPFX ---
func TestConvertPEMToPFX_Valid(t *testing.T) {
_, _, certPEM, keyPEM := makeRSACertAndKey(t, "pem-to-pfx.test", time.Now().Add(time.Hour))
pfxData, err := ConvertPEMToPFX(string(certPEM), string(keyPEM), "", "test-password")
require.NoError(t, err)
assert.NotEmpty(t, pfxData)
}
func TestConvertPEMToPFX_WithChain(t *testing.T) {
_, _, certPEM, keyPEM := makeRSACertAndKey(t, "pfx-chain.test", time.Now().Add(time.Hour))
_, _, chainPEM, _ := makeRSACertAndKey(t, "pfx-ca.test", time.Now().Add(time.Hour))
pfxData, err := ConvertPEMToPFX(string(certPEM), string(keyPEM), string(chainPEM), "pass")
require.NoError(t, err)
assert.NotEmpty(t, pfxData)
}
func TestConvertPEMToPFX_BadCert(t *testing.T) {
_, err := ConvertPEMToPFX("not-pem", "not-pem", "", "pass")
assert.Error(t, err)
assert.Contains(t, err.Error(), "cert PEM")
}
func TestConvertPEMToPFX_BadKey(t *testing.T) {
_, _, certPEM, _ := makeRSACertAndKey(t, "pfx-badkey.test", time.Now().Add(time.Hour))
_, err := ConvertPEMToPFX(string(certPEM), "not-pem", "", "pass")
assert.Error(t, err)
assert.Contains(t, err.Error(), "key PEM")
}
// --- ExtractCertificateMetadata ---
func TestExtractCertificateMetadata_Nil(t *testing.T) {
result := ExtractCertificateMetadata(nil)
assert.Nil(t, result)
}
func TestExtractCertificateMetadata_Valid(t *testing.T) {
cert, _, _, _ := makeRSACertAndKey(t, "metadata.test", time.Now().Add(24*time.Hour))
meta := ExtractCertificateMetadata(cert)
require.NotNil(t, meta)
assert.NotEmpty(t, meta.Fingerprint)
assert.NotEmpty(t, meta.SerialNumber)
assert.Contains(t, meta.KeyType, "RSA")
assert.Contains(t, meta.Domains, "metadata.test")
}
func TestExtractCertificateMetadata_WithSANs(t *testing.T) {
priv, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(42),
Subject: pkix.Name{CommonName: "san.test", Organization: []string{"Test Org"}},
Issuer: pkix.Name{Organization: []string{"Test Issuer"}},
NotBefore: time.Now(),
NotAfter: time.Now().Add(time.Hour),
DNSNames: []string{"san.test", "alt.test", "other.test"},
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &priv.PublicKey, priv)
require.NoError(t, err)
cert, err := x509.ParseCertificate(der)
require.NoError(t, err)
meta := ExtractCertificateMetadata(cert)
require.NotNil(t, meta)
assert.Contains(t, meta.Domains, "san.test")
assert.Contains(t, meta.Domains, "alt.test")
assert.Contains(t, meta.Domains, "other.test")
assert.Equal(t, "Test Org", meta.IssuerOrg)
}
// --- detectKeyType ---
func TestDetectKeyType_Ed25519(t *testing.T) {
cert, _, _, _ := makeEd25519CertAndKey(t, "ed25519-type.test")
assert.Equal(t, "Ed25519", detectKeyType(cert))
}
func TestDetectKeyType_RSA(t *testing.T) {
cert, _, _, _ := makeRSACertAndKey(t, "rsa-type.test", time.Now().Add(time.Hour))
kt := detectKeyType(cert)
assert.Contains(t, kt, "RSA-")
}
func TestDetectKeyType_ECDSA_P256(t *testing.T) {
cert, _, _, _ := makeECDSACertAndKey(t, "p256-type.test")
assert.Equal(t, "ECDSA-P256", detectKeyType(cert))
}
// --- formatSerial ---
func TestFormatSerial_Nil(t *testing.T) {
assert.Equal(t, "", formatSerial(nil))
}
func TestFormatSerial_Value(t *testing.T) {
result := formatSerial(big.NewInt(256))
assert.NotEmpty(t, result)
assert.Contains(t, result, ":")
}
// --- formatFingerprint ---
func TestFormatFingerprint_Normal(t *testing.T) {
result := formatFingerprint("aabbccdd")
assert.Equal(t, "AA:BB:CC:DD", result)
}
func TestFormatFingerprint_OddLength(t *testing.T) {
result := formatFingerprint("aabbc")
assert.Contains(t, result, "AA:BB")
}
// --- DetectFormat DER ---
func TestDetectFormat_DER(t *testing.T) {
cert, _, _, _ := makeRSACertAndKey(t, "detect-der.test", time.Now().Add(time.Hour))
format := DetectFormat(cert.Raw)
assert.Equal(t, FormatDER, format)
}
func TestDetectFormat_PEM(t *testing.T) {
_, _, certPEM, _ := makeRSACertAndKey(t, "detect-pem.test", time.Now().Add(time.Hour))
format := DetectFormat(certPEM)
assert.Equal(t, FormatPEM, format)
}

View File

@@ -361,4 +361,83 @@ describe('CertificateList', () => {
await user.click(screen.getByText('Expires'))
expect(getRowNames()).toEqual(['Zulu', 'Alpha'])
})
it('shows success toast when single delete succeeds', async () => {
const { toast } = await import('../../utils/toast')
deleteMutateFn.mockImplementation((_uuid: string, { onSuccess }: { onSuccess: () => void }) => {
onSuccess()
})
const user = userEvent.setup()
renderWithClient(<CertificateList />)
const rows = await screen.findAllByRole('row')
const customRow = rows.find(r => r.textContent?.includes('CustomCert'))!
await user.click(within(customRow).getByRole('button', { name: 'certificates.deleteTitle' }))
const dialog = await screen.findByRole('dialog')
await user.click(within(dialog).getByRole('button', { name: 'certificates.deleteButton' }))
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('certificates.deleteSuccess'))
})
it('shows error toast when single delete fails', async () => {
const { toast } = await import('../../utils/toast')
deleteMutateFn.mockImplementation((_uuid: string, { onError }: { onError: (e: Error) => void }) => {
onError(new Error('Network failure'))
})
const user = userEvent.setup()
renderWithClient(<CertificateList />)
const rows = await screen.findAllByRole('row')
const customRow = rows.find(r => r.textContent?.includes('CustomCert'))!
await user.click(within(customRow).getByRole('button', { name: 'certificates.deleteTitle' }))
const dialog = await screen.findByRole('dialog')
await user.click(within(dialog).getByRole('button', { name: 'certificates.deleteButton' }))
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('certificates.deleteFailed: Network failure'))
})
it('shows success toast when all bulk deletes succeed', async () => {
const { toast } = await import('../../utils/toast')
bulkDeleteMutateFn.mockImplementation((_uuids: string[], { onSuccess }: { onSuccess: (data: { succeeded: number; failed: number }) => void }) => {
onSuccess({ succeeded: 2, failed: 0 })
})
const user = userEvent.setup()
renderWithClient(<CertificateList />)
const rows = await screen.findAllByRole('row')
await user.click(within(rows.find(r => r.textContent?.includes('CustomCert'))!).getByRole('checkbox'))
await user.click(within(rows.find(r => r.textContent?.includes('LE Staging'))!).getByRole('checkbox'))
await user.click(screen.getByRole('button', { name: /certificates\.bulkDeleteButton/i }))
const dialog = await screen.findByRole('dialog')
await user.click(within(dialog).getByRole('button', { name: /certificates\.bulkDeleteButton/i }))
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('certificates.bulkDeleteSuccess'))
})
it('shows error toast when bulk delete fails entirely', async () => {
const { toast } = await import('../../utils/toast')
bulkDeleteMutateFn.mockImplementation((_uuids: string[], { onError }: { onError: () => void }) => {
onError()
})
const user = userEvent.setup()
renderWithClient(<CertificateList />)
const rows = await screen.findAllByRole('row')
await user.click(within(rows.find(r => r.textContent?.includes('CustomCert'))!).getByRole('checkbox'))
await user.click(screen.getByRole('button', { name: /certificates\.bulkDeleteButton/i }))
const dialog = await screen.findByRole('dialog')
await user.click(within(dialog).getByRole('button', { name: /certificates\.bulkDeleteButton/i }))
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('certificates.bulkDeleteFailed'))
})
it('opens detail dialog when view button is clicked', async () => {
const user = userEvent.setup()
renderWithClient(<CertificateList />)
const rows = await screen.findAllByRole('row')
const customRow = rows.find(r => r.textContent?.includes('CustomCert'))!
await user.click(within(customRow).getByTestId('view-cert-cert-1'))
expect(await screen.findByRole('dialog')).toBeInTheDocument()
})
it('opens export dialog when export button is clicked', async () => {
const user = userEvent.setup()
renderWithClient(<CertificateList />)
const rows = await screen.findAllByRole('row')
const customRow = rows.find(r => r.textContent?.includes('CustomCert'))!
await user.click(within(customRow).getByTestId('export-cert-cert-1'))
expect(await screen.findByRole('dialog')).toBeInTheDocument()
})
})

View File

@@ -310,4 +310,66 @@ describe('CertificateUploadDialog', () => {
expect(screen.queryByTestId('certificate-validation-preview')).toBeFalsy()
})
})
it('resets validation when key file changes', async () => {
const mockResult = {
valid: true,
common_name: 'test.com',
domains: ['test.com'],
issuer_org: 'CA',
expires_at: '2026-01-01',
key_match: false,
chain_valid: false,
chain_depth: 0,
warnings: [],
errors: [],
}
validateMutateFn.mockImplementation((_params: unknown, opts: { onSuccess: (r: typeof mockResult) => void }) => {
opts.onSuccess(mockResult)
})
renderDialog()
const certInput = document.getElementById('cert-file') as HTMLInputElement
await userEvent.upload(certInput, createFile())
await userEvent.click(await screen.findByTestId('validate-certificate-btn'))
expect(await screen.findByTestId('certificate-validation-preview')).toBeTruthy()
const keyInput = document.getElementById('key-file') as HTMLInputElement
const keyFile = new File(['key-data'], 'private.key', { type: 'application/x-pem-file' })
await userEvent.upload(keyInput, keyFile)
await waitFor(() => {
expect(screen.queryByTestId('certificate-validation-preview')).toBeFalsy()
})
})
it('resets validation when chain file changes', async () => {
const mockResult = {
valid: true,
common_name: 'test.com',
domains: ['test.com'],
issuer_org: 'CA',
expires_at: '2026-01-01',
key_match: false,
chain_valid: false,
chain_depth: 0,
warnings: [],
errors: [],
}
validateMutateFn.mockImplementation((_params: unknown, opts: { onSuccess: (r: typeof mockResult) => void }) => {
opts.onSuccess(mockResult)
})
renderDialog()
const certInput = document.getElementById('cert-file') as HTMLInputElement
await userEvent.upload(certInput, createFile())
await userEvent.click(await screen.findByTestId('validate-certificate-btn'))
expect(await screen.findByTestId('certificate-validation-preview')).toBeTruthy()
const chainInput = document.getElementById('chain-file') as HTMLInputElement
const chainFile = new File(['chain-data'], 'chain.pem', { type: 'application/x-pem-file' })
await userEvent.upload(chainInput, chainFile)
await waitFor(() => {
expect(screen.queryByTestId('certificate-validation-preview')).toBeFalsy()
})
})
})

View File

@@ -73,4 +73,15 @@ describe('Dashboard page', () => {
expect(await screen.findByText('Error')).toBeInTheDocument()
})
it('handles certificates with missing domains field', async () => {
// The top-level mock returns certs with "domain" (singular) but Dashboard
// reads "domains" (plural), so the !cert.domains guard on line 48 is
// already exercised by every render. Re-render and verify it doesn't crash.
renderWithQueryClient(<Dashboard />)
expect(await screen.findByText('Dashboard')).toBeInTheDocument()
// "1 valid" still renders even though cert.domains is undefined
expect(screen.getByText('1 valid')).toBeInTheDocument()
})
})

View File

@@ -453,12 +453,12 @@ describe('UsersPage', () => {
await waitFor(() => {
expect(client.post).toHaveBeenCalledWith('/users/preview-invite-url', { email: 'test@example.com' })
}, { timeout: 1000 })
}, { timeout: 2000 })
// Look for the preview URL content with ellipsis replacing the token
await waitFor(() => {
expect(screen.getByText('https://charon.example.com/accept-invite?token=...')).toBeInTheDocument()
}, { timeout: 1000 })
}, { timeout: 2000 })
})
it('debounces URL preview for 500ms', async () => {
@@ -521,12 +521,16 @@ describe('UsersPage', () => {
const emailInput = screen.getByPlaceholderText('user@example.com')
await user.type(emailInput, 'test@example.com')
await waitFor(() => {
expect(client.post).toHaveBeenCalledWith('/users/preview-invite-url', { email: 'test@example.com' })
}, { timeout: 2000 })
await waitFor(() => {
const preview = screen.getByText('https://example.com/accept-invite?token=...')
expect(preview.textContent).toContain('...')
expect(preview.textContent).not.toContain('SAMPLE_TOKEN_PREVIEW')
}, { timeout: 1000 })
}, { timeout: 2000 })
})
it('shows warning when not configured', async () => {
@@ -550,11 +554,15 @@ describe('UsersPage', () => {
const emailInput = screen.getByPlaceholderText('user@example.com')
await user.type(emailInput, 'test@example.com')
await waitFor(() => {
expect(client.post).toHaveBeenCalledWith('/users/preview-invite-url', { email: 'test@example.com' })
}, { timeout: 2000 })
await waitFor(() => {
// Look for link to system settings
const link = screen.getByRole('link')
expect(link.getAttribute('href')).toContain('/settings/system')
}, { timeout: 1000 })
}, { timeout: 2000 })
})
it('does not show preview when email is invalid', async () => {
@@ -590,14 +598,9 @@ describe('UsersPage', () => {
const emailInput = screen.getByPlaceholderText('user@example.com')
await user.type(emailInput, 'test@example.com')
// Wait for debounce
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 600))
})
await waitFor(() => {
expect(client.post).toHaveBeenCalledWith('/users/preview-invite-url', { email: 'test@example.com' })
}, { timeout: 1000 })
}, { timeout: 2000 })
// Verify preview is not displayed after error
const previewQuery = screen.queryByText(/accept-invite/)