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:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user