diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4118829f..1e92ba74 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -50,9 +50,10 @@ repos: language: system files: '^frontend/.*\.(ts|tsx|js|jsx)$' pass_filenames: false - - id: frontend-test - name: Frontend Tests - entry: bash -c 'cd frontend && npm test -- --run' - language: system + - id: frontend-test-coverage + name: Frontend Test Coverage + entry: scripts/frontend-test-coverage.sh + language: script files: '^frontend/.*\.(ts|tsx|js|jsx)$' pass_filenames: false + verbose: true diff --git a/backend/internal/api/handlers/auth_handler_test.go b/backend/internal/api/handlers/auth_handler_test.go index 3a54d5ab..8325375d 100644 --- a/backend/internal/api/handlers/auth_handler_test.go +++ b/backend/internal/api/handlers/auth_handler_test.go @@ -60,6 +60,32 @@ func TestAuthHandler_Login(t *testing.T) { assert.Contains(t, w.Body.String(), "token") } +func TestAuthHandler_Login_Errors(t *testing.T) { + handler, _ := setupAuthHandler(t) + gin.SetMode(gin.TestMode) + r := gin.New() + r.POST("/login", handler.Login) + + // 1. Invalid JSON + req := httptest.NewRequest("POST", "/login", bytes.NewBufferString("invalid")) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + assert.Equal(t, http.StatusBadRequest, w.Code) + + // 2. Invalid Credentials + body := map[string]string{ + "email": "nonexistent@example.com", + "password": "wrong", + } + jsonBody, _ := json.Marshal(body) + req = httptest.NewRequest("POST", "/login", bytes.NewBuffer(jsonBody)) + req.Header.Set("Content-Type", "application/json") + w = httptest.NewRecorder() + r.ServeHTTP(w, req) + assert.Equal(t, http.StatusUnauthorized, w.Code) +} + func TestAuthHandler_Register(t *testing.T) { handler, _ := setupAuthHandler(t) @@ -158,6 +184,23 @@ func TestAuthHandler_Me(t *testing.T) { assert.Equal(t, "me@example.com", resp["email"]) } +func TestAuthHandler_Me_NotFound(t *testing.T) { + handler, _ := setupAuthHandler(t) + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(func(c *gin.Context) { + c.Set("userID", uint(999)) // Non-existent ID + c.Next() + }) + r.GET("/me", handler.Me) + + req := httptest.NewRequest("GET", "/me", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusNotFound, w.Code) +} + func TestAuthHandler_ChangePassword(t *testing.T) { handler, db := setupAuthHandler(t) diff --git a/backend/internal/api/handlers/backup_handler_test.go b/backend/internal/api/handlers/backup_handler_test.go index e10f06a3..1fa38d13 100644 --- a/backend/internal/api/handlers/backup_handler_test.go +++ b/backend/internal/api/handlers/backup_handler_test.go @@ -145,3 +145,45 @@ func TestBackupLifecycle(t *testing.T) { router.ServeHTTP(resp, req) require.Equal(t, http.StatusNotFound, resp.Code) } + +func TestBackupHandler_Errors(t *testing.T) { + router, svc, tmpDir := setupBackupTest(t) + defer os.RemoveAll(tmpDir) + + // 1. List Error (remove backup dir to cause ReadDir error) + os.RemoveAll(svc.BackupDir) + // Create a file with same name to cause ReadDir to fail (if it expects dir) + // Or just make it unreadable + // os.Chmod(svc.BackupDir, 0000) // Might not work as expected in all envs + // Simpler: if BackupDir doesn't exist, ListBackups returns error? + // os.ReadDir returns error if dir doesn't exist. + + req := httptest.NewRequest(http.MethodGet, "/api/v1/backups", nil) + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + require.Equal(t, http.StatusInternalServerError, resp.Code) + + // 2. Create Error (make backup dir read-only or non-existent) + // If we removed it above, CreateBackup might try to create it? + // NewBackupService creates it. CreateBackup uses it. + // If we create a file named "backups" where the dir should be, MkdirAll might fail? + // Or just make the parent dir read-only. + + // Let's try path traversal for Download/Delete/Restore to cover those errors + + // 3. Create Error (make backup dir read-only) + // We can't easily make the dir read-only for the service without affecting other tests or requiring root. + // But we can mock the service or use a different config. + // If we set BackupDir to a non-existent dir that cannot be created? + // NewBackupService creates it. + // If we set BackupDir to a file? + + // Let's skip Create error for now and focus on what we can test. + // We can test Download Not Found (already covered). + + // 4. Delete Error (Not Found) + req = httptest.NewRequest(http.MethodDelete, "/api/v1/backups/missing.zip", nil) + resp = httptest.NewRecorder() + router.ServeHTTP(resp, req) + require.Equal(t, http.StatusNotFound, resp.Code) +} diff --git a/backend/internal/api/handlers/notification_handler_test.go b/backend/internal/api/handlers/notification_handler_test.go index ade0afbc..7981c283 100644 --- a/backend/internal/api/handlers/notification_handler_test.go +++ b/backend/internal/api/handlers/notification_handler_test.go @@ -109,6 +109,25 @@ func TestNotificationHandler_MarkAllAsRead(t *testing.T) { assert.Equal(t, int64(0), count) } +func TestNotificationHandler_MarkAllAsRead_Error(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupNotificationTestDB() + service := services.NewNotificationService(db) + handler := handlers.NewNotificationHandler(service) + + r := gin.New() + r.POST("/notifications/read-all", handler.MarkAllAsRead) + + // Close DB to force error + sqlDB, _ := db.DB() + sqlDB.Close() + + req, _ := http.NewRequest("POST", "/notifications/read-all", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + assert.Equal(t, http.StatusInternalServerError, w.Code) +} + func TestNotificationHandler_DBError(t *testing.T) { gin.SetMode(gin.TestMode) db := setupNotificationTestDB() diff --git a/backend/internal/api/handlers/proxy_host_handler_test.go b/backend/internal/api/handlers/proxy_host_handler_test.go index 329ba3dc..18d872b8 100644 --- a/backend/internal/api/handlers/proxy_host_handler_test.go +++ b/backend/internal/api/handlers/proxy_host_handler_test.go @@ -267,6 +267,19 @@ func TestProxyHostConnection(t *testing.T) { require.Equal(t, http.StatusOK, resp.Code) } +func TestProxyHostHandler_List_Error(t *testing.T) { + router, db := setupTestRouter(t) + + // Close DB to force error + sqlDB, _ := db.DB() + sqlDB.Close() + + req := httptest.NewRequest(http.MethodGet, "/api/v1/proxy-hosts", nil) + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + require.Equal(t, http.StatusInternalServerError, resp.Code) +} + func TestProxyHostWithCaddyIntegration(t *testing.T) { // Mock Caddy Admin API caddyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/backend/internal/caddy/manager_test.go b/backend/internal/caddy/manager_test.go index 6ce61bd6..7a1f9099 100644 --- a/backend/internal/caddy/manager_test.go +++ b/backend/internal/caddy/manager_test.go @@ -40,7 +40,7 @@ func TestManager_ApplyConfig(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.ProxyHost{}, &models.Setting{}, &models.CaddyConfig{})) + require.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.Setting{}, &models.CaddyConfig{}, &models.SSLCertificate{})) // Setup Manager tmpDir := t.TempDir() @@ -77,7 +77,7 @@ func TestManager_ApplyConfig_Failure(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.ProxyHost{}, &models.Setting{}, &models.CaddyConfig{})) + require.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.Setting{}, &models.CaddyConfig{}, &models.SSLCertificate{})) // Setup Manager tmpDir := t.TempDir() @@ -158,7 +158,7 @@ func TestManager_RotateSnapshots(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.ProxyHost{}, &models.Setting{}, &models.CaddyConfig{})) + require.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.Setting{}, &models.CaddyConfig{}, &models.SSLCertificate{})) client := NewClient(caddyServer.URL) manager := NewManager(client, db, tmpDir, "") @@ -212,7 +212,7 @@ func TestManager_Rollback_Success(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.ProxyHost{}, &models.Setting{}, &models.CaddyConfig{})) + require.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.Setting{}, &models.CaddyConfig{}, &models.SSLCertificate{})) // Setup Manager tmpDir := t.TempDir() diff --git a/backend/internal/services/auth_service_test.go b/backend/internal/services/auth_service_test.go index bcccd01a..40c2647c 100644 --- a/backend/internal/services/auth_service_test.go +++ b/backend/internal/services/auth_service_test.go @@ -129,3 +129,23 @@ func TestAuthService_ValidateToken(t *testing.T) { _, err = service.ValidateToken("invalid.token.string") assert.Error(t, err) } + +func TestAuthService_GetUserByID(t *testing.T) { + db := setupAuthTestDB(t) + cfg := config.Config{JWTSecret: "test-secret"} + service := NewAuthService(db, cfg) + + // Setup user + user, err := service.Register("test@example.com", "password123", "Test User") + require.NoError(t, err) + + // Test 1: Get existing user + foundUser, err := service.GetUserByID(user.ID) + require.NoError(t, err) + assert.Equal(t, user.ID, foundUser.ID) + assert.Equal(t, user.Email, foundUser.Email) + + // Test 2: Get non-existent user + _, err = service.GetUserByID(999) + assert.Error(t, err) +} diff --git a/backend/internal/services/backup_service.go b/backend/internal/services/backup_service.go index af38acb4..aab07d68 100644 --- a/backend/internal/services/backup_service.go +++ b/backend/internal/services/backup_service.go @@ -40,14 +40,7 @@ func NewBackupService(cfg *config.Config) *BackupService { } // Schedule daily backup at 3 AM - _, err := s.Cron.AddFunc("0 3 * * *", func() { - fmt.Println("Starting scheduled backup...") - if name, err := s.CreateBackup(); err != nil { - fmt.Printf("Scheduled backup failed: %v\n", err) - } else { - fmt.Printf("Scheduled backup created: %s\n", name) - } - }) + _, err := s.Cron.AddFunc("0 3 * * *", s.RunScheduledBackup) if err != nil { fmt.Printf("Failed to schedule backup: %v\n", err) } @@ -56,6 +49,15 @@ func NewBackupService(cfg *config.Config) *BackupService { return s } +func (s *BackupService) RunScheduledBackup() { + fmt.Println("Starting scheduled backup...") + if name, err := s.CreateBackup(); err != nil { + fmt.Printf("Scheduled backup failed: %v\n", err) + } else { + fmt.Printf("Scheduled backup created: %s\n", name) + } +} + // ListBackups returns all backup files sorted by time (newest first) func (s *BackupService) ListBackups() ([]BackupFile, error) { entries, err := os.ReadDir(s.BackupDir) diff --git a/backend/internal/services/backup_service_test.go b/backend/internal/services/backup_service_test.go index 72e96750..8fedfe9f 100644 --- a/backend/internal/services/backup_service_test.go +++ b/backend/internal/services/backup_service_test.go @@ -125,3 +125,25 @@ func TestBackupService_PathTraversal(t *testing.T) { assert.Error(t, err) assert.Contains(t, err.Error(), "invalid filename") } + +func TestBackupService_RunScheduledBackup(t *testing.T) { + // Setup temp dirs + tmpDir := t.TempDir() + dataDir := filepath.Join(tmpDir, "data") + os.MkdirAll(dataDir, 0755) + + // Create dummy DB + dbPath := filepath.Join(dataDir, "cpm.db") + os.WriteFile(dbPath, []byte("dummy db"), 0644) + + cfg := &config.Config{DatabasePath: dbPath} + service := NewBackupService(cfg) + + // Run scheduled backup manually + service.RunScheduledBackup() + + // Verify backup created + backups, err := service.ListBackups() + require.NoError(t, err) + assert.Len(t, backups, 1) +} diff --git a/backend/internal/services/certificate_service.go b/backend/internal/services/certificate_service.go index e7c3cb82..edb74aed 100644 --- a/backend/internal/services/certificate_service.go +++ b/backend/internal/services/certificate_service.go @@ -4,6 +4,7 @@ import ( "crypto/x509" "encoding/pem" "fmt" + "log" "os" "path/filepath" "strings" @@ -45,7 +46,7 @@ func NewCertificateService(dataDir string, db *gorm.DB) *CertificateService { func (s *CertificateService) ListCertificates() ([]CertificateInfo, error) { // First, scan Caddy data directory for auto-generated certificates and persist them. certRoot := filepath.Join(s.dataDir, "certificates") - fmt.Printf("CertificateService: scanning cert directory: %s\n", certRoot) + log.Printf("CertificateService: scanning cert directory: %s\n", certRoot) foundDomains := map[string]struct{}{} @@ -53,27 +54,27 @@ func (s *CertificateService) ListCertificates() ([]CertificateInfo, error) { if _, err := os.Stat(certRoot); err == nil { _ = filepath.Walk(certRoot, func(path string, info os.FileInfo, err error) error { if err != nil { - fmt.Printf("CertificateService: walk error for %s: %v\n", path, err) + log.Printf("CertificateService: walk error for %s: %v\n", path, err) return nil } if !info.IsDir() && strings.HasSuffix(info.Name(), ".crt") { - fmt.Printf("CertificateService: found cert file: %s\n", path) + log.Printf("CertificateService: found cert file: %s\n", path) certData, err := os.ReadFile(path) if err != nil { - fmt.Printf("CertificateService: failed to read cert file %s: %v\n", path, err) + log.Printf("CertificateService: failed to read cert file %s: %v\n", path, err) return nil } block, _ := pem.Decode(certData) if block == nil { - fmt.Printf("CertificateService: pem decode failed for %s\n", path) + log.Printf("CertificateService: pem decode failed for %s\n", path) return nil } cert, err := x509.ParseCertificate(block.Bytes) if err != nil { - fmt.Printf("CertificateService: failed to parse cert %s: %v\n", path, err) + log.Printf("CertificateService: failed to parse cert %s: %v\n", path, err) return nil } @@ -110,10 +111,10 @@ func (s *CertificateService) ListCertificates() ([]CertificateInfo, error) { UpdatedAt: now, } if err := s.db.Create(&newCert).Error; err != nil { - fmt.Printf("CertificateService: failed to create DB cert for %s: %v\n", domain, err) + log.Printf("CertificateService: failed to create DB cert for %s: %v\n", domain, err) } } else { - fmt.Printf("CertificateService: db error querying cert %s: %v\n", domain, res.Error) + log.Printf("CertificateService: db error querying cert %s: %v\n", domain, res.Error) } } else { // Update expiry/certificate content if changed @@ -126,12 +127,12 @@ func (s *CertificateService) ListCertificates() ([]CertificateInfo, error) { if updated { existing.UpdatedAt = time.Now() if err := s.db.Save(&existing).Error; err != nil { - fmt.Printf("CertificateService: failed to update DB cert for %s: %v\n", domain, err) + log.Printf("CertificateService: failed to update DB cert for %s: %v\n", domain, err) } } else { // still update ExpiresAt if needed if err := s.db.Model(&existing).Update("expires_at", &expiresAt).Error; err != nil { - fmt.Printf("CertificateService: failed to update expiry for %s: %v\n", domain, err) + log.Printf("CertificateService: failed to update expiry for %s: %v\n", domain, err) } } } @@ -140,9 +141,9 @@ func (s *CertificateService) ListCertificates() ([]CertificateInfo, error) { }) } else { if os.IsNotExist(err) { - fmt.Printf("CertificateService: cert directory does not exist: %s\n", certRoot) + log.Printf("CertificateService: cert directory does not exist: %s\n", certRoot) } else { - fmt.Printf("CertificateService: failed to stat cert directory: %v\n", err) + log.Printf("CertificateService: failed to stat cert directory: %v\n", err) } } @@ -153,9 +154,9 @@ func (s *CertificateService) ListCertificates() ([]CertificateInfo, error) { if _, ok := foundDomains[c.Domains]; !ok { // remove stale record if err := s.db.Delete(&models.SSLCertificate{}, "id = ?", c.ID).Error; err != nil { - fmt.Printf("CertificateService: failed to delete stale cert %s: %v\n", c.Domains, err) + log.Printf("CertificateService: failed to delete stale cert %s: %v\n", c.Domains, err) } else { - fmt.Printf("CertificateService: removed stale DB cert for %s\n", c.Domains) + log.Printf("CertificateService: removed stale DB cert for %s\n", c.Domains) } } } @@ -236,7 +237,39 @@ func (s *CertificateService) UploadCertificate(name, certPEM, keyPEM string) (*m return sslCert, nil } -// DeleteCertificate removes a custom certificate. +// DeleteCertificate removes a certificate. func (s *CertificateService) DeleteCertificate(id uint) error { + var cert models.SSLCertificate + if err := s.db.First(&cert, id).Error; err != nil { + return err + } + + if cert.Provider == "letsencrypt" { + // Best-effort file deletion + certRoot := filepath.Join(s.dataDir, "certificates") + _ = filepath.Walk(certRoot, func(path string, info os.FileInfo, err error) error { + if err == nil && !info.IsDir() && strings.HasSuffix(info.Name(), ".crt") { + if info.Name() == cert.Domains+".crt" { + // Found it + log.Printf("CertificateService: deleting ACME cert file %s", path) + if err := os.Remove(path); err != nil { + log.Printf("CertificateService: failed to delete cert file: %v", err) + } + // Try to delete key as well + keyPath := strings.TrimSuffix(path, ".crt") + ".key" + if _, err := os.Stat(keyPath); err == nil { + os.Remove(keyPath) + } + // Also try to delete the json meta file + jsonPath := strings.TrimSuffix(path, ".crt") + ".json" + if _, err := os.Stat(jsonPath); err == nil { + os.Remove(jsonPath) + } + } + } + return nil + }) + } + return s.db.Delete(&models.SSLCertificate{}, "id = ?", id).Error } diff --git a/backend/internal/services/certificate_service_test.go b/backend/internal/services/certificate_service_test.go index f31a4b6c..9118ad00 100644 --- a/backend/internal/services/certificate_service_test.go +++ b/backend/internal/services/certificate_service_test.go @@ -175,3 +175,75 @@ func TestCertificateService_UploadAndDelete(t *testing.T) { } assert.False(t, found) } + +func TestCertificateService_Persistence(t *testing.T) { + // Setup + tmpDir := t.TempDir() + db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{})) + + cs := NewCertificateService(tmpDir, db) + + // 1. Create a fake ACME cert file + domain := "persist.example.com" + expiry := time.Now().Add(24 * time.Hour) + certPEM := generateTestCert(t, domain, expiry) + + certDir := filepath.Join(tmpDir, "certificates", "acme-v02.api.letsencrypt.org-directory", domain) + err = os.MkdirAll(certDir, 0755) + require.NoError(t, err) + + certPath := filepath.Join(certDir, domain+".crt") + err = os.WriteFile(certPath, certPEM, 0644) + require.NoError(t, err) + + // 2. Call ListCertificates to trigger scan and persistence + certs, err := cs.ListCertificates() + require.NoError(t, err) + + // Verify it's in the returned list + var foundInList bool + for _, c := range certs { + if c.Domain == domain { + foundInList = true + assert.Equal(t, "letsencrypt", c.Provider) + break + } + } + assert.True(t, foundInList, "Certificate should be in the returned list") + + // 3. Verify it's in the DB + var dbCert models.SSLCertificate + err = db.Where("domains = ? AND provider = ?", domain, "letsencrypt").First(&dbCert).Error + assert.NoError(t, err, "Certificate should be persisted to DB") + assert.Equal(t, domain, dbCert.Name) + assert.Equal(t, string(certPEM), dbCert.Certificate) + + // 4. Delete the certificate via Service (which should delete the file) + err = cs.DeleteCertificate(dbCert.ID) + require.NoError(t, err) + + // Verify file is gone + _, err = os.Stat(certPath) + assert.True(t, os.IsNotExist(err), "Cert file should be deleted") + + // 5. Call ListCertificates again to trigger cleanup (though DB row is already gone) + certs, err = cs.ListCertificates() + require.NoError(t, err) + + // Verify it's NOT in the returned list + foundInList = false + for _, c := range certs { + if c.Domain == domain { + foundInList = true + break + } + } + assert.False(t, foundInList, "Certificate should NOT be in the returned list after deletion") + + // 6. Verify it's gone from the DB + err = db.Where("domains = ? AND provider = ?", domain, "letsencrypt").First(&dbCert).Error + assert.Error(t, err, "Certificate should be removed from DB") + assert.Equal(t, gorm.ErrRecordNotFound, err) +} diff --git a/frontend/src/api/__tests__/certificates.test.ts b/frontend/src/api/__tests__/certificates.test.ts new file mode 100644 index 00000000..95ea26af --- /dev/null +++ b/frontend/src/api/__tests__/certificates.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import client from '../client'; +import { getCertificates, uploadCertificate, deleteCertificate, Certificate } from '../certificates'; + +vi.mock('../client', () => ({ + default: { + get: vi.fn(), + post: vi.fn(), + delete: vi.fn(), + }, +})); + +describe('certificates API', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + const mockCert: Certificate = { + id: 1, + domain: 'example.com', + issuer: 'Let\'s Encrypt', + expires_at: '2023-01-01', + status: 'valid', + provider: 'letsencrypt', + }; + + it('getCertificates calls client.get', async () => { + vi.mocked(client.get).mockResolvedValue({ data: [mockCert] }); + const result = await getCertificates(); + expect(client.get).toHaveBeenCalledWith('/certificates'); + expect(result).toEqual([mockCert]); + }); + + it('uploadCertificate calls client.post with FormData', async () => { + vi.mocked(client.post).mockResolvedValue({ data: mockCert }); + const certFile = new File(['cert'], 'cert.pem', { type: 'text/plain' }); + const keyFile = new File(['key'], 'key.pem', { type: 'text/plain' }); + + const result = await uploadCertificate('My Cert', certFile, keyFile); + + expect(client.post).toHaveBeenCalledWith('/certificates', expect.any(FormData), { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + expect(result).toEqual(mockCert); + }); + + it('deleteCertificate calls client.delete', async () => { + vi.mocked(client.delete).mockResolvedValue({ data: {} }); + await deleteCertificate(1); + expect(client.delete).toHaveBeenCalledWith('/certificates/1'); + }); +}); diff --git a/frontend/src/api/__tests__/domains.test.ts b/frontend/src/api/__tests__/domains.test.ts new file mode 100644 index 00000000..3181876e --- /dev/null +++ b/frontend/src/api/__tests__/domains.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import client from '../client'; +import { getDomains, createDomain, deleteDomain, Domain } from '../domains'; + +vi.mock('../client', () => ({ + default: { + get: vi.fn(), + post: vi.fn(), + delete: vi.fn(), + }, +})); + +describe('domains API', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + const mockDomain: Domain = { + id: 1, + uuid: '123', + name: 'example.com', + created_at: '2023-01-01', + }; + + it('getDomains calls client.get', async () => { + vi.mocked(client.get).mockResolvedValue({ data: [mockDomain] }); + const result = await getDomains(); + expect(client.get).toHaveBeenCalledWith('/domains'); + expect(result).toEqual([mockDomain]); + }); + + it('createDomain calls client.post', async () => { + vi.mocked(client.post).mockResolvedValue({ data: mockDomain }); + const result = await createDomain('example.com'); + expect(client.post).toHaveBeenCalledWith('/domains', { name: 'example.com' }); + expect(result).toEqual(mockDomain); + }); + + it('deleteDomain calls client.delete', async () => { + vi.mocked(client.delete).mockResolvedValue({ data: {} }); + await deleteDomain('123'); + expect(client.delete).toHaveBeenCalledWith('/domains/123'); + }); +}); diff --git a/frontend/src/api/__tests__/proxyHosts.test.ts b/frontend/src/api/__tests__/proxyHosts.test.ts new file mode 100644 index 00000000..88ba9ed3 --- /dev/null +++ b/frontend/src/api/__tests__/proxyHosts.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import client from '../client'; +import { + getProxyHosts, + getProxyHost, + createProxyHost, + updateProxyHost, + deleteProxyHost, + testProxyHostConnection, + ProxyHost +} from '../proxyHosts'; + +vi.mock('../client', () => ({ + default: { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + delete: vi.fn(), + }, +})); + +describe('proxyHosts API', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + const mockHost: ProxyHost = { + uuid: '123', + domain_names: 'example.com', + forward_scheme: 'http', + forward_host: 'localhost', + forward_port: 8080, + ssl_forced: true, + http2_support: true, + hsts_enabled: true, + hsts_subdomains: false, + block_exploits: false, + websocket_support: false, + locations: [], + enabled: true, + created_at: '2023-01-01', + updated_at: '2023-01-01', + }; + + it('getProxyHosts calls client.get', async () => { + vi.mocked(client.get).mockResolvedValue({ data: [mockHost] }); + const result = await getProxyHosts(); + expect(client.get).toHaveBeenCalledWith('/proxy-hosts'); + expect(result).toEqual([mockHost]); + }); + + it('getProxyHost calls client.get with uuid', async () => { + vi.mocked(client.get).mockResolvedValue({ data: mockHost }); + const result = await getProxyHost('123'); + expect(client.get).toHaveBeenCalledWith('/proxy-hosts/123'); + expect(result).toEqual(mockHost); + }); + + it('createProxyHost calls client.post', async () => { + vi.mocked(client.post).mockResolvedValue({ data: mockHost }); + const newHost = { domain_names: 'example.com' }; + const result = await createProxyHost(newHost); + expect(client.post).toHaveBeenCalledWith('/proxy-hosts', newHost); + expect(result).toEqual(mockHost); + }); + + it('updateProxyHost calls client.put', async () => { + vi.mocked(client.put).mockResolvedValue({ data: mockHost }); + const updates = { enabled: false }; + const result = await updateProxyHost('123', updates); + expect(client.put).toHaveBeenCalledWith('/proxy-hosts/123', updates); + expect(result).toEqual(mockHost); + }); + + it('deleteProxyHost calls client.delete', async () => { + vi.mocked(client.delete).mockResolvedValue({ data: {} }); + await deleteProxyHost('123'); + expect(client.delete).toHaveBeenCalledWith('/proxy-hosts/123'); + }); + + it('testProxyHostConnection calls client.post', async () => { + vi.mocked(client.post).mockResolvedValue({ data: {} }); + await testProxyHostConnection('localhost', 8080); + expect(client.post).toHaveBeenCalledWith('/proxy-hosts/test', { + forward_host: 'localhost', + forward_port: 8080, + }); + }); +}); diff --git a/frontend/src/components/ProxyHostForm.tsx b/frontend/src/components/ProxyHostForm.tsx index 5172f926..5ce39c74 100644 --- a/frontend/src/components/ProxyHostForm.tsx +++ b/frontend/src/components/ProxyHostForm.tsx @@ -304,10 +304,11 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor )}