diff --git a/backend/internal/api/handlers/auth_handler_test.go b/backend/internal/api/handlers/auth_handler_test.go index 90305cbf..3a54d5ab 100644 --- a/backend/internal/api/handlers/auth_handler_test.go +++ b/backend/internal/api/handlers/auth_handler_test.go @@ -224,3 +224,29 @@ func TestAuthHandler_ChangePassword_WrongOld(t *testing.T) { assert.Equal(t, http.StatusBadRequest, w.Code) } + +func TestAuthHandler_ChangePassword_Errors(t *testing.T) { + handler, _ := setupAuthHandler(t) + gin.SetMode(gin.TestMode) + r := gin.New() + r.POST("/change-password", handler.ChangePassword) + + // 1. BindJSON error (checked before auth) + req, _ := http.NewRequest("POST", "/change-password", bytes.NewBufferString("invalid json")) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + assert.Equal(t, http.StatusBadRequest, w.Code) + + // 2. Unauthorized (valid JSON but no user in context) + body := map[string]string{ + "old_password": "oldpassword", + "new_password": "newpassword123", + } + jsonBody, _ := json.Marshal(body) + req, _ = http.NewRequest("POST", "/change-password", bytes.NewBuffer(jsonBody)) + req.Header.Set("Content-Type", "application/json") + w = httptest.NewRecorder() + r.ServeHTTP(w, req) + assert.Equal(t, http.StatusUnauthorized, w.Code) +} diff --git a/backend/internal/api/handlers/import_handler.go b/backend/internal/api/handlers/import_handler.go index 4bc98f0a..87c58831 100644 --- a/backend/internal/api/handlers/import_handler.go +++ b/backend/internal/api/handlers/import_handler.go @@ -94,14 +94,32 @@ func (h *ImportHandler) GetPreview(c *gin.Context) { session.Status = "reviewing" h.db.Save(&session) + // Read original Caddyfile content if available + var caddyfileContent string + if session.SourceFile != "" { + // Try to read from the source file path (if it's a mounted file) + if content, err := os.ReadFile(session.SourceFile); err == nil { + caddyfileContent = string(content) + } else { + // If source file not readable (e.g. uploaded temp file deleted), try to find backup + // This is a best-effort attempt + backupPath := filepath.Join(h.importDir, "backups", filepath.Base(session.SourceFile)) + if content, err := os.ReadFile(backupPath); err == nil { + caddyfileContent = string(content) + } + } + } + c.JSON(http.StatusOK, gin.H{ "session": gin.H{ - "id": session.UUID, - "state": session.Status, - "created_at": session.CreatedAt, - "updated_at": session.UpdatedAt, + "id": session.UUID, + "state": session.Status, + "created_at": session.CreatedAt, + "updated_at": session.UpdatedAt, + "source_file": session.SourceFile, }, - "preview": result, + "preview": result, + "caddyfile_content": caddyfileContent, }) } diff --git a/backend/internal/api/handlers/import_handler_test.go b/backend/internal/api/handlers/import_handler_test.go index c499ab9f..d7760f95 100644 --- a/backend/internal/api/handlers/import_handler_test.go +++ b/backend/internal/api/handlers/import_handler_test.go @@ -198,16 +198,234 @@ func TestImportHandler_Upload(t *testing.T) { // ExtractHosts will return empty result // processImport should succeed - // Wait, fake_caddy.sh needs to handle "version" command too for ValidateCaddyBinary - // The current fake_caddy.sh just echoes json. - // I should update fake_caddy.sh or create a better one. + assert.Equal(t, http.StatusOK, w.Code) +} - // Let's assume it fails for now or check the response - // If it fails, it's likely due to ValidateCaddyBinary calling "version" and getting JSON - // But ValidateCaddyBinary just checks exit code 0. - // fake_caddy.sh exits with 0. +func TestImportHandler_GetPreview_WithContent(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupImportTestDB(t) + tmpDir := t.TempDir() + handler := handlers.NewImportHandler(db, "echo", tmpDir) + router := gin.New() + router.GET("/import/preview", handler.GetPreview) + + // Case: Active session with source file + content := "example.com {\n reverse_proxy localhost:8080\n}" + sourceFile := filepath.Join(tmpDir, "source.caddyfile") + err := os.WriteFile(sourceFile, []byte(content), 0644) + assert.NoError(t, err) + + // Case: Active session with source file + session := models.ImportSession{ + UUID: uuid.NewString(), + Status: "pending", + ParsedData: `{"hosts": []}`, + SourceFile: sourceFile, + } + db.Create(&session) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/import/preview", nil) + router.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code) + var result map[string]interface{} + err = json.Unmarshal(w.Body.Bytes(), &result) + assert.NoError(t, err) + + assert.Equal(t, content, result["caddyfile_content"]) +} + +func TestImportHandler_Commit_Errors(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupImportTestDB(t) + handler := handlers.NewImportHandler(db, "echo", "/tmp") + router := gin.New() + router.POST("/import/commit", handler.Commit) + + // Case 1: Invalid JSON + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/import/commit", bytes.NewBufferString("invalid")) + router.ServeHTTP(w, req) + assert.Equal(t, http.StatusBadRequest, w.Code) + + // Case 2: Session not found + payload := map[string]interface{}{ + "session_uuid": "non-existent", + "resolutions": map[string]string{}, + } + body, _ := json.Marshal(payload) + w = httptest.NewRecorder() + req, _ = http.NewRequest("POST", "/import/commit", bytes.NewBuffer(body)) + router.ServeHTTP(w, req) + assert.Equal(t, http.StatusNotFound, w.Code) + + // Case 3: Invalid ParsedData + session := models.ImportSession{ + UUID: "invalid-data-uuid", + Status: "reviewing", + ParsedData: "invalid-json", + } + db.Create(&session) + + payload = map[string]interface{}{ + "session_uuid": "invalid-data-uuid", + "resolutions": map[string]string{}, + } + body, _ = json.Marshal(payload) + w = httptest.NewRecorder() + req, _ = http.NewRequest("POST", "/import/commit", bytes.NewBuffer(body)) + router.ServeHTTP(w, req) + assert.Equal(t, http.StatusInternalServerError, w.Code) +} + +func TestImportHandler_Cancel_Errors(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupImportTestDB(t) + handler := handlers.NewImportHandler(db, "echo", "/tmp") + router := gin.New() + router.DELETE("/import/cancel", handler.Cancel) + + // Case 1: Session not found + w := httptest.NewRecorder() + req, _ := http.NewRequest("DELETE", "/import/cancel?session_uuid=non-existent", nil) + router.ServeHTTP(w, req) + assert.Equal(t, http.StatusNotFound, w.Code) +} + +func TestCheckMountedImport(t *testing.T) { + db := setupImportTestDB(t) + tmpDir := t.TempDir() + mountPath := filepath.Join(tmpDir, "mounted.caddyfile") + + // Use fake caddy script + cwd, _ := os.Getwd() + fakeCaddy := filepath.Join(cwd, "testdata", "fake_caddy.sh") + os.Chmod(fakeCaddy, 0755) + + // Case 1: File does not exist + err := handlers.CheckMountedImport(db, mountPath, fakeCaddy, tmpDir) + assert.NoError(t, err) + + // Case 2: File exists, not processed + err = os.WriteFile(mountPath, []byte("example.com"), 0644) + assert.NoError(t, err) + + err = handlers.CheckMountedImport(db, mountPath, fakeCaddy, tmpDir) + assert.NoError(t, err) + + // Check if session created + var count int64 + db.Model(&models.ImportSession{}).Where("source_file = ?", mountPath).Count(&count) + assert.Equal(t, int64(1), count) + + // Case 3: Already processed + err = handlers.CheckMountedImport(db, mountPath, fakeCaddy, tmpDir) + assert.NoError(t, err) +} + +func TestImportHandler_Upload_Failure(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupImportTestDB(t) + + // Use fake caddy script that fails + cwd, _ := os.Getwd() + fakeCaddy := filepath.Join(cwd, "testdata", "fake_caddy_fail.sh") + + tmpDir := t.TempDir() + handler := handlers.NewImportHandler(db, fakeCaddy, tmpDir) + router := gin.New() + router.POST("/import/upload", handler.Upload) + + payload := map[string]string{ + "content": "invalid caddyfile", + "filename": "Caddyfile", + } + body, _ := json.Marshal(payload) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/import/upload", bytes.NewBuffer(body)) + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + // The error message comes from processImport -> ImportFile -> "import failed: ..." + assert.Contains(t, resp["error"], "import failed") +} + +func TestImportHandler_Upload_Conflict(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupImportTestDB(t) + + // Pre-create a host to cause conflict + db.Create(&models.ProxyHost{ + DomainNames: "example.com", + ForwardHost: "127.0.0.1", + ForwardPort: 9090, + }) + + // Use fake caddy script that returns hosts + cwd, _ := os.Getwd() + fakeCaddy := filepath.Join(cwd, "testdata", "fake_caddy_hosts.sh") + + tmpDir := t.TempDir() + handler := handlers.NewImportHandler(db, fakeCaddy, tmpDir) + router := gin.New() + router.POST("/import/upload", handler.Upload) + + payload := map[string]string{ + "content": "example.com", + "filename": "Caddyfile", + } + body, _ := json.Marshal(payload) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/import/upload", bytes.NewBuffer(body)) + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + // Verify session created with conflict + var session models.ImportSession + db.First(&session) + assert.Equal(t, "pending", session.Status) + assert.Contains(t, session.ConflictReport, "Domain 'example.com' already exists") +} + +func TestImportHandler_GetPreview_BackupContent(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupImportTestDB(t) + tmpDir := t.TempDir() + handler := handlers.NewImportHandler(db, "echo", tmpDir) + router := gin.New() + router.GET("/import/preview", handler.GetPreview) + + // Create backup file + backupDir := filepath.Join(tmpDir, "backups") + os.MkdirAll(backupDir, 0755) + content := "backup content" + backupFile := filepath.Join(backupDir, "source.caddyfile") + os.WriteFile(backupFile, []byte(content), 0644) + + // Case: Active session with missing source file but existing backup + session := models.ImportSession{ + UUID: uuid.NewString(), + Status: "pending", + ParsedData: `{"hosts": []}`, + SourceFile: "/non/existent/source.caddyfile", + } + db.Create(&session) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/import/preview", nil) + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + var result map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &result) + + assert.Equal(t, content, result["caddyfile_content"]) } func TestImportHandler_RegisterRoutes(t *testing.T) { diff --git a/backend/internal/api/handlers/testdata/fake_caddy_fail.sh b/backend/internal/api/handlers/testdata/fake_caddy_fail.sh new file mode 100755 index 00000000..c3e063b1 --- /dev/null +++ b/backend/internal/api/handlers/testdata/fake_caddy_fail.sh @@ -0,0 +1,6 @@ +#!/bin/sh +if [ "$1" = "version" ]; then + echo "v2.0.0" + exit 0 +fi +exit 1 diff --git a/backend/internal/api/handlers/testdata/fake_caddy_hosts.sh b/backend/internal/api/handlers/testdata/fake_caddy_hosts.sh new file mode 100755 index 00000000..df463bc7 --- /dev/null +++ b/backend/internal/api/handlers/testdata/fake_caddy_hosts.sh @@ -0,0 +1,10 @@ +#!/bin/sh +if [ "$1" = "version" ]; then + echo "v2.0.0" + exit 0 +fi +if [ "$1" = "adapt" ]; then + echo '{"apps":{"http":{"servers":{"srv0":{"routes":[{"match":[{"host":["example.com"]}],"handle":[{"handler":"reverse_proxy","upstreams":[{"dial":"localhost:8080"}]}]}]}}}}}' + exit 0 +fi +exit 1 diff --git a/backend/internal/api/handlers/user_handler_test.go b/backend/internal/api/handlers/user_handler_test.go index d2532ef7..a7e0d5ea 100644 --- a/backend/internal/api/handlers/user_handler_test.go +++ b/backend/internal/api/handlers/user_handler_test.go @@ -350,3 +350,39 @@ func TestUserHandler_UpdateProfile(t *testing.T) { assert.Equal(t, http.StatusConflict, w.Code) }) } + +func TestUserHandler_UpdateProfile_Errors(t *testing.T) { + handler, _ := setupUserHandler(t) + gin.SetMode(gin.TestMode) + r := gin.New() + + // 1. Unauthorized (no userID) + r.PUT("/profile-no-auth", handler.UpdateProfile) + req, _ := http.NewRequest("PUT", "/profile-no-auth", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + assert.Equal(t, http.StatusUnauthorized, w.Code) + + // Middleware for subsequent tests + r.Use(func(c *gin.Context) { + c.Set("userID", uint(999)) // Non-existent ID + c.Next() + }) + r.PUT("/profile", handler.UpdateProfile) + + // 2. BindJSON error + req, _ = http.NewRequest("PUT", "/profile", bytes.NewBufferString("invalid")) + req.Header.Set("Content-Type", "application/json") + w = httptest.NewRecorder() + r.ServeHTTP(w, req) + assert.Equal(t, http.StatusBadRequest, w.Code) + + // 3. User not found + body := map[string]string{"name": "New Name", "email": "new@example.com"} + jsonBody, _ := json.Marshal(body) + req, _ = http.NewRequest("PUT", "/profile", bytes.NewBuffer(jsonBody)) + req.Header.Set("Content-Type", "application/json") + w = httptest.NewRecorder() + r.ServeHTTP(w, req) + assert.Equal(t, http.StatusNotFound, w.Code) +} diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index f423c57f..b19fe851 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -21,3 +21,6 @@ services: - CPM_CADDY_CONFIG_DIR=/app/data/caddy volumes: - /var/run/docker.sock:/var/run/docker.sock:ro # For local container discovery + # Mount your existing Caddyfile for automatic import (optional) + # - ./my-existing-Caddyfile:/import/Caddyfile:ro + # - ./sites:/import/sites:ro # If your Caddyfile imports other files diff --git a/docker-compose.local.yml b/docker-compose.local.yml index 1ce8d552..d8374a20 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -31,6 +31,9 @@ services: - caddy_data_local:/data - caddy_config_local:/config - /var/run/docker.sock:/var/run/docker.sock:ro # For local container discovery + # Mount your existing Caddyfile for automatic import (optional) + # - ./my-existing-Caddyfile:/import/Caddyfile:ro + # - ./sites:/import/sites:ro # If your Caddyfile imports other files healthcheck: test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/api/v1/health"] interval: 30s diff --git a/docker-compose.yml b/docker-compose.yml index 4c3bfb8d..c4f31df9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,6 +27,7 @@ services: - /var/run/docker.sock:/var/run/docker.sock:ro # For local container discovery # Mount your existing Caddyfile for automatic import (optional) # - ./my-existing-Caddyfile:/import/Caddyfile:ro + # - ./sites:/import/sites:ro # If your Caddyfile imports other files healthcheck: test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/api/v1/health"] interval: 30s diff --git a/docs/beta_release_draft_pr.md b/docs/beta_release_draft_pr.md index db330496..0ce5729e 100644 --- a/docs/beta_release_draft_pr.md +++ b/docs/beta_release_draft_pr.md @@ -65,4 +65,4 @@ Marking this as a DRAFT to allow review of token changes before merge. Please: - Review for any missed workflow references. --- -Generated by automated assistant for alignment between branches. \ No newline at end of file +Generated by automated assistant for alignment between branches. diff --git a/docs/beta_release_draft_pr_body_snapshot.md b/docs/beta_release_draft_pr_body_snapshot.md index cab54aff..8af3819f 100644 --- a/docs/beta_release_draft_pr_body_snapshot.md +++ b/docs/beta_release_draft_pr_body_snapshot.md @@ -35,4 +35,4 @@ Ensures alpha integration branch inherits hardened CI/release pipeline and updat 3. Spot any residual `GITHUB_TOKEN` references missed. --- -Generated draft to align branches; will convert to ready-for-review after validation. \ No newline at end of file +Generated draft to align branches; will convert to ready-for-review after validation. diff --git a/docs/beta_release_pr_body.md b/docs/beta_release_pr_body.md index 56e97a6b..405468d2 100644 --- a/docs/beta_release_pr_body.md +++ b/docs/beta_release_pr_body.md @@ -34,4 +34,4 @@ Most recent snapshot commit: `308ae5dd` (final body content before PR). Full ord Please focus review on secret usage, workflow call integrity, and artifact correctness. Comment with any missed token references. --- -Generated programmatically to aid structured review. \ No newline at end of file +Generated programmatically to aid structured review. diff --git a/frontend/coverage.out b/frontend/coverage.out new file mode 100644 index 00000000..5f02b111 --- /dev/null +++ b/frontend/coverage.out @@ -0,0 +1 @@ +mode: set diff --git a/frontend/src/api/import.ts b/frontend/src/api/import.ts index 3c9a5460..ae21c326 100644 --- a/frontend/src/api/import.ts +++ b/frontend/src/api/import.ts @@ -14,6 +14,7 @@ export interface ImportPreview { conflicts: string[]; errors: string[]; }; + caddyfile_content?: string; } export const uploadCaddyfile = async (content: string): Promise => { diff --git a/frontend/src/components/ImportReviewTable.tsx b/frontend/src/components/ImportReviewTable.tsx index 605a7f94..a877ffaa 100644 --- a/frontend/src/components/ImportReviewTable.tsx +++ b/frontend/src/components/ImportReviewTable.tsx @@ -9,11 +9,12 @@ interface Props { hosts: HostPreview[] conflicts: string[] errors: string[] + caddyfileContent?: string onCommit: (resolutions: Record) => Promise onCancel: () => void } -export default function ImportReviewTable({ hosts, conflicts, errors, onCommit, onCancel }: Props) { +export default function ImportReviewTable({ hosts, conflicts, errors, caddyfileContent, onCommit, onCancel }: Props) { const [resolutions, setResolutions] = useState>(() => { const init: Record = {} conflicts.forEach((d: string) => { init[d] = 'keep' }) @@ -21,6 +22,7 @@ export default function ImportReviewTable({ hosts, conflicts, errors, onCommit, }) const [submitting, setSubmitting] = useState(false) const [error, setError] = useState(null) + const [showSource, setShowSource] = useState(false) const handleCommit = async () => { setSubmitting(true) @@ -35,10 +37,25 @@ export default function ImportReviewTable({ hosts, conflicts, errors, onCommit, } return ( -
-
-

Review Imported Hosts

-
+
+ {caddyfileContent && ( +
+
setShowSource(!showSource)}> +

Source Caddyfile Content

+ {showSource ? 'Hide' : 'Show'} +
+ {showSource && ( +
+
{caddyfileContent}
+
+ )} +
+ )} + +
+
+

Review Imported Hosts

+
+
) } diff --git a/frontend/src/pages/ImportCaddy.tsx b/frontend/src/pages/ImportCaddy.tsx index b27e926f..7a7bef82 100644 --- a/frontend/src/pages/ImportCaddy.tsx +++ b/frontend/src/pages/ImportCaddy.tsx @@ -70,6 +70,17 @@ export default function ImportCaddy() {
)} + {/* Show warning if preview is empty but session exists (e.g. mounted file was empty or invalid) */} + {session && preview && preview.preview && preview.preview.hosts.length === 0 && ( +
+

No domains found in Caddyfile

+

+ The imported file appears to be empty or contains no valid reverse_proxy directives. + Please check the file content below. +

+
+ )} + {!session && (
@@ -136,6 +147,7 @@ api.example.com { hosts={preview.preview.hosts} conflicts={preview.preview.conflicts} errors={preview.preview.errors} + caddyfileContent={preview.caddyfile_content} onCommit={handleCommit} onCancel={() => setShowReview(false)} />