# Phase 3: Backend Routes Implementation Plan > **Phase**: 3 of Skipped Tests Remediation > **Status**: ✅ COMPLETE > **Created**: 2026-01-22 > **Completed**: 2026-01-22 > **Target Tests**: 7 tests to re-enable > **Actual Result**: 7 tests enabled and passing --- ## Executive Summary Phase 3 addresses missing backend API routes and a data persistence issue that block 7 E2E tests: 1. **NPM Import Route** (`/tasks/import/npm`) - 4 skipped tests 2. **JSON Import Route** (`/tasks/import/json`) - 2 skipped tests 3. **SMTP Persistence Bug** - 1 skipped test at `smtp-settings.spec.ts:336` The existing Caddyfile import infrastructure provides a solid foundation. NPM and JSON import routes will extend this pattern with format-specific parsers. --- ## Root Cause Analysis ### Issue 1: Missing NPM Import Route **Location**: Tests at [tests/integration/import-to-production.spec.ts](../../tests/integration/import-to-production.spec.ts#L170-L237) **Problem**: The tests navigate to `/tasks/import/npm` but this route doesn't exist in the frontend router or backend API. **Evidence**: ```typescript // From import-to-production.spec.ts lines 170-180 test.skip('should display NPM import page', async ({ page, adminUser }) => { await page.goto('/tasks/import/npm'); // Route doesn't exist ... }); ``` **Expected NPM Export Format** (from test file): ```json { "proxy_hosts": [ { "domain_names": ["test.example.com"], "forward_host": "192.168.1.100", "forward_port": 80 } ], "access_lists": [], "certificates": [] } ``` ### Issue 2: Missing JSON Import Route **Location**: Tests at [tests/integration/import-to-production.spec.ts](../../tests/integration/import-to-production.spec.ts#L243-L256) **Problem**: The `/tasks/import/json` route is not implemented. Tests navigate to this route expecting a generic JSON configuration import interface. ### Issue 3: SMTP Save Not Persisting **Location**: Test at [tests/settings/smtp-settings.spec.ts](../../tests/settings/smtp-settings.spec.ts#L336) **Problem**: After saving SMTP configuration and reloading the page, the updated values don't persist. **Skip Comment**: ```typescript // Note: Skip - SMTP save not persisting correctly (backend issue, not test issue) ``` **Analysis of Code Flow**: 1. **Frontend**: [SMTPSettings.tsx](../../frontend/src/pages/SMTPSettings.tsx#L50-L62) - Calls `updateSMTPConfig()` which POSTs to `/settings/smtp` - On success, invalidates query and shows toast 2. **Backend Handler**: [settings_handler.go](../../backend/internal/api/handlers/settings_handler.go#L109-L136) - `UpdateSMTPConfig()` receives the request - Calls `h.MailService.SaveSMTPConfig(config)` 3. **Mail Service**: [mail_service.go](../../backend/internal/services/mail_service.go#L117-L144) - `SaveSMTPConfig()` uses upsert pattern - **POTENTIAL BUG**: Uses `First()` then conditional `Create()`/`Updates()` separately **Root Cause Hypothesis**: The `SaveSMTPConfig` method has a problematic upsert pattern: ```go // Current pattern in mail_service.go lines 127-143: result := s.db.Where("key = ?", key).First(&models.Setting{}) if result.Error == gorm.ErrRecordNotFound { s.db.Create(&setting) // Creates new } else { s.db.Model(&models.Setting{}).Where("key = ?", key).Updates(...) // Updates existing } ``` **Issues identified**: 1. No transaction wrapping - partial failures possible 2. `Updates()` with map may not update all fields correctly 3. If `First()` returns error other than `ErrRecordNotFound`, the else branch runs but may not execute correctly 4. Race condition between read and write operations --- ## Implementation Plan ### Task 1: Implement NPM Import Backend Handler **File**: `backend/internal/api/handlers/npm_import_handler.go` (NEW) #### 1.1 Create NPM Parser Model ```go // NPMExport represents the Nginx Proxy Manager export format type NPMExport struct { ProxyHosts []NPMProxyHost `json:"proxy_hosts"` AccessLists []NPMAccessList `json:"access_lists"` Certificates []NPMCertificate `json:"certificates"` } type NPMProxyHost struct { DomainNames []string `json:"domain_names"` ForwardScheme string `json:"forward_scheme"` ForwardHost string `json:"forward_host"` ForwardPort int `json:"forward_port"` CachingEnabled bool `json:"caching_enabled"` BlockExploits bool `json:"block_exploits"` AllowWebsocket bool `json:"allow_websocket_upgrade"` HTTP2Support bool `json:"http2_support"` HSTSEnabled bool `json:"hsts_enabled"` HSTSSubdomains bool `json:"hsts_subdomains"` SSLForced bool `json:"ssl_forced"` Enabled bool `json:"enabled"` } type NPMAccessList struct { Name string `json:"name"` Items []NPMAccessItem `json:"items"` } type NPMAccessItem struct { Type string `json:"type"` // "allow" or "deny" Address string `json:"address"` } type NPMCertificate struct { NiceName string `json:"nice_name"` DomainNames []string `json:"domain_names"` Provider string `json:"provider"` } ``` #### 1.2 Create NPM Import Handler **File**: `backend/internal/api/handlers/npm_import_handler.go` ```go package handlers import ( "encoding/json" "net/http" "strings" "github.com/gin-gonic/gin" "github.com/google/uuid" "gorm.io/gorm" "github.com/Wikid82/charon/backend/internal/caddy" "github.com/Wikid82/charon/backend/internal/models" "github.com/Wikid82/charon/backend/internal/services" ) type NPMImportHandler struct { db *gorm.DB proxyHostSvc *services.ProxyHostService } func NewNPMImportHandler(db *gorm.DB) *NPMImportHandler { return &NPMImportHandler{ db: db, proxyHostSvc: services.NewProxyHostService(db), } } func (h *NPMImportHandler) RegisterRoutes(router *gin.RouterGroup) { router.POST("/import/npm/upload", h.Upload) router.POST("/import/npm/commit", h.Commit) } // Upload handles NPM export JSON upload and returns preview func (h *NPMImportHandler) Upload(c *gin.Context) { var req struct { Content string `json:"content" binding:"required"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // Parse NPM export JSON var npmExport NPMExport if err := json.Unmarshal([]byte(req.Content), &npmExport); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid NPM export JSON"}) return } // Convert to internal format result := h.convertNPMToImportResult(npmExport) // Check for conflicts with existing hosts existingHosts, _ := h.proxyHostSvc.List() existingDomainsMap := make(map[string]models.ProxyHost) for _, eh := range existingHosts { existingDomainsMap[eh.DomainNames] = eh } conflictDetails := make(map[string]gin.H) for _, ph := range result.Hosts { if existing, found := existingDomainsMap[ph.DomainNames]; found { result.Conflicts = append(result.Conflicts, ph.DomainNames) conflictDetails[ph.DomainNames] = gin.H{ "existing": gin.H{ "forward_scheme": existing.ForwardScheme, "forward_host": existing.ForwardHost, "forward_port": existing.ForwardPort, }, "imported": gin.H{ "forward_scheme": ph.ForwardScheme, "forward_host": ph.ForwardHost, "forward_port": ph.ForwardPort, }, } } } sid := uuid.NewString() c.JSON(http.StatusOK, gin.H{ "session": gin.H{"id": sid, "state": "transient", "source": "npm"}, "conflict_details": conflictDetails, "preview": result, }) } func (h *NPMImportHandler) convertNPMToImportResult(export NPMExport) *caddy.ImportResult { result := &caddy.ImportResult{ Hosts: []caddy.ParsedHost{}, Conflicts: []string{}, Errors: []string{}, } for _, proxy := range export.ProxyHosts { // Join domain names with comma for storage domains := strings.Join(proxy.DomainNames, ", ") host := caddy.ParsedHost{ DomainNames: domains, ForwardScheme: proxy.ForwardScheme, ForwardHost: proxy.ForwardHost, ForwardPort: proxy.ForwardPort, SSLForced: proxy.SSLForced, WebsocketSupport: proxy.AllowWebsocket, } if host.ForwardScheme == "" { host.ForwardScheme = "http" } if host.ForwardPort == 0 { host.ForwardPort = 80 } result.Hosts = append(result.Hosts, host) } return result } ``` #### 1.3 Register NPM Import Routes **File**: `backend/internal/api/routes/routes.go` Add to the `Register` function: ```go // NPM Import Handler npmImportHandler := handlers.NewNPMImportHandler(db) npmImportHandler.RegisterRoutes(api) ``` ### Task 2: Implement JSON Import Backend Handler **File**: `backend/internal/api/handlers/json_import_handler.go` (NEW) The JSON import handler will accept a generic Charon export format: ```go package handlers // CharonExport represents a generic Charon configuration export type CharonExport struct { Version string `json:"version"` ExportedAt string `json:"exported_at"` ProxyHosts []CharonProxyHost `json:"proxy_hosts"` AccessLists []CharonAccessList `json:"access_lists"` DNSRecords []CharonDNSRecord `json:"dns_records"` } type JSONImportHandler struct { db *gorm.DB proxyHostSvc *services.ProxyHostService } func NewJSONImportHandler(db *gorm.DB) *JSONImportHandler { return &JSONImportHandler{ db: db, proxyHostSvc: services.NewProxyHostService(db), } } func (h *JSONImportHandler) RegisterRoutes(router *gin.RouterGroup) { router.POST("/import/json/upload", h.Upload) router.POST("/import/json/commit", h.Commit) } // Upload validates and previews JSON import func (h *JSONImportHandler) Upload(c *gin.Context) { var req struct { Content string `json:"content" binding:"required"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // Try to parse as Charon export format var charonExport CharonExport if err := json.Unmarshal([]byte(req.Content), &charonExport); err != nil { // Fallback: try NPM format var npmExport NPMExport if err := json.Unmarshal([]byte(req.Content), &npmExport); err != nil { c.JSON(http.StatusBadRequest, gin.H{ "error": "Invalid JSON format. Expected Charon or NPM export format.", }) return } // Convert NPM to import result // ... (similar to NPM handler) } // Convert Charon export to import result result := h.convertCharonToImportResult(charonExport) // ... (conflict checking and response) } ``` ### Task 3: Implement Frontend Routes #### 3.1 Create ImportNPM Page **File**: `frontend/src/pages/ImportNPM.tsx` (NEW) ```tsx import { useState } from 'react' import { useNavigate } from 'react-router-dom' import { useTranslation } from 'react-i18next' import { useNPMImport } from '../hooks/useNPMImport' import ImportReviewTable from '../components/ImportReviewTable' import ImportSuccessModal from '../components/dialogs/ImportSuccessModal' export default function ImportNPM() { const { t } = useTranslation() const navigate = useNavigate() const { preview, loading, error, upload, commit, commitResult, clearCommitResult } = useNPMImport() const [content, setContent] = useState('') const [showReview, setShowReview] = useState(false) const [showSuccessModal, setShowSuccessModal] = useState(false) const handleUpload = async () => { if (!content.trim()) { alert(t('importNPM.enterContent')) return } // Validate JSON try { JSON.parse(content) } catch { alert(t('importNPM.invalidJSON')) return } try { await upload(content) setShowReview(true) } catch { // Error handled by hook } } // ... (rest follows ImportCaddy pattern) return (