fix: resolve CI test failures and close patch coverage gaps
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
166
backend/internal/caddy/config_customcert_test.go
Normal file
166
backend/internal/caddy/config_customcert_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
38
backend/internal/services/certificate_helpers_test.go
Normal file
38
backend/internal/services/certificate_helpers_test.go
Normal 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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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: ¬Before,
|
||||
})
|
||||
|
||||
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: ¬Before,
|
||||
})
|
||||
|
||||
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")
|
||||
}
|
||||
324
backend/internal/services/certificate_validator_coverage_test.go
Normal file
324
backend/internal/services/certificate_validator_coverage_test.go
Normal 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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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/)
|
||||
|
||||
Reference in New Issue
Block a user