chore: implement NPM/JSON import routes and fix SMTP persistence

Phase 3 of skipped tests remediation - enables 7 previously skipped E2E tests

Backend:

Add NPM import handler with session-based upload/commit/cancel
Add JSON import handler with Charon/NPM format support
Fix SMTP SaveSMTPConfig using transaction-based upsert
Add comprehensive unit tests for new handlers
Frontend:

Add ImportNPM page component following ImportCaddy pattern
Add ImportJSON page component with format detection
Add useNPMImport and useJSONImport React Query hooks
Add API clients for npm/json import endpoints
Register routes in App.tsx and navigation in Layout.tsx
Add i18n keys for new import pages
Tests:

7 E2E tests now enabled and passing
Backend coverage: 86.8%
Reduced total skipped tests from 98 to 91
Closes: Phase 3 of skipped-tests-remediation plan
This commit is contained in:
GitHub Actions
2026-01-22 21:10:01 +00:00
parent b60e0be5fb
commit bc15e976b2
21 changed files with 3771 additions and 476 deletions
@@ -0,0 +1,516 @@
package handlers
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"sync"
"time"
"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"
)
// jsonImportSession stores the parsed content for a JSON import session.
type jsonImportSession struct {
SourceType string // "charon" or "npm"
CharonExport *CharonExport
NPMExport *NPMExport
}
// jsonImportSessions stores parsed exports keyed by session UUID.
// TODO: Implement session expiration to prevent memory leaks (e.g., TTL-based cleanup).
var (
jsonImportSessions = make(map[string]jsonImportSession)
jsonImportSessionsMu sync.RWMutex
)
// CharonExport represents the top-level structure of a Charon export file.
type CharonExport struct {
Version string `json:"version"`
ExportedAt time.Time `json:"exported_at"`
ProxyHosts []CharonProxyHost `json:"proxy_hosts"`
AccessLists []CharonAccessList `json:"access_lists"`
DNSRecords []CharonDNSRecord `json:"dns_records"`
}
// CharonProxyHost represents a proxy host in Charon export format.
type CharonProxyHost struct {
UUID string `json:"uuid"`
Name string `json:"name"`
DomainNames string `json:"domain_names"`
ForwardScheme string `json:"forward_scheme"`
ForwardHost string `json:"forward_host"`
ForwardPort int `json:"forward_port"`
SSLForced bool `json:"ssl_forced"`
HTTP2Support bool `json:"http2_support"`
HSTSEnabled bool `json:"hsts_enabled"`
HSTSSubdomains bool `json:"hsts_subdomains"`
BlockExploits bool `json:"block_exploits"`
WebsocketSupport bool `json:"websocket_support"`
Application string `json:"application"`
Enabled bool `json:"enabled"`
AdvancedConfig string `json:"advanced_config"`
WAFDisabled bool `json:"waf_disabled"`
UseDNSChallenge bool `json:"use_dns_challenge"`
}
// CharonAccessList represents an access list in Charon export format.
type CharonAccessList struct {
UUID string `json:"uuid"`
Name string `json:"name"`
Description string `json:"description"`
Type string `json:"type"`
IPRules string `json:"ip_rules"`
CountryCodes string `json:"country_codes"`
LocalNetworkOnly bool `json:"local_network_only"`
Enabled bool `json:"enabled"`
}
// CharonDNSRecord represents a DNS record in Charon export format.
type CharonDNSRecord struct {
UUID string `json:"uuid"`
Name string `json:"name"`
Type string `json:"type"`
Value string `json:"value"`
TTL int `json:"ttl"`
ProviderID uint `json:"provider_id"`
}
// JSONImportHandler handles JSON configuration imports (both Charon and NPM formats).
type JSONImportHandler struct {
db *gorm.DB
proxyHostSvc *services.ProxyHostService
}
// NewJSONImportHandler creates a new JSON import handler.
func NewJSONImportHandler(db *gorm.DB) *JSONImportHandler {
return &JSONImportHandler{
db: db,
proxyHostSvc: services.NewProxyHostService(db),
}
}
// RegisterRoutes registers JSON import routes.
func (h *JSONImportHandler) RegisterRoutes(router *gin.RouterGroup) {
router.POST("/import/json/upload", h.Upload)
router.POST("/import/json/commit", h.Commit)
router.POST("/import/json/cancel", h.Cancel)
}
// Upload parses a JSON export (Charon or NPM format) and returns a preview.
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 Charon format first
var charonExport CharonExport
if err := json.Unmarshal([]byte(req.Content), &charonExport); err == nil && h.isCharonFormat(charonExport) {
h.handleCharonUpload(c, charonExport)
return
}
// Fall back to NPM format
var npmExport NPMExport
if err := json.Unmarshal([]byte(req.Content), &npmExport); err == nil && len(npmExport.ProxyHosts) > 0 {
h.handleNPMUpload(c, npmExport)
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": "unrecognized JSON format - must be Charon or NPM export"})
}
// isCharonFormat checks if the export is in Charon format.
func (h *JSONImportHandler) isCharonFormat(export CharonExport) bool {
return export.Version != "" || len(export.ProxyHosts) > 0
}
// handleCharonUpload processes a Charon format export.
func (h *JSONImportHandler) handleCharonUpload(c *gin.Context, export CharonExport) {
result := h.convertCharonToImportResult(export)
if len(result.Hosts) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "no proxy hosts found in Charon export"})
return
}
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,
"ssl_forced": existing.SSLForced,
"websocket": existing.WebsocketSupport,
"enabled": existing.Enabled,
},
"imported": gin.H{
"forward_scheme": ph.ForwardScheme,
"forward_host": ph.ForwardHost,
"forward_port": ph.ForwardPort,
"ssl_forced": ph.SSLForced,
"websocket": ph.WebsocketSupport,
},
}
}
}
sid := uuid.NewString()
// Store the parsed export in session storage for later commit
jsonImportSessionsMu.Lock()
jsonImportSessions[sid] = jsonImportSession{
SourceType: "charon",
CharonExport: &export,
}
jsonImportSessionsMu.Unlock()
c.JSON(http.StatusOK, gin.H{
"session": gin.H{"id": sid, "state": "transient", "source_type": "charon"},
"preview": result,
"conflict_details": conflictDetails,
"charon_export": gin.H{
"version": export.Version,
"exported_at": export.ExportedAt,
"proxy_hosts": len(export.ProxyHosts),
"access_lists": len(export.AccessLists),
"dns_records": len(export.DNSRecords),
},
})
}
// handleNPMUpload processes an NPM format export.
func (h *JSONImportHandler) handleNPMUpload(c *gin.Context, export NPMExport) {
npmHandler := NewNPMImportHandler(h.db)
result := npmHandler.convertNPMToImportResult(export)
if len(result.Hosts) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "no proxy hosts found in NPM export"})
return
}
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,
"ssl_forced": existing.SSLForced,
"websocket": existing.WebsocketSupport,
"enabled": existing.Enabled,
},
"imported": gin.H{
"forward_scheme": ph.ForwardScheme,
"forward_host": ph.ForwardHost,
"forward_port": ph.ForwardPort,
"ssl_forced": ph.SSLForced,
"websocket": ph.WebsocketSupport,
},
}
}
}
sid := uuid.NewString()
// Store the parsed export in session storage for later commit
jsonImportSessionsMu.Lock()
jsonImportSessions[sid] = jsonImportSession{
SourceType: "npm",
NPMExport: &export,
}
jsonImportSessionsMu.Unlock()
c.JSON(http.StatusOK, gin.H{
"session": gin.H{"id": sid, "state": "transient", "source_type": "npm"},
"preview": result,
"conflict_details": conflictDetails,
"npm_export": gin.H{
"proxy_hosts": len(export.ProxyHosts),
"access_lists": len(export.AccessLists),
"certificates": len(export.Certificates),
},
})
}
// Commit finalizes the JSON import with user's conflict resolutions.
func (h *JSONImportHandler) Commit(c *gin.Context) {
var req struct {
SessionUUID string `json:"session_uuid" binding:"required"`
Resolutions map[string]string `json:"resolutions"`
Names map[string]string `json:"names"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Retrieve the stored session
jsonImportSessionsMu.RLock()
session, ok := jsonImportSessions[req.SessionUUID]
jsonImportSessionsMu.RUnlock()
if !ok {
c.JSON(http.StatusNotFound, gin.H{"error": "session not found or expired"})
return
}
// Route to the appropriate commit handler based on source type
if session.SourceType == "charon" && session.CharonExport != nil {
h.commitCharonImport(c, *session.CharonExport, req.Resolutions, req.Names, req.SessionUUID)
return
}
if session.SourceType == "npm" && session.NPMExport != nil {
h.commitNPMImport(c, *session.NPMExport, req.Resolutions, req.Names, req.SessionUUID)
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session state"})
}
// Cancel cancels a JSON import session and cleans up resources.
func (h *JSONImportHandler) Cancel(c *gin.Context) {
var req struct {
SessionUUID string `json:"session_uuid"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Clean up session if it exists
jsonImportSessionsMu.Lock()
delete(jsonImportSessions, req.SessionUUID)
jsonImportSessionsMu.Unlock()
c.JSON(http.StatusOK, gin.H{"status": "cancelled"})
}
// commitCharonImport commits a Charon format import.
func (h *JSONImportHandler) commitCharonImport(c *gin.Context, export CharonExport, resolutions, names map[string]string, sessionUUID string) {
result := h.convertCharonToImportResult(export)
proxyHosts := caddy.ConvertToProxyHosts(result.Hosts)
created := 0
updated := 0
skipped := 0
errors := []string{}
existingHosts, _ := h.proxyHostSvc.List()
existingMap := make(map[string]*models.ProxyHost)
for i := range existingHosts {
existingMap[existingHosts[i].DomainNames] = &existingHosts[i]
}
for _, host := range proxyHosts {
action := resolutions[host.DomainNames]
if customName, ok := names[host.DomainNames]; ok && customName != "" {
host.Name = customName
}
if action == "skip" || action == "keep" {
skipped++
continue
}
if action == "rename" {
host.DomainNames += "-imported"
}
if action == "overwrite" {
if existing, found := existingMap[host.DomainNames]; found {
host.ID = existing.ID
host.UUID = existing.UUID
host.CertificateID = existing.CertificateID
host.CreatedAt = existing.CreatedAt
if err := h.proxyHostSvc.Update(&host); err != nil {
errors = append(errors, fmt.Sprintf("%s: %s", host.DomainNames, err.Error()))
} else {
updated++
}
continue
}
}
host.UUID = uuid.NewString()
if err := h.proxyHostSvc.Create(&host); err != nil {
errors = append(errors, fmt.Sprintf("%s: %s", host.DomainNames, err.Error()))
} else {
created++
}
}
// Clean up session after successful commit
jsonImportSessionsMu.Lock()
delete(jsonImportSessions, sessionUUID)
jsonImportSessionsMu.Unlock()
c.JSON(http.StatusOK, gin.H{
"created": created,
"updated": updated,
"skipped": skipped,
"errors": errors,
})
}
// commitNPMImport commits an NPM format import.
func (h *JSONImportHandler) commitNPMImport(c *gin.Context, export NPMExport, resolutions, names map[string]string, sessionUUID string) {
npmHandler := NewNPMImportHandler(h.db)
result := npmHandler.convertNPMToImportResult(export)
proxyHosts := caddy.ConvertToProxyHosts(result.Hosts)
created := 0
updated := 0
skipped := 0
errors := []string{}
existingHosts, _ := h.proxyHostSvc.List()
existingMap := make(map[string]*models.ProxyHost)
for i := range existingHosts {
existingMap[existingHosts[i].DomainNames] = &existingHosts[i]
}
for _, host := range proxyHosts {
action := resolutions[host.DomainNames]
if customName, ok := names[host.DomainNames]; ok && customName != "" {
host.Name = customName
}
if action == "skip" || action == "keep" {
skipped++
continue
}
if action == "rename" {
host.DomainNames += "-imported"
}
if action == "overwrite" {
if existing, found := existingMap[host.DomainNames]; found {
host.ID = existing.ID
host.UUID = existing.UUID
host.CertificateID = existing.CertificateID
host.CreatedAt = existing.CreatedAt
if err := h.proxyHostSvc.Update(&host); err != nil {
errors = append(errors, fmt.Sprintf("%s: %s", host.DomainNames, err.Error()))
} else {
updated++
}
continue
}
}
host.UUID = uuid.NewString()
if err := h.proxyHostSvc.Create(&host); err != nil {
errors = append(errors, fmt.Sprintf("%s: %s", host.DomainNames, err.Error()))
} else {
created++
}
}
// Clean up session after successful commit
jsonImportSessionsMu.Lock()
delete(jsonImportSessions, sessionUUID)
jsonImportSessionsMu.Unlock()
c.JSON(http.StatusOK, gin.H{
"created": created,
"updated": updated,
"skipped": skipped,
"errors": errors,
})
}
// convertCharonToImportResult converts Charon export format to ImportResult.
func (h *JSONImportHandler) convertCharonToImportResult(export CharonExport) *caddy.ImportResult {
result := &caddy.ImportResult{
Hosts: []caddy.ParsedHost{},
Conflicts: []string{},
Errors: []string{},
}
for _, ch := range export.ProxyHosts {
if ch.DomainNames == "" {
result.Errors = append(result.Errors, fmt.Sprintf("host %s has no domain names", ch.UUID))
continue
}
scheme := ch.ForwardScheme
if scheme == "" {
scheme = "http"
}
port := ch.ForwardPort
if port == 0 {
port = 80
}
warnings := []string{}
if ch.AdvancedConfig != "" && !isValidJSON(ch.AdvancedConfig) {
warnings = append(warnings, "Advanced config may need review")
}
host := caddy.ParsedHost{
DomainNames: ch.DomainNames,
ForwardScheme: scheme,
ForwardHost: ch.ForwardHost,
ForwardPort: port,
SSLForced: ch.SSLForced,
WebsocketSupport: ch.WebsocketSupport,
Warnings: warnings,
}
rawJSON, _ := json.Marshal(ch)
host.RawJSON = string(rawJSON)
result.Hosts = append(result.Hosts, host)
}
return result
}
// isValidJSON checks if a string is valid JSON.
func isValidJSON(s string) bool {
s = strings.TrimSpace(s)
if s == "" {
return true
}
var js json.RawMessage
return json.Unmarshal([]byte(s), &js) == nil
}
@@ -0,0 +1,600 @@
package handlers
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"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/models"
)
func setupJSONTestDB(t *testing.T) *gorm.DB {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
err = db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.Setting{})
require.NoError(t, err)
return db
}
func TestNewJSONImportHandler(t *testing.T) {
db := setupJSONTestDB(t)
handler := NewJSONImportHandler(db)
assert.NotNil(t, handler)
assert.NotNil(t, handler.db)
assert.NotNil(t, handler.proxyHostSvc)
}
func TestJSONImportHandler_RegisterRoutes(t *testing.T) {
db := setupJSONTestDB(t)
handler := NewJSONImportHandler(db)
gin.SetMode(gin.TestMode)
router := gin.New()
api := router.Group("/api/v1")
handler.RegisterRoutes(api)
routes := router.Routes()
routePaths := make(map[string]bool)
for _, r := range routes {
routePaths[r.Method+":"+r.Path] = true
}
assert.True(t, routePaths["POST:/api/v1/import/json/upload"])
assert.True(t, routePaths["POST:/api/v1/import/json/commit"])
assert.True(t, routePaths["POST:/api/v1/import/json/cancel"])
}
func TestJSONImportHandler_Upload_CharonFormat(t *testing.T) {
db := setupJSONTestDB(t)
handler := NewJSONImportHandler(db)
gin.SetMode(gin.TestMode)
router := gin.New()
api := router.Group("/api/v1")
handler.RegisterRoutes(api)
charonExport := CharonExport{
Version: "1.0.0",
ExportedAt: time.Now(),
ProxyHosts: []CharonProxyHost{
{
UUID: "test-uuid-1",
Name: "Test Host",
DomainNames: "example.com",
ForwardScheme: "http",
ForwardHost: "192.168.1.100",
ForwardPort: 8080,
SSLForced: true,
WebsocketSupport: true,
Enabled: true,
},
},
AccessLists: []CharonAccessList{
{
UUID: "acl-uuid-1",
Name: "Test ACL",
Type: "whitelist",
Enabled: true,
},
},
}
content, _ := json.Marshal(charonExport)
body, _ := json.Marshal(map[string]string{"content": string(content)})
req := httptest.NewRequest(http.MethodPost, "/api/v1/import/json/upload", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var response map[string]any
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
assert.Contains(t, response, "session")
session := response["session"].(map[string]any)
assert.Equal(t, "charon", session["source_type"])
assert.Contains(t, response, "charon_export")
charonInfo := response["charon_export"].(map[string]any)
assert.Equal(t, "1.0.0", charonInfo["version"])
}
func TestJSONImportHandler_Upload_NPMFormatFallback(t *testing.T) {
db := setupJSONTestDB(t)
handler := NewJSONImportHandler(db)
gin.SetMode(gin.TestMode)
router := gin.New()
api := router.Group("/api/v1")
handler.RegisterRoutes(api)
npmExport := NPMExport{
ProxyHosts: []NPMProxyHost{
{
ID: 1,
DomainNames: []string{"npm-example.com"},
ForwardScheme: "http",
ForwardHost: "192.168.1.100",
ForwardPort: 8080,
Enabled: true,
},
},
}
content, _ := json.Marshal(npmExport)
body, _ := json.Marshal(map[string]string{"content": string(content)})
req := httptest.NewRequest(http.MethodPost, "/api/v1/import/json/upload", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var response map[string]any
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
session := response["session"].(map[string]any)
assert.Equal(t, "npm", session["source_type"])
assert.Contains(t, response, "npm_export")
}
func TestJSONImportHandler_Upload_UnrecognizedFormat(t *testing.T) {
db := setupJSONTestDB(t)
handler := NewJSONImportHandler(db)
gin.SetMode(gin.TestMode)
router := gin.New()
api := router.Group("/api/v1")
handler.RegisterRoutes(api)
unknownFormat := map[string]any{
"some_field": "some_value",
"other": 123,
}
content, _ := json.Marshal(unknownFormat)
body, _ := json.Marshal(map[string]string{"content": string(content)})
req := httptest.NewRequest(http.MethodPost, "/api/v1/import/json/upload", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
}
func TestJSONImportHandler_Upload_InvalidJSON(t *testing.T) {
db := setupJSONTestDB(t)
handler := NewJSONImportHandler(db)
gin.SetMode(gin.TestMode)
router := gin.New()
api := router.Group("/api/v1")
handler.RegisterRoutes(api)
body, _ := json.Marshal(map[string]string{"content": "{invalid json"})
req := httptest.NewRequest(http.MethodPost, "/api/v1/import/json/upload", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
}
func TestJSONImportHandler_Commit_CharonFormat(t *testing.T) {
db := setupJSONTestDB(t)
handler := NewJSONImportHandler(db)
gin.SetMode(gin.TestMode)
router := gin.New()
api := router.Group("/api/v1")
handler.RegisterRoutes(api)
charonExport := CharonExport{
Version: "1.0.0",
ExportedAt: time.Now(),
ProxyHosts: []CharonProxyHost{
{
UUID: "test-uuid-1",
Name: "Test Host",
DomainNames: "newcharon.com",
ForwardScheme: "http",
ForwardHost: "192.168.1.100",
ForwardPort: 8080,
Enabled: true,
},
},
}
// Step 1: Upload to get session ID
content, _ := json.Marshal(charonExport)
uploadBody, _ := json.Marshal(map[string]string{"content": string(content)})
uploadReq := httptest.NewRequest(http.MethodPost, "/api/v1/import/json/upload", bytes.NewReader(uploadBody))
uploadReq.Header.Set("Content-Type", "application/json")
uploadW := httptest.NewRecorder()
router.ServeHTTP(uploadW, uploadReq)
require.Equal(t, http.StatusOK, uploadW.Code)
var uploadResponse map[string]any
err := json.Unmarshal(uploadW.Body.Bytes(), &uploadResponse)
require.NoError(t, err)
session := uploadResponse["session"].(map[string]any)
sessionID := session["id"].(string)
// Step 2: Commit with session UUID
commitBody, _ := json.Marshal(map[string]any{
"session_uuid": sessionID,
"resolutions": map[string]string{},
"names": map[string]string{"newcharon.com": "Custom Name"},
})
req := httptest.NewRequest(http.MethodPost, "/api/v1/import/json/commit", bytes.NewReader(commitBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var response map[string]any
err = json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
assert.Equal(t, float64(1), response["created"])
var host models.ProxyHost
db.Where("domain_names = ?", "newcharon.com").First(&host)
assert.Equal(t, "Custom Name", host.Name)
}
func TestJSONImportHandler_Commit_NPMFormatFallback(t *testing.T) {
db := setupJSONTestDB(t)
handler := NewJSONImportHandler(db)
gin.SetMode(gin.TestMode)
router := gin.New()
api := router.Group("/api/v1")
handler.RegisterRoutes(api)
npmExport := NPMExport{
ProxyHosts: []NPMProxyHost{
{
ID: 1,
DomainNames: []string{"newnpm.com"},
ForwardScheme: "http",
ForwardHost: "192.168.1.100",
ForwardPort: 8080,
Enabled: true,
},
},
}
// Step 1: Upload to get session ID
content, _ := json.Marshal(npmExport)
uploadBody, _ := json.Marshal(map[string]string{"content": string(content)})
uploadReq := httptest.NewRequest(http.MethodPost, "/api/v1/import/json/upload", bytes.NewReader(uploadBody))
uploadReq.Header.Set("Content-Type", "application/json")
uploadW := httptest.NewRecorder()
router.ServeHTTP(uploadW, uploadReq)
require.Equal(t, http.StatusOK, uploadW.Code)
var uploadResponse map[string]any
err := json.Unmarshal(uploadW.Body.Bytes(), &uploadResponse)
require.NoError(t, err)
session := uploadResponse["session"].(map[string]any)
sessionID := session["id"].(string)
// Step 2: Commit with session UUID
commitBody, _ := json.Marshal(map[string]any{
"session_uuid": sessionID,
"resolutions": map[string]string{},
"names": map[string]string{},
})
req := httptest.NewRequest(http.MethodPost, "/api/v1/import/json/commit", bytes.NewReader(commitBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var response map[string]any
err = json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
assert.Equal(t, float64(1), response["created"])
}
func TestJSONImportHandler_Commit_SessionNotFound(t *testing.T) {
db := setupJSONTestDB(t)
handler := NewJSONImportHandler(db)
gin.SetMode(gin.TestMode)
router := gin.New()
api := router.Group("/api/v1")
handler.RegisterRoutes(api)
// Try to commit with a non-existent session
commitBody, _ := json.Marshal(map[string]any{
"session_uuid": "non-existent-uuid",
"resolutions": map[string]string{},
"names": map[string]string{},
})
req := httptest.NewRequest(http.MethodPost, "/api/v1/import/json/commit", bytes.NewReader(commitBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusNotFound, w.Code)
var response map[string]any
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
assert.Contains(t, response["error"], "session not found")
}
func TestJSONImportHandler_Cancel(t *testing.T) {
db := setupJSONTestDB(t)
handler := NewJSONImportHandler(db)
gin.SetMode(gin.TestMode)
router := gin.New()
api := router.Group("/api/v1")
handler.RegisterRoutes(api)
charonExport := CharonExport{
Version: "1.0.0",
ExportedAt: time.Now(),
ProxyHosts: []CharonProxyHost{
{
UUID: "cancel-test-uuid",
Name: "Cancel Test",
DomainNames: "cancel-test.com",
ForwardScheme: "http",
ForwardHost: "192.168.1.100",
ForwardPort: 8080,
Enabled: true,
},
},
}
// Step 1: Upload to get session ID
content, _ := json.Marshal(charonExport)
uploadBody, _ := json.Marshal(map[string]string{"content": string(content)})
uploadReq := httptest.NewRequest(http.MethodPost, "/api/v1/import/json/upload", bytes.NewReader(uploadBody))
uploadReq.Header.Set("Content-Type", "application/json")
uploadW := httptest.NewRecorder()
router.ServeHTTP(uploadW, uploadReq)
require.Equal(t, http.StatusOK, uploadW.Code)
var uploadResponse map[string]any
err := json.Unmarshal(uploadW.Body.Bytes(), &uploadResponse)
require.NoError(t, err)
session := uploadResponse["session"].(map[string]any)
sessionID := session["id"].(string)
// Step 2: Cancel the session
cancelBody, _ := json.Marshal(map[string]any{
"session_uuid": sessionID,
})
cancelReq := httptest.NewRequest(http.MethodPost, "/api/v1/import/json/cancel", bytes.NewReader(cancelBody))
cancelReq.Header.Set("Content-Type", "application/json")
cancelW := httptest.NewRecorder()
router.ServeHTTP(cancelW, cancelReq)
assert.Equal(t, http.StatusOK, cancelW.Code)
var cancelResponse map[string]any
err = json.Unmarshal(cancelW.Body.Bytes(), &cancelResponse)
require.NoError(t, err)
assert.Equal(t, "cancelled", cancelResponse["status"])
// Step 3: Try to commit with cancelled session (should fail)
commitBody, _ := json.Marshal(map[string]any{
"session_uuid": sessionID,
"resolutions": map[string]string{},
"names": map[string]string{},
})
commitReq := httptest.NewRequest(http.MethodPost, "/api/v1/import/json/commit", bytes.NewReader(commitBody))
commitReq.Header.Set("Content-Type", "application/json")
commitW := httptest.NewRecorder()
router.ServeHTTP(commitW, commitReq)
assert.Equal(t, http.StatusNotFound, commitW.Code)
}
func TestJSONImportHandler_ConflictDetection(t *testing.T) {
db := setupJSONTestDB(t)
existingHost := models.ProxyHost{
UUID: "existing-uuid",
DomainNames: "conflict.com",
ForwardScheme: "http",
ForwardHost: "old-server",
ForwardPort: 80,
Enabled: true,
}
db.Create(&existingHost)
handler := NewJSONImportHandler(db)
gin.SetMode(gin.TestMode)
router := gin.New()
api := router.Group("/api/v1")
handler.RegisterRoutes(api)
charonExport := CharonExport{
Version: "1.0.0",
ProxyHosts: []CharonProxyHost{
{
UUID: "new-uuid",
DomainNames: "conflict.com",
ForwardScheme: "http",
ForwardHost: "new-server",
ForwardPort: 8080,
Enabled: true,
},
},
}
content, _ := json.Marshal(charonExport)
body, _ := json.Marshal(map[string]string{"content": string(content)})
req := httptest.NewRequest(http.MethodPost, "/api/v1/import/json/upload", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var response map[string]any
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
conflictDetails := response["conflict_details"].(map[string]any)
assert.Contains(t, conflictDetails, "conflict.com")
}
func TestJSONImportHandler_IsCharonFormat(t *testing.T) {
db := setupJSONTestDB(t)
handler := NewJSONImportHandler(db)
tests := []struct {
name string
export CharonExport
expected bool
}{
{
name: "with version",
export: CharonExport{Version: "1.0.0"},
expected: true,
},
{
name: "with proxy hosts",
export: CharonExport{
ProxyHosts: []CharonProxyHost{{DomainNames: "test.com"}},
},
expected: true,
},
{
name: "empty export",
export: CharonExport{},
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := handler.isCharonFormat(tt.export)
assert.Equal(t, tt.expected, result)
})
}
}
func TestIsValidJSON(t *testing.T) {
tests := []struct {
name string
input string
expected bool
}{
{"valid object", `{"key": "value"}`, true},
{"valid array", `[1, 2, 3]`, true},
{"valid string", `"hello"`, true},
{"valid number", `123`, true},
{"empty string", "", true},
{"whitespace only", " ", true},
{"invalid json", `{key: "value"}`, false},
{"incomplete", `{"key":`, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := isValidJSON(tt.input)
assert.Equal(t, tt.expected, result)
})
}
}
func TestJSONImportHandler_ConvertCharonToImportResult(t *testing.T) {
db := setupJSONTestDB(t)
handler := NewJSONImportHandler(db)
charonExport := CharonExport{
Version: "1.0.0",
ExportedAt: time.Now(),
ProxyHosts: []CharonProxyHost{
{
UUID: "uuid-1",
Name: "Host 1",
DomainNames: "host1.com",
ForwardScheme: "https",
ForwardHost: "backend1",
ForwardPort: 443,
SSLForced: true,
WebsocketSupport: true,
},
{
UUID: "uuid-2",
DomainNames: "",
ForwardScheme: "http",
ForwardHost: "backend2",
ForwardPort: 80,
},
},
}
result := handler.convertCharonToImportResult(charonExport)
assert.Len(t, result.Hosts, 1)
assert.Len(t, result.Errors, 1)
host := result.Hosts[0]
assert.Equal(t, "host1.com", host.DomainNames)
assert.Equal(t, "https", host.ForwardScheme)
assert.Equal(t, "backend1", host.ForwardHost)
assert.Equal(t, 443, host.ForwardPort)
assert.True(t, host.SSLForced)
assert.True(t, host.WebsocketSupport)
}
@@ -0,0 +1,368 @@
package handlers
import (
"encoding/json"
"fmt"
"net/http"
"sync"
"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"
)
// npmImportSessions stores parsed NPM exports keyed by session UUID.
// TODO: Implement session expiration to prevent memory leaks (e.g., TTL-based cleanup).
var (
npmImportSessions = make(map[string]NPMExport)
npmImportSessionsMu sync.RWMutex
)
// NPMExport represents the top-level structure of an NPM export file.
type NPMExport struct {
ProxyHosts []NPMProxyHost `json:"proxy_hosts"`
AccessLists []NPMAccessList `json:"access_lists"`
Certificates []NPMCertificate `json:"certificates"`
}
// NPMProxyHost represents a proxy host from NPM export.
type NPMProxyHost struct {
ID int `json:"id"`
DomainNames []string `json:"domain_names"`
ForwardScheme string `json:"forward_scheme"`
ForwardHost string `json:"forward_host"`
ForwardPort int `json:"forward_port"`
CertificateID *int `json:"certificate_id"`
SSLForced bool `json:"ssl_forced"`
CachingEnabled bool `json:"caching_enabled"`
BlockExploits bool `json:"block_exploits"`
AdvancedConfig string `json:"advanced_config"`
Meta any `json:"meta"`
AllowWebsocketUpgrade bool `json:"allow_websocket_upgrade"`
HTTP2Support bool `json:"http2_support"`
HSTSEnabled bool `json:"hsts_enabled"`
HSTSSubdomains bool `json:"hsts_subdomains"`
AccessListID *int `json:"access_list_id"`
Enabled bool `json:"enabled"`
Locations []any `json:"locations"`
CustomLocations []any `json:"custom_locations"`
OwnerUserID int `json:"owner_user_id"`
UseDefaultLocation bool `json:"use_default_location"`
IPV6 bool `json:"ipv6"`
CreatedOn string `json:"created_on"`
ModifiedOn string `json:"modified_on"`
ForwardDomainName string `json:"forward_domain_name"`
ForwardDomainNameEnabled bool `json:"forward_domain_name_enabled"`
}
// NPMAccessList represents an access list from NPM export.
type NPMAccessList struct {
ID int `json:"id"`
Name string `json:"name"`
PassAuth int `json:"pass_auth"`
SatisfyAny int `json:"satisfy_any"`
OwnerUserID int `json:"owner_user_id"`
Items []NPMAccessItem `json:"items"`
Clients []NPMAccessItem `json:"clients"`
ProxyHostsCount int `json:"proxy_host_count"`
CreatedOn string `json:"created_on"`
ModifiedOn string `json:"modified_on"`
AuthorizationHeader any `json:"authorization_header"`
}
// NPMAccessItem represents an item in an NPM access list.
type NPMAccessItem struct {
ID int `json:"id"`
AccessListID int `json:"access_list_id"`
Address string `json:"address"`
Directive string `json:"directive"`
CreatedOn string `json:"created_on"`
ModifiedOn string `json:"modified_on"`
}
// NPMCertificate represents a certificate from NPM export.
type NPMCertificate struct {
ID int `json:"id"`
Provider string `json:"provider"`
NiceName string `json:"nice_name"`
DomainNames []string `json:"domain_names"`
ExpiresOn string `json:"expires_on"`
CreatedOn string `json:"created_on"`
ModifiedOn string `json:"modified_on"`
IsDNSChallenge bool `json:"is_dns_challenge"`
Meta any `json:"meta"`
}
// NPMImportHandler handles NPM configuration imports.
type NPMImportHandler struct {
db *gorm.DB
proxyHostSvc *services.ProxyHostService
}
// NewNPMImportHandler creates a new NPM import handler.
func NewNPMImportHandler(db *gorm.DB) *NPMImportHandler {
return &NPMImportHandler{
db: db,
proxyHostSvc: services.NewProxyHostService(db),
}
}
// RegisterRoutes registers NPM import routes.
func (h *NPMImportHandler) RegisterRoutes(router *gin.RouterGroup) {
router.POST("/import/npm/upload", h.Upload)
router.POST("/import/npm/commit", h.Commit)
router.POST("/import/npm/cancel", h.Cancel)
}
// Upload parses an NPM export JSON and returns a preview with conflict detection.
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
}
var npmExport NPMExport
if err := json.Unmarshal([]byte(req.Content), &npmExport); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid NPM export JSON: %v", err)})
return
}
result := h.convertNPMToImportResult(npmExport)
if len(result.Hosts) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "no proxy hosts found in NPM export"})
return
}
// 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,
"ssl_forced": existing.SSLForced,
"websocket": existing.WebsocketSupport,
"enabled": existing.Enabled,
},
"imported": gin.H{
"forward_scheme": ph.ForwardScheme,
"forward_host": ph.ForwardHost,
"forward_port": ph.ForwardPort,
"ssl_forced": ph.SSLForced,
"websocket": ph.WebsocketSupport,
},
}
}
}
sid := uuid.NewString()
// Store the parsed export in session storage for later commit
npmImportSessionsMu.Lock()
npmImportSessions[sid] = npmExport
npmImportSessionsMu.Unlock()
c.JSON(http.StatusOK, gin.H{
"session": gin.H{"id": sid, "state": "transient", "source_type": "npm"},
"preview": result,
"conflict_details": conflictDetails,
"npm_export": gin.H{
"proxy_hosts": len(npmExport.ProxyHosts),
"access_lists": len(npmExport.AccessLists),
"certificates": len(npmExport.Certificates),
},
})
}
// Commit finalizes the NPM import with user's conflict resolutions.
func (h *NPMImportHandler) Commit(c *gin.Context) {
var req struct {
SessionUUID string `json:"session_uuid" binding:"required"`
Resolutions map[string]string `json:"resolutions"` // domain -> action
Names map[string]string `json:"names"` // domain -> custom name
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Retrieve the stored NPM export from session
npmImportSessionsMu.RLock()
npmExport, ok := npmImportSessions[req.SessionUUID]
npmImportSessionsMu.RUnlock()
if !ok {
c.JSON(http.StatusNotFound, gin.H{"error": "session not found or expired"})
return
}
result := h.convertNPMToImportResult(npmExport)
proxyHosts := caddy.ConvertToProxyHosts(result.Hosts)
created := 0
updated := 0
skipped := 0
errors := []string{}
existingHosts, _ := h.proxyHostSvc.List()
existingMap := make(map[string]*models.ProxyHost)
for i := range existingHosts {
existingMap[existingHosts[i].DomainNames] = &existingHosts[i]
}
for _, host := range proxyHosts {
action := req.Resolutions[host.DomainNames]
if customName, ok := req.Names[host.DomainNames]; ok && customName != "" {
host.Name = customName
}
if action == "skip" || action == "keep" {
skipped++
continue
}
if action == "rename" {
host.DomainNames += "-imported"
}
if action == "overwrite" {
if existing, found := existingMap[host.DomainNames]; found {
host.ID = existing.ID
host.UUID = existing.UUID
host.CertificateID = existing.CertificateID
host.CreatedAt = existing.CreatedAt
if err := h.proxyHostSvc.Update(&host); err != nil {
errors = append(errors, fmt.Sprintf("%s: %s", host.DomainNames, err.Error()))
} else {
updated++
}
continue
}
}
host.UUID = uuid.NewString()
if err := h.proxyHostSvc.Create(&host); err != nil {
errors = append(errors, fmt.Sprintf("%s: %s", host.DomainNames, err.Error()))
} else {
created++
}
}
// Clean up session after successful commit
npmImportSessionsMu.Lock()
delete(npmImportSessions, req.SessionUUID)
npmImportSessionsMu.Unlock()
c.JSON(http.StatusOK, gin.H{
"created": created,
"updated": updated,
"skipped": skipped,
"errors": errors,
})
}
// Cancel cancels an NPM import session and cleans up resources.
func (h *NPMImportHandler) Cancel(c *gin.Context) {
var req struct {
SessionUUID string `json:"session_uuid"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Clean up session if it exists
npmImportSessionsMu.Lock()
delete(npmImportSessions, req.SessionUUID)
npmImportSessionsMu.Unlock()
c.JSON(http.StatusOK, gin.H{"status": "cancelled"})
}
// convertNPMToImportResult converts NPM export format to Charon's ImportResult.
func (h *NPMImportHandler) convertNPMToImportResult(export NPMExport) *caddy.ImportResult {
result := &caddy.ImportResult{
Hosts: []caddy.ParsedHost{},
Conflicts: []string{},
Errors: []string{},
}
for _, npmHost := range export.ProxyHosts {
if len(npmHost.DomainNames) == 0 {
result.Errors = append(result.Errors, fmt.Sprintf("host %d has no domain names", npmHost.ID))
continue
}
// NPM stores multiple domains as array; join them
domainNames := ""
for i, d := range npmHost.DomainNames {
if i > 0 {
domainNames += ","
}
domainNames += d
}
scheme := npmHost.ForwardScheme
if scheme == "" {
scheme = "http"
}
port := npmHost.ForwardPort
if port == 0 {
port = 80
}
warnings := []string{}
if npmHost.CachingEnabled {
warnings = append(warnings, "Caching not supported - will be disabled")
}
if len(npmHost.Locations) > 0 || len(npmHost.CustomLocations) > 0 {
warnings = append(warnings, "Custom locations not fully supported")
}
if npmHost.AdvancedConfig != "" {
warnings = append(warnings, "Advanced nginx config not compatible - manual review required")
}
if npmHost.AccessListID != nil && *npmHost.AccessListID > 0 {
warnings = append(warnings, fmt.Sprintf("Access list reference (ID: %d) needs manual mapping", *npmHost.AccessListID))
}
host := caddy.ParsedHost{
DomainNames: domainNames,
ForwardScheme: scheme,
ForwardHost: npmHost.ForwardHost,
ForwardPort: port,
SSLForced: npmHost.SSLForced,
WebsocketSupport: npmHost.AllowWebsocketUpgrade,
Warnings: warnings,
}
rawJSON, _ := json.Marshal(npmHost)
host.RawJSON = string(rawJSON)
result.Hosts = append(result.Hosts, host)
}
return result
}
@@ -0,0 +1,493 @@
package handlers
import (
"bytes"
"encoding/json"
"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/models"
)
func setupNPMTestDB(t *testing.T) *gorm.DB {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
err = db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.Setting{})
require.NoError(t, err)
return db
}
func TestNewNPMImportHandler(t *testing.T) {
db := setupNPMTestDB(t)
handler := NewNPMImportHandler(db)
assert.NotNil(t, handler)
assert.NotNil(t, handler.db)
assert.NotNil(t, handler.proxyHostSvc)
}
func TestNPMImportHandler_RegisterRoutes(t *testing.T) {
db := setupNPMTestDB(t)
handler := NewNPMImportHandler(db)
gin.SetMode(gin.TestMode)
router := gin.New()
api := router.Group("/api/v1")
handler.RegisterRoutes(api)
routes := router.Routes()
routePaths := make(map[string]bool)
for _, r := range routes {
routePaths[r.Method+":"+r.Path] = true
}
assert.True(t, routePaths["POST:/api/v1/import/npm/upload"])
assert.True(t, routePaths["POST:/api/v1/import/npm/commit"])
assert.True(t, routePaths["POST:/api/v1/import/npm/cancel"])
}
func TestNPMImportHandler_Upload_ValidNPMExport(t *testing.T) {
db := setupNPMTestDB(t)
handler := NewNPMImportHandler(db)
gin.SetMode(gin.TestMode)
router := gin.New()
api := router.Group("/api/v1")
handler.RegisterRoutes(api)
npmExport := NPMExport{
ProxyHosts: []NPMProxyHost{
{
ID: 1,
DomainNames: []string{"example.com"},
ForwardScheme: "http",
ForwardHost: "192.168.1.100",
ForwardPort: 8080,
SSLForced: true,
AllowWebsocketUpgrade: true,
Enabled: true,
},
{
ID: 2,
DomainNames: []string{"test.com", "www.test.com"},
ForwardScheme: "https",
ForwardHost: "192.168.1.101",
ForwardPort: 443,
Enabled: true,
},
},
AccessLists: []NPMAccessList{
{
ID: 1,
Name: "Test ACL",
},
},
}
content, _ := json.Marshal(npmExport)
body, _ := json.Marshal(map[string]string{"content": string(content)})
req := httptest.NewRequest(http.MethodPost, "/api/v1/import/npm/upload", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var response map[string]any
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
assert.Contains(t, response, "session")
assert.Contains(t, response, "preview")
assert.Contains(t, response, "npm_export")
preview := response["preview"].(map[string]any)
hosts := preview["hosts"].([]any)
assert.Len(t, hosts, 2)
}
func TestNPMImportHandler_Upload_EmptyExport(t *testing.T) {
db := setupNPMTestDB(t)
handler := NewNPMImportHandler(db)
gin.SetMode(gin.TestMode)
router := gin.New()
api := router.Group("/api/v1")
handler.RegisterRoutes(api)
npmExport := NPMExport{
ProxyHosts: []NPMProxyHost{},
}
content, _ := json.Marshal(npmExport)
body, _ := json.Marshal(map[string]string{"content": string(content)})
req := httptest.NewRequest(http.MethodPost, "/api/v1/import/npm/upload", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
}
func TestNPMImportHandler_Upload_InvalidJSON(t *testing.T) {
db := setupNPMTestDB(t)
handler := NewNPMImportHandler(db)
gin.SetMode(gin.TestMode)
router := gin.New()
api := router.Group("/api/v1")
handler.RegisterRoutes(api)
body, _ := json.Marshal(map[string]string{"content": "not valid json"})
req := httptest.NewRequest(http.MethodPost, "/api/v1/import/npm/upload", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
}
func TestNPMImportHandler_Upload_ConflictDetection(t *testing.T) {
db := setupNPMTestDB(t)
existingHost := models.ProxyHost{
UUID: "existing-uuid",
DomainNames: "example.com",
ForwardScheme: "http",
ForwardHost: "old-server",
ForwardPort: 80,
Enabled: true,
}
db.Create(&existingHost)
handler := NewNPMImportHandler(db)
gin.SetMode(gin.TestMode)
router := gin.New()
api := router.Group("/api/v1")
handler.RegisterRoutes(api)
npmExport := NPMExport{
ProxyHosts: []NPMProxyHost{
{
ID: 1,
DomainNames: []string{"example.com"},
ForwardScheme: "http",
ForwardHost: "new-server",
ForwardPort: 8080,
Enabled: true,
},
},
}
content, _ := json.Marshal(npmExport)
body, _ := json.Marshal(map[string]string{"content": string(content)})
req := httptest.NewRequest(http.MethodPost, "/api/v1/import/npm/upload", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var response map[string]any
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
assert.Contains(t, response, "conflict_details")
conflictDetails := response["conflict_details"].(map[string]any)
assert.Contains(t, conflictDetails, "example.com")
}
func TestNPMImportHandler_Commit_CreateNew(t *testing.T) {
db := setupNPMTestDB(t)
handler := NewNPMImportHandler(db)
gin.SetMode(gin.TestMode)
router := gin.New()
api := router.Group("/api/v1")
handler.RegisterRoutes(api)
npmExport := NPMExport{
ProxyHosts: []NPMProxyHost{
{
ID: 1,
DomainNames: []string{"newhost.com"},
ForwardScheme: "http",
ForwardHost: "192.168.1.100",
ForwardPort: 8080,
Enabled: true,
},
},
}
// Step 1: Upload to get session ID
content, _ := json.Marshal(npmExport)
uploadBody, _ := json.Marshal(map[string]string{"content": string(content)})
uploadReq := httptest.NewRequest(http.MethodPost, "/api/v1/import/npm/upload", bytes.NewReader(uploadBody))
uploadReq.Header.Set("Content-Type", "application/json")
uploadW := httptest.NewRecorder()
router.ServeHTTP(uploadW, uploadReq)
require.Equal(t, http.StatusOK, uploadW.Code)
var uploadResponse map[string]any
err := json.Unmarshal(uploadW.Body.Bytes(), &uploadResponse)
require.NoError(t, err)
session := uploadResponse["session"].(map[string]any)
sessionID := session["id"].(string)
// Step 2: Commit with session UUID
commitBody, _ := json.Marshal(map[string]any{
"session_uuid": sessionID,
"resolutions": map[string]string{},
"names": map[string]string{},
})
req := httptest.NewRequest(http.MethodPost, "/api/v1/import/npm/commit", bytes.NewReader(commitBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var response map[string]any
err = json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
assert.Equal(t, float64(1), response["created"])
assert.Equal(t, float64(0), response["updated"])
assert.Equal(t, float64(0), response["skipped"])
var host models.ProxyHost
db.Where("domain_names = ?", "newhost.com").First(&host)
assert.NotEmpty(t, host.UUID)
assert.Equal(t, "192.168.1.100", host.ForwardHost)
}
func TestNPMImportHandler_Commit_SkipAction(t *testing.T) {
db := setupNPMTestDB(t)
handler := NewNPMImportHandler(db)
gin.SetMode(gin.TestMode)
router := gin.New()
api := router.Group("/api/v1")
handler.RegisterRoutes(api)
npmExport := NPMExport{
ProxyHosts: []NPMProxyHost{
{
ID: 1,
DomainNames: []string{"skipme.com"},
ForwardScheme: "http",
ForwardHost: "192.168.1.100",
ForwardPort: 8080,
Enabled: true,
},
},
}
// Step 1: Upload to get session ID
content, _ := json.Marshal(npmExport)
uploadBody, _ := json.Marshal(map[string]string{"content": string(content)})
uploadReq := httptest.NewRequest(http.MethodPost, "/api/v1/import/npm/upload", bytes.NewReader(uploadBody))
uploadReq.Header.Set("Content-Type", "application/json")
uploadW := httptest.NewRecorder()
router.ServeHTTP(uploadW, uploadReq)
require.Equal(t, http.StatusOK, uploadW.Code)
var uploadResponse map[string]any
err := json.Unmarshal(uploadW.Body.Bytes(), &uploadResponse)
require.NoError(t, err)
session := uploadResponse["session"].(map[string]any)
sessionID := session["id"].(string)
// Step 2: Commit with skip resolution
commitBody, _ := json.Marshal(map[string]any{
"session_uuid": sessionID,
"resolutions": map[string]string{"skipme.com": "skip"},
"names": map[string]string{},
})
req := httptest.NewRequest(http.MethodPost, "/api/v1/import/npm/commit", bytes.NewReader(commitBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var response map[string]any
err = json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
assert.Equal(t, float64(0), response["created"])
assert.Equal(t, float64(1), response["skipped"])
}
func TestNPMImportHandler_Commit_SessionNotFound(t *testing.T) {
db := setupNPMTestDB(t)
handler := NewNPMImportHandler(db)
gin.SetMode(gin.TestMode)
router := gin.New()
api := router.Group("/api/v1")
handler.RegisterRoutes(api)
// Try to commit with a non-existent session
commitBody, _ := json.Marshal(map[string]any{
"session_uuid": "non-existent-uuid",
"resolutions": map[string]string{},
"names": map[string]string{},
})
req := httptest.NewRequest(http.MethodPost, "/api/v1/import/npm/commit", bytes.NewReader(commitBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusNotFound, w.Code)
var response map[string]any
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
assert.Contains(t, response["error"], "session not found")
}
func TestNPMImportHandler_Cancel(t *testing.T) {
db := setupNPMTestDB(t)
handler := NewNPMImportHandler(db)
gin.SetMode(gin.TestMode)
router := gin.New()
api := router.Group("/api/v1")
handler.RegisterRoutes(api)
npmExport := NPMExport{
ProxyHosts: []NPMProxyHost{
{
ID: 1,
DomainNames: []string{"cancel-test.com"},
ForwardScheme: "http",
ForwardHost: "192.168.1.100",
ForwardPort: 8080,
Enabled: true,
},
},
}
// Step 1: Upload to get session ID
content, _ := json.Marshal(npmExport)
uploadBody, _ := json.Marshal(map[string]string{"content": string(content)})
uploadReq := httptest.NewRequest(http.MethodPost, "/api/v1/import/npm/upload", bytes.NewReader(uploadBody))
uploadReq.Header.Set("Content-Type", "application/json")
uploadW := httptest.NewRecorder()
router.ServeHTTP(uploadW, uploadReq)
require.Equal(t, http.StatusOK, uploadW.Code)
var uploadResponse map[string]any
err := json.Unmarshal(uploadW.Body.Bytes(), &uploadResponse)
require.NoError(t, err)
session := uploadResponse["session"].(map[string]any)
sessionID := session["id"].(string)
// Step 2: Cancel the session
cancelBody, _ := json.Marshal(map[string]any{
"session_uuid": sessionID,
})
cancelReq := httptest.NewRequest(http.MethodPost, "/api/v1/import/npm/cancel", bytes.NewReader(cancelBody))
cancelReq.Header.Set("Content-Type", "application/json")
cancelW := httptest.NewRecorder()
router.ServeHTTP(cancelW, cancelReq)
assert.Equal(t, http.StatusOK, cancelW.Code)
var cancelResponse map[string]any
err = json.Unmarshal(cancelW.Body.Bytes(), &cancelResponse)
require.NoError(t, err)
assert.Equal(t, "cancelled", cancelResponse["status"])
// Step 3: Try to commit with cancelled session (should fail)
commitBody, _ := json.Marshal(map[string]any{
"session_uuid": sessionID,
"resolutions": map[string]string{},
"names": map[string]string{},
})
commitReq := httptest.NewRequest(http.MethodPost, "/api/v1/import/npm/commit", bytes.NewReader(commitBody))
commitReq.Header.Set("Content-Type", "application/json")
commitW := httptest.NewRecorder()
router.ServeHTTP(commitW, commitReq)
assert.Equal(t, http.StatusNotFound, commitW.Code)
}
func TestNPMImportHandler_ConvertNPMToImportResult(t *testing.T) {
db := setupNPMTestDB(t)
handler := NewNPMImportHandler(db)
npmExport := NPMExport{
ProxyHosts: []NPMProxyHost{
{
ID: 1,
DomainNames: []string{"test.com", "www.test.com"},
ForwardScheme: "https",
ForwardHost: "backend",
ForwardPort: 443,
SSLForced: true,
AllowWebsocketUpgrade: true,
CachingEnabled: true,
AdvancedConfig: "proxy_set_header X-Custom value;",
},
{
ID: 2,
DomainNames: []string{},
},
},
}
result := handler.convertNPMToImportResult(npmExport)
assert.Len(t, result.Hosts, 1)
assert.Len(t, result.Errors, 1)
host := result.Hosts[0]
assert.Equal(t, "test.com,www.test.com", host.DomainNames)
assert.Equal(t, "https", host.ForwardScheme)
assert.Equal(t, "backend", host.ForwardHost)
assert.Equal(t, 443, host.ForwardPort)
assert.True(t, host.SSLForced)
assert.True(t, host.WebsocketSupport)
assert.Len(t, host.Warnings, 2) // Caching + Advanced config warnings
}
+8
View File
@@ -583,4 +583,12 @@ func RegisterImportHandler(router *gin.Engine, db *gorm.DB, caddyBinary, importD
importHandler := handlers.NewImportHandler(db, caddyBinary, importDir, mountPath)
api := router.Group("/api/v1")
importHandler.RegisterRoutes(api)
// NPM Import Handler - supports Nginx Proxy Manager export format
npmImportHandler := handlers.NewNPMImportHandler(db)
npmImportHandler.RegisterRoutes(api)
// JSON Import Handler - supports both Charon and NPM export formats
jsonImportHandler := handlers.NewJSONImportHandler(db)
jsonImportHandler.RegisterRoutes(api)
}