chore: Add tests for CertificateList and CertificateUploadDialog components

- Implement test to deselect a row checkbox in CertificateList by clicking it a second time.
- Add test to close detail dialog via the close button in CertificateList.
- Add test to close export dialog via the cancel button in CertificateList.
- Add test to show KEY format badge when a .key file is uploaded in CertificateUploadDialog.
- Add test to ensure no format badge is shown for unknown file extensions in CertificateUploadDialog.
This commit is contained in:
GitHub Actions
2026-04-15 11:35:10 +00:00
parent fb8d80f6a3
commit 8239a94938
10 changed files with 3334 additions and 1724 deletions
+1
View File
@@ -315,3 +315,4 @@ validation-evidence/**
docs/reports/codecove_patch_report.md
vuln-results.json
test_output.txt
coverage_results.txt
@@ -2,6 +2,7 @@ package handlers
import (
"bytes"
"encoding/json"
"fmt"
"mime/multipart"
"net/http"
@@ -339,3 +340,368 @@ func TestGet_DBError(t *testing.T) {
// Should be 500 since the table doesn't exist
assert.Equal(t, http.StatusInternalServerError, w.Code)
}
// --- Export handler: re-auth and service error paths ---
func TestExport_IncludeKey_MissingPassword(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.User{}))
svc := services.NewCertificateService(tmpDir, db, nil)
h := NewCertificateHandler(svc, nil, nil)
h.SetDB(db)
r := gin.New()
r.Use(mockAuthMiddleware())
r.POST("/api/certificates/:uuid/export", h.Export)
body := bytes.NewBufferString(`{"format":"pem","include_key":true}`)
req := httptest.NewRequest(http.MethodPost, "/api/certificates/"+uuid.New().String()+"/export", body)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusForbidden, w.Code)
}
func TestExport_IncludeKey_NoUserContext(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.User{}))
svc := services.NewCertificateService(tmpDir, db, nil)
h := NewCertificateHandler(svc, nil, nil)
h.SetDB(db)
r := gin.New() // no middleware — "user" key absent
r.POST("/api/certificates/:uuid/export", h.Export)
body := bytes.NewBufferString(`{"format":"pem","include_key":true,"password":"somepass"}`)
req := httptest.NewRequest(http.MethodPost, "/api/certificates/"+uuid.New().String()+"/export", body)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusForbidden, w.Code)
}
func TestExport_IncludeKey_InvalidClaimsType(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.User{}))
svc := services.NewCertificateService(tmpDir, db, nil)
h := NewCertificateHandler(svc, nil, nil)
h.SetDB(db)
r := gin.New()
r.Use(func(c *gin.Context) { c.Set("user", "not-a-map"); c.Next() })
r.POST("/api/certificates/:uuid/export", h.Export)
body := bytes.NewBufferString(`{"format":"pem","include_key":true,"password":"somepass"}`)
req := httptest.NewRequest(http.MethodPost, "/api/certificates/"+uuid.New().String()+"/export", body)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusForbidden, w.Code)
}
func TestExport_IncludeKey_UserIDNotInClaims(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.User{}))
svc := services.NewCertificateService(tmpDir, db, nil)
h := NewCertificateHandler(svc, nil, nil)
h.SetDB(db)
r := gin.New()
r.Use(func(c *gin.Context) { c.Set("user", map[string]any{}); c.Next() }) // no "id" key
r.POST("/api/certificates/:uuid/export", h.Export)
body := bytes.NewBufferString(`{"format":"pem","include_key":true,"password":"somepass"}`)
req := httptest.NewRequest(http.MethodPost, "/api/certificates/"+uuid.New().String()+"/export", body)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusForbidden, w.Code)
}
func TestExport_IncludeKey_UserNotFoundInDB(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.User{}))
svc := services.NewCertificateService(tmpDir, db, nil)
h := NewCertificateHandler(svc, nil, nil)
h.SetDB(db)
r := gin.New()
r.Use(func(c *gin.Context) { c.Set("user", map[string]any{"id": float64(9999)}); c.Next() })
r.POST("/api/certificates/:uuid/export", h.Export)
body := bytes.NewBufferString(`{"format":"pem","include_key":true,"password":"somepass"}`)
req := httptest.NewRequest(http.MethodPost, "/api/certificates/"+uuid.New().String()+"/export", body)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusForbidden, w.Code)
}
func TestExport_IncludeKey_WrongPassword(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.User{}))
u := &models.User{UUID: uuid.New().String(), Email: "export@example.com", Name: "Export User"}
require.NoError(t, u.SetPassword("correctpass"))
require.NoError(t, db.Create(u).Error)
svc := services.NewCertificateService(tmpDir, db, nil)
h := NewCertificateHandler(svc, nil, nil)
h.SetDB(db)
r := gin.New()
r.Use(func(c *gin.Context) { c.Set("user", map[string]any{"id": float64(u.ID)}); c.Next() })
r.POST("/api/certificates/:uuid/export", h.Export)
body := bytes.NewBufferString(`{"format":"pem","include_key":true,"password":"wrongpass"}`)
req := httptest.NewRequest(http.MethodPost, "/api/certificates/"+uuid.New().String()+"/export", body)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusForbidden, w.Code)
}
func TestExport_CertNotFound(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{}))
svc := services.NewCertificateService(tmpDir, db, nil)
h := NewCertificateHandler(svc, nil, nil)
r := gin.New()
r.Use(mockAuthMiddleware())
r.POST("/api/certificates/:uuid/export", h.Export)
body := bytes.NewBufferString(`{"format":"pem"}`)
req := httptest.NewRequest(http.MethodPost, "/api/certificates/"+uuid.New().String()+"/export", body)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusNotFound, w.Code)
}
func TestExport_ServiceError(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{}))
certUUID := uuid.New().String()
cert := models.SSLCertificate{UUID: certUUID, Name: "test", Domains: "test.example.com", Provider: "custom"}
require.NoError(t, db.Create(&cert).Error)
svc := services.NewCertificateService(tmpDir, db, nil)
h := NewCertificateHandler(svc, nil, nil)
r := gin.New()
r.Use(mockAuthMiddleware())
r.POST("/api/certificates/:uuid/export", h.Export)
body := bytes.NewBufferString(`{"format":"unsupported_xyz"}`)
req := httptest.NewRequest(http.MethodPost, "/api/certificates/"+certUUID+"/export", body)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusInternalServerError, w.Code)
}
// --- Delete numeric ID paths ---
func TestDelete_NumericID_UsageCheckError(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{})) // no ProxyHost → IsCertificateInUse fails
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/1", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusInternalServerError, w.Code)
}
func TestDelete_NumericID_LowDiskSpace(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{}))
cert := models.SSLCertificate{UUID: uuid.New().String(), Name: "low-space", Domains: "lowspace.example.com", Provider: "custom"}
require.NoError(t, db.Create(&cert).Error)
svc := services.NewCertificateService(tmpDir, db, nil)
backup := &mockBackupService{
availableSpaceFunc: func() (int64, error) { return 1024, nil }, // < 100 MB
createFunc: func() (string, error) { return "", nil },
}
h := NewCertificateHandler(svc, backup, nil)
r := gin.New()
r.Use(mockAuthMiddleware())
r.DELETE("/api/certificates/:uuid", h.Delete)
req := httptest.NewRequest(http.MethodDelete, fmt.Sprintf("/api/certificates/%d", cert.ID), http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusInsufficientStorage, w.Code)
}
func TestDelete_NumericID_BackupError(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{}))
cert := models.SSLCertificate{UUID: uuid.New().String(), Name: "backup-err", Domains: "backuperr.example.com", Provider: "custom"}
require.NoError(t, db.Create(&cert).Error)
svc := services.NewCertificateService(tmpDir, db, nil)
backup := &mockBackupService{
availableSpaceFunc: func() (int64, error) { return 1 << 30, nil }, // 1 GB — plenty
createFunc: func() (string, error) { return "", fmt.Errorf("backup create failed") },
}
h := NewCertificateHandler(svc, backup, nil)
r := gin.New()
r.Use(mockAuthMiddleware())
r.DELETE("/api/certificates/:uuid", h.Delete)
req := httptest.NewRequest(http.MethodDelete, fmt.Sprintf("/api/certificates/%d", cert.ID), http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusInternalServerError, w.Code)
}
func TestDelete_NumericID_DeleteError(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.ProxyHost{})) // no SSLCertificate → DeleteCertificateByID fails
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/42", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusInternalServerError, w.Code)
}
// --- Delete UUID: internal usage-check error ---
func TestDelete_UUID_UsageCheckInternalError(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{})) // no ProxyHost → IsCertificateInUse fails
certUUID := uuid.New().String()
cert := models.SSLCertificate{UUID: certUUID, Name: "uuid-err", Domains: "uuiderr.example.com", Provider: "custom"}
require.NoError(t, db.Create(&cert).Error)
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.StatusInternalServerError, w.Code)
}
// --- sendDeleteNotification: rate limit ---
func TestSendDeleteNotification_RateLimit(t *testing.T) {
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{}))
ns := services.NewNotificationService(db, nil)
svc := services.NewCertificateService(t.TempDir(), db, nil)
h := NewCertificateHandler(svc, nil, ns)
w := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(w)
ctx.Request = httptest.NewRequest(http.MethodDelete, "/", http.NoBody)
certRef := uuid.New().String()
h.sendDeleteNotification(ctx, certRef) // first call — sets timestamp
h.sendDeleteNotification(ctx, certRef) // second call — hits rate limit branch
}
// --- Update: empty UUID param (lines 207-209) ---
func TestUpdate_EmptyUUID(t *testing.T) {
svc := services.NewCertificateService(t.TempDir(), nil, nil)
h := NewCertificateHandler(svc, nil, nil)
w := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(w)
ctx.Request = httptest.NewRequest(http.MethodPut, "/api/certificates/", bytes.NewBufferString(`{"name":"test"}`))
ctx.Request.Header.Set("Content-Type", "application/json")
// No Params set — c.Param("uuid") returns ""
h.Update(ctx)
assert.Equal(t, http.StatusBadRequest, w.Code)
}
// --- Update: DB error (non-ErrCertNotFound) → lines 223-225 ---
func TestUpdate_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 no AutoMigrate → ssl_certificates table absent → "no such table" error
svc := services.NewCertificateService(t.TempDir(), db, nil)
h := NewCertificateHandler(svc, nil, nil)
r := gin.New()
r.Use(mockAuthMiddleware())
r.PUT("/api/certificates/:uuid", h.Update)
body, _ := json.Marshal(map[string]string{"name": "new-name"})
req := httptest.NewRequest(http.MethodPut, "/api/certificates/"+uuid.New().String(), bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusInternalServerError, w.Code)
}
@@ -0,0 +1,128 @@
package handlers
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGenerateForwardHostWarnings_PrivateIP(t *testing.T) {
warnings := generateForwardHostWarnings("192.168.1.100")
require.Len(t, warnings, 1)
assert.Equal(t, "forward_host", warnings[0].Field)
}
func TestBulkUpdateSecurityHeaders_AllFail_Rollback(t *testing.T) {
r, _ := setupTestRouterForSecurityHeaders(t)
body, err := json.Marshal(map[string]any{
"host_uuids": []string{
uuid.New().String(),
uuid.New().String(),
uuid.New().String(),
},
})
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPut, "/api/v1/proxy-hosts/bulk-update-security-headers", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
}
func TestBulkUpdateSecurityHeaders_ProfileDB_NonNotFoundError(t *testing.T) {
r, db := setupTestRouterForSecurityHeaders(t)
// Drop the security_header_profiles table so the lookup returns a non-NotFound DB error
require.NoError(t, db.Exec("DROP TABLE security_header_profiles").Error)
profileID := uint(1)
body, err := json.Marshal(map[string]any{
"host_uuids": []string{uuid.New().String()},
"security_header_profile_id": profileID,
})
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPut, "/api/v1/proxy-hosts/bulk-update-security-headers", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusInternalServerError, w.Code)
}
func TestGenerateForwardHostWarnings_DockerBridgeIP(t *testing.T) {
warnings := generateForwardHostWarnings("172.17.0.1")
require.Len(t, warnings, 1)
assert.Equal(t, "forward_host", warnings[0].Field)
}
func TestParseNullableUintField_DefaultType(t *testing.T) {
id, exists, err := parseNullableUintField(true, "test_field")
assert.Nil(t, id)
assert.True(t, exists)
assert.Error(t, err)
}
func TestParseForwardPortField_StringEmpty(t *testing.T) {
_, err := parseForwardPortField("")
assert.Error(t, err)
}
func TestParseForwardPortField_StringNonNumeric(t *testing.T) {
_, err := parseForwardPortField("notaport")
assert.Error(t, err)
}
func TestParseForwardPortField_StringValid(t *testing.T) {
port, err := parseForwardPortField("8080")
require.NoError(t, err)
assert.Equal(t, 8080, port)
}
func TestParseForwardPortField_DefaultType(t *testing.T) {
_, err := parseForwardPortField(true)
assert.Error(t, err)
}
func TestCreate_InvalidCertificateRef(t *testing.T) {
r, _ := setupTestRouterForSecurityHeaders(t)
body, err := json.Marshal(map[string]any{
"domain_names": "cert-ref.example.com",
"forward_host": "localhost",
"forward_port": 8080,
"certificate_id": uuid.New().String(),
})
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPost, "/api/v1/proxy-hosts", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
}
func TestCreate_InvalidSecurityHeaderProfileRef(t *testing.T) {
r, _ := setupTestRouterForSecurityHeaders(t)
body, err := json.Marshal(map[string]any{
"domain_names": "shp-ref.example.com",
"forward_host": "localhost",
"forward_port": 8080,
"security_header_profile_id": uuid.New().String(),
})
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPost, "/api/v1/proxy-hosts", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
}
@@ -0,0 +1,172 @@
package services
import (
"context"
"fmt"
"testing"
"time"
"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"
)
// TestCheckExpiry_QueryFails covers lines 977-979: CheckExpiringCertificates fails.
func TestCheckExpiry_QueryFails(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.Notification{}, &models.NotificationProvider{}))
// Drop ssl_certificates so CheckExpiringCertificates returns an error
require.NoError(t, db.Exec("DROP TABLE ssl_certificates").Error)
ns := NewNotificationService(db, nil)
svc := NewCertificateService(t.TempDir(), db, nil)
// Should not panic — logs the error and returns
svc.checkExpiry(context.Background(), ns, 30)
}
// TestCheckExpiry_ExpiredCert_Success covers lines 981-998: expired cert notification success path.
func TestCheckExpiry_ExpiredCert_Success(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.Notification{}, &models.NotificationProvider{}))
past := time.Now().Add(-48 * time.Hour)
certUUID := uuid.New().String()
require.NoError(t, db.Create(&models.SSLCertificate{
UUID: certUUID,
Name: "expired-cert",
Provider: "custom",
Domains: "expired.example.com",
ExpiresAt: &past,
}).Error)
ns := NewNotificationService(db, nil)
svc := NewCertificateService(t.TempDir(), db, nil)
svc.checkExpiry(context.Background(), ns, 30)
var notifications []models.Notification
require.NoError(t, db.Find(&notifications).Error)
assert.NotEmpty(t, notifications)
}
// TestCheckExpiry_ExpiringSoonCert_Success covers lines 999-1014: expiring-soon cert notification success path.
func TestCheckExpiry_ExpiringSoonCert_Success(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.Notification{}, &models.NotificationProvider{}))
soon := time.Now().Add(7 * 24 * time.Hour)
certUUID := uuid.New().String()
require.NoError(t, db.Create(&models.SSLCertificate{
UUID: certUUID,
Name: "expiring-soon-cert",
Provider: "custom",
Domains: "soon.example.com",
ExpiresAt: &soon,
}).Error)
ns := NewNotificationService(db, nil)
svc := NewCertificateService(t.TempDir(), db, nil)
svc.checkExpiry(context.Background(), ns, 30)
var notifications []models.Notification
require.NoError(t, db.Find(&notifications).Error)
assert.NotEmpty(t, notifications)
}
// TestCheckExpiry_NotificationFails covers lines 991-992 and 1006-1007:
// Create() fails for both expired and expiring-soon certs.
func TestCheckExpiry_NotificationFails(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.Notification{}, &models.NotificationProvider{}))
past := time.Now().Add(-48 * time.Hour)
soon := time.Now().Add(7 * 24 * time.Hour)
require.NoError(t, db.Create(&models.SSLCertificate{
UUID: uuid.New().String(),
Name: "expired-cert",
Provider: "custom",
Domains: "expired2.example.com",
ExpiresAt: &past,
}).Error)
require.NoError(t, db.Create(&models.SSLCertificate{
UUID: uuid.New().String(),
Name: "soon-cert",
Provider: "custom",
Domains: "soon2.example.com",
ExpiresAt: &soon,
}).Error)
// Drop notifications table so Create() fails
require.NoError(t, db.Exec("DROP TABLE notifications").Error)
ns := NewNotificationService(db, nil)
svc := NewCertificateService(t.TempDir(), db, nil)
// Should not panic — logs errors and continues
svc.checkExpiry(context.Background(), ns, 30)
}
func TestUploadCertificate_KeyMismatch(t *testing.T) {
cert1PEM, _ := generateTestCertAndKey(t, "cert1.example.com", time.Now().Add(24*time.Hour))
_, key2PEM := generateTestCertAndKey(t, "cert2.example.com", time.Now().Add(24*time.Hour))
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{}))
svc := NewCertificateService(t.TempDir(), db, nil)
_, err = svc.UploadCertificate("mismatch-test", string(cert1PEM), string(key2PEM), "")
require.Error(t, err)
assert.Contains(t, err.Error(), "key validation failed")
}
func TestUploadCertificate_DBError(t *testing.T) {
certPEM, keyPEM := generateTestCertAndKey(t, "db-err.example.com", time.Now().Add(24*time.Hour))
db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
require.NoError(t, err)
// No AutoMigrate → ssl_certificates table absent → db.Create fails
svc := NewCertificateService(t.TempDir(), db, nil)
_, err = svc.UploadCertificate("db-error-test", string(certPEM), string(keyPEM), "")
require.Error(t, err)
assert.Contains(t, err.Error(), "failed to save certificate")
}
func TestGetCertificate_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)
// No AutoMigrate → ssl_certificates table absent → First() returns error
svc := NewCertificateService(t.TempDir(), db, nil)
_, err = svc.GetCertificate(uuid.New().String())
require.Error(t, err)
assert.Contains(t, err.Error(), "failed to fetch certificate")
}
func TestUpdateCertificate_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)
// No AutoMigrate → ssl_certificates table absent → First() returns non-ErrRecordNotFound error
svc := NewCertificateService(t.TempDir(), db, nil)
_, err = svc.UpdateCertificate(uuid.New().String(), "new-name")
require.Error(t, err)
assert.Contains(t, err.Error(), "failed to fetch certificate")
}
@@ -0,0 +1,236 @@
package services
import (
"fmt"
"os"
"path/filepath"
"testing"
"time"
"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"
)
func TestSyncFromDisk_StagingToProductionUpgrade(t *testing.T) {
tmpDir := t.TempDir()
certRoot := filepath.Join(tmpDir, "certificates")
require.NoError(t, os.MkdirAll(certRoot, 0755))
domain := "staging-upgrade.example.com"
certPEM, _ := generateTestCertAndKey(t, domain, time.Now().Add(24*time.Hour))
certFile := filepath.Join(certRoot, domain+".crt")
require.NoError(t, os.WriteFile(certFile, certPEM, 0600))
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{}))
existing := models.SSLCertificate{
UUID: uuid.New().String(),
Name: domain,
Provider: "letsencrypt-staging",
Domains: domain,
Certificate: "old-content",
}
require.NoError(t, db.Create(&existing).Error)
svc := newTestCertificateService(tmpDir, db)
require.NoError(t, svc.SyncFromDisk())
var updated models.SSLCertificate
require.NoError(t, db.Where("uuid = ?", existing.UUID).First(&updated).Error)
assert.Equal(t, "letsencrypt", updated.Provider)
}
func TestSyncFromDisk_ExpiryOnlyUpdate(t *testing.T) {
tmpDir := t.TempDir()
certRoot := filepath.Join(tmpDir, "certificates")
require.NoError(t, os.MkdirAll(certRoot, 0755))
domain := "expiry-only.example.com"
certPEM, _ := generateTestCertAndKey(t, domain, time.Now().Add(24*time.Hour))
certFile := filepath.Join(certRoot, domain+".crt")
require.NoError(t, os.WriteFile(certFile, certPEM, 0600))
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{}))
existing := models.SSLCertificate{
UUID: uuid.New().String(),
Name: domain,
Provider: "letsencrypt",
Domains: domain,
Certificate: string(certPEM), // identical content
}
require.NoError(t, db.Create(&existing).Error)
svc := newTestCertificateService(tmpDir, db)
require.NoError(t, svc.SyncFromDisk())
var updated models.SSLCertificate
require.NoError(t, db.Where("uuid = ?", existing.UUID).First(&updated).Error)
assert.Equal(t, "letsencrypt", updated.Provider)
assert.Equal(t, string(certPEM), updated.Certificate)
}
func TestSyncFromDisk_CertRootStatPermissionError(t *testing.T) {
if os.Getuid() == 0 {
t.Skip("cannot test permission error as root")
}
tmpDir := t.TempDir()
certRoot := filepath.Join(tmpDir, "certificates")
require.NoError(t, os.MkdirAll(certRoot, 0755))
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{}))
// Restrict parent dir so os.Stat(certRoot) fails with permission error
require.NoError(t, os.Chmod(tmpDir, 0))
defer func() { _ = os.Chmod(tmpDir, 0755) }()
svc := newTestCertificateService(tmpDir, db)
err = svc.SyncFromDisk()
require.NoError(t, err)
}
func TestListCertificates_StaleCache_TriggersBackgroundSync(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{}))
svc := newTestCertificateService(tmpDir, db)
// Simulate stale cache
svc.cacheMu.Lock()
svc.initialized = true
svc.lastScan = time.Now().Add(-10 * time.Minute)
before := svc.lastScan
svc.cacheMu.Unlock()
_, err = svc.ListCertificates()
require.NoError(t, err)
// Background goroutine should update lastScan via SyncFromDisk
require.Eventually(t, func() bool {
svc.cacheMu.RLock()
defer svc.cacheMu.RUnlock()
return svc.lastScan.After(before)
}, 2*time.Second, 10*time.Millisecond)
}
func TestGetDecryptedPrivateKey_DecryptFails(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{}))
svc := newTestCertServiceWithEnc(t, tmpDir, db)
cert := models.SSLCertificate{
UUID: uuid.New().String(),
Name: "enc-fail",
Domains: "encfail.example.com",
Provider: "custom",
PrivateKeyEncrypted: "corrupted-ciphertext",
}
require.NoError(t, db.Create(&cert).Error)
_, err = svc.GetDecryptedPrivateKey(&cert)
assert.Error(t, err)
}
func TestDeleteCertificate_LetsEncryptProvider_FileCleanup(t *testing.T) {
tmpDir := t.TempDir()
certRoot := filepath.Join(tmpDir, "certificates")
require.NoError(t, os.MkdirAll(certRoot, 0755))
domain := "le-cleanup.example.com"
certFile := filepath.Join(certRoot, domain+".crt")
keyFile := filepath.Join(certRoot, domain+".key")
jsonFile := filepath.Join(certRoot, domain+".json")
certPEM, _ := generateTestCertAndKey(t, domain, time.Now().Add(24*time.Hour))
require.NoError(t, os.WriteFile(certFile, certPEM, 0600))
require.NoError(t, os.WriteFile(keyFile, []byte("key"), 0600))
require.NoError(t, os.WriteFile(jsonFile, []byte("{}"), 0600))
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{}))
certUUID := uuid.New().String()
cert := models.SSLCertificate{
UUID: certUUID,
Name: domain,
Provider: "letsencrypt",
Domains: domain,
}
require.NoError(t, db.Create(&cert).Error)
svc := newTestCertificateService(tmpDir, db)
require.NoError(t, svc.DeleteCertificate(certUUID))
assert.NoFileExists(t, certFile)
assert.NoFileExists(t, keyFile)
assert.NoFileExists(t, jsonFile)
}
func TestDeleteCertificate_StagingProvider_FileCleanup(t *testing.T) {
tmpDir := t.TempDir()
certRoot := filepath.Join(tmpDir, "certificates")
require.NoError(t, os.MkdirAll(certRoot, 0755))
domain := "le-staging-cleanup.example.com"
certFile := filepath.Join(certRoot, domain+".crt")
certPEM, _ := generateTestCertAndKey(t, domain, time.Now().Add(24*time.Hour))
require.NoError(t, os.WriteFile(certFile, certPEM, 0600))
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{}))
certUUID := uuid.New().String()
cert := models.SSLCertificate{
UUID: certUUID,
Name: domain,
Provider: "letsencrypt-staging",
Domains: domain,
}
require.NoError(t, db.Create(&cert).Error)
svc := newTestCertificateService(tmpDir, db)
require.NoError(t, svc.DeleteCertificate(certUUID))
assert.NoFileExists(t, certFile)
}
func TestCheckExpiringCertificates_DBError(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)
// deliberately do NOT AutoMigrate SSLCertificate
svc := newTestCertificateService(tmpDir, db)
_, err = svc.CheckExpiringCertificates(30)
assert.Error(t, err)
}
@@ -0,0 +1,189 @@
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"
)
func TestDetectFormat_PasswordProtectedPFX(t *testing.T) {
cert, key, _, _ := makeRSACertAndKey(t, "pfx-pw.example.com", time.Now().Add(24*time.Hour))
pfxData, err := pkcs12.Modern.Encode(key, cert, nil, "custompw")
require.NoError(t, err)
format := DetectFormat(pfxData)
assert.Equal(t, FormatPFX, format)
}
func TestParsePEMPrivateKey_PKCS1RSA(t *testing.T) {
key, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)
keyDER := x509.MarshalPKCS1PrivateKey(key)
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: keyDER})
parsed, err := parsePEMPrivateKey(keyPEM)
require.NoError(t, err)
assert.NotNil(t, parsed)
}
func TestParsePEMPrivateKey_ECPrivKey(t *testing.T) {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
keyDER, err := x509.MarshalECPrivateKey(key)
require.NoError(t, err)
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
parsed, err := parsePEMPrivateKey(keyPEM)
require.NoError(t, err)
assert.NotNil(t, parsed)
}
func TestDetectKeyType_ECDSAP384(t *testing.T) {
key, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
require.NoError(t, err)
template := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "p384.example.com"},
NotBefore: time.Now().Add(-time.Minute),
NotAfter: time.Now().Add(24 * time.Hour),
}
certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
require.NoError(t, err)
cert, err := x509.ParseCertificate(certDER)
require.NoError(t, err)
assert.Equal(t, "ECDSA-P384", detectKeyType(cert))
}
func TestDetectKeyType_ECDSAUnknownCurve(t *testing.T) {
key, err := ecdsa.GenerateKey(elliptic.P224(), rand.Reader)
require.NoError(t, err)
template := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "p224.example.com"},
NotBefore: time.Now().Add(-time.Minute),
NotAfter: time.Now().Add(24 * time.Hour),
}
certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
require.NoError(t, err)
cert, err := x509.ParseCertificate(certDER)
require.NoError(t, err)
assert.Equal(t, "ECDSA", detectKeyType(cert))
}
func TestConvertPEMToPFX_EmptyChain(t *testing.T) {
_, _, certPEM, keyPEM := makeRSACertAndKey(t, "pfx-chain.example.com", time.Now().Add(24*time.Hour))
pfxData, err := ConvertPEMToPFX(string(certPEM), string(keyPEM), "", "testpass")
require.NoError(t, err)
assert.NotEmpty(t, pfxData)
}
func TestConvertPEMToDER_NonCertBlock(t *testing.T) {
key, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)
keyPEM := pem.EncodeToMemory(&pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(key),
})
_, err = ConvertPEMToDER(string(keyPEM))
require.Error(t, err)
assert.Contains(t, err.Error(), "invalid certificate PEM")
}
func TestFormatSerial_NilInput(t *testing.T) {
assert.Equal(t, "", formatSerial(nil))
}
func TestDetectFormat_EmptyPasswordPFX(t *testing.T) {
cert, key, _, _ := makeRSACertAndKey(t, "empty-pw.example.com", time.Now().Add(24*time.Hour))
pfxData, err := pkcs12.Modern.Encode(key, cert, nil, "")
require.NoError(t, err)
format := DetectFormat(pfxData)
assert.Equal(t, FormatPFX, format)
}
func TestParseCertificateInput_BadChainPEM(t *testing.T) {
_, _, certPEM, _ := makeRSACertAndKey(t, "bad-chain-test.example.com", time.Now().Add(24*time.Hour))
badChain := []byte("-----BEGIN CERTIFICATE-----\naW52YWxpZA==\n-----END CERTIFICATE-----\n")
_, err := ParseCertificateInput(certPEM, nil, badChain, "")
require.Error(t, err)
assert.Contains(t, err.Error(), "failed to parse chain PEM")
}
func TestValidateChain_WithIntermediates(t *testing.T) {
cert, _, _, _ := makeRSACertAndKey(t, "chain-inter.example.com", time.Now().Add(24*time.Hour))
_ = ValidateChain(cert, []*x509.Certificate{cert})
}
func TestConvertPEMToPFX_BadCertPEM(t *testing.T) {
badCertPEM := "-----BEGIN CERTIFICATE-----\naW52YWxpZA==\n-----END CERTIFICATE-----\n"
_, err := ConvertPEMToPFX(badCertPEM, "somekey", "", "pass")
require.Error(t, err)
assert.Contains(t, err.Error(), "failed to parse cert PEM")
}
func TestConvertPEMToPFX_BadChainPEM(t *testing.T) {
_, _, certPEM, keyPEM := makeRSACertAndKey(t, "pfx-bad-chain.example.com", time.Now().Add(24*time.Hour))
badChain := "-----BEGIN CERTIFICATE-----\naW52YWxpZA==\n-----END CERTIFICATE-----\n"
_, err := ConvertPEMToPFX(string(certPEM), string(keyPEM), badChain, "pass")
require.Error(t, err)
assert.Contains(t, err.Error(), "failed to parse chain PEM")
}
func TestParsePEMPrivateKey_PKCS8(t *testing.T) {
key, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)
der, err := x509.MarshalPKCS8PrivateKey(key)
require.NoError(t, err)
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der})
parsed, err := parsePEMPrivateKey(keyPEM)
require.NoError(t, err)
assert.NotNil(t, parsed)
}
func TestEncodeKeyToPEM_UnsupportedKeyType(t *testing.T) {
type badKey struct{}
_, err := encodeKeyToPEM(badKey{})
require.Error(t, err)
assert.Contains(t, err.Error(), "failed to marshal private key")
}
func TestDetectKeyType_Unknown(t *testing.T) {
cert := &x509.Certificate{
PublicKey: "not-a-real-key",
}
assert.Equal(t, "Unknown", detectKeyType(cert))
}
File diff suppressed because it is too large Load Diff
+376 -1724
View File
File diff suppressed because it is too large Load Diff
@@ -440,4 +440,40 @@ describe('CertificateList', () => {
await user.click(within(customRow).getByTestId('export-cert-cert-1'))
expect(await screen.findByRole('dialog')).toBeInTheDocument()
})
it('deselects a row checkbox by clicking it a second time', async () => {
const user = userEvent.setup()
renderWithClient(<CertificateList />)
const rows = await screen.findAllByRole('row')
const customRow = rows.find(r => r.textContent?.includes('CustomCert'))!
const checkbox = within(customRow).getByRole('checkbox')
await user.click(checkbox)
expect(screen.getByRole('status')).toBeInTheDocument()
await user.click(checkbox)
await waitFor(() => expect(screen.queryByRole('status')).not.toBeInTheDocument())
})
it('closes detail dialog via the dialog close button', 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'))
const dialog = await screen.findByRole('dialog')
expect(dialog).toBeInTheDocument()
await user.click(within(dialog).getByRole('button', { name: 'Close' }))
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
})
it('closes export dialog via the cancel button', 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'))
const dialog = await screen.findByRole('dialog')
expect(dialog).toBeInTheDocument()
await user.click(within(dialog).getByRole('button', { name: 'common.cancel' }))
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
})
})
@@ -384,4 +384,26 @@ describe('CertificateUploadDialog', () => {
expect(screen.queryByTestId('certificate-validation-preview')).toBeFalsy()
})
})
it('shows KEY format badge when .key file is uploaded', async () => {
const user = userEvent.setup({ applyAccept: false })
renderDialog()
const certInput = document.getElementById('cert-file') as HTMLInputElement
const file = new File(['key-data'], 'server.key', { type: 'application/x-pem-file' })
await user.upload(certInput, file)
expect(await screen.findByText('KEY')).toBeTruthy()
})
it('shows no format badge for unknown file extension', async () => {
const user = userEvent.setup({ applyAccept: false })
renderDialog()
const certInput = document.getElementById('cert-file') as HTMLInputElement
const file = new File(['data'], 'cert.bin', { type: 'application/octet-stream' })
await user.upload(certInput, file)
await screen.findByText('cert.bin')
expect(screen.queryByText('KEY')).toBeNull()
expect(screen.queryByText('DER')).toBeNull()
expect(screen.queryByText('PFX/PKCS#12')).toBeNull()
expect(screen.queryByText('PEM')).toBeNull()
})
})