diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index cfc9925e..4214b5ae 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -43,18 +43,6 @@ on: - 'playwright.config.js' - '.github/workflows/e2e-tests.yml' - push: - branches: - - main - - development - - 'feature/**' - paths: - - 'frontend/**' - - 'backend/**' - - 'tests/**' - - 'playwright.config.js' - - '.github/workflows/e2e-tests.yml' - workflow_dispatch: inputs: browser: @@ -161,7 +149,7 @@ jobs: matrix: shard: [1, 2, 3, 4] total-shards: [4] - browser: [chromium] + browser: [chromium, firefox, webkit] steps: - name: Checkout repository @@ -340,12 +328,11 @@ jobs: echo "" >> $GITHUB_STEP_SUMMARY echo "Each shard generates its own HTML report for easier debugging:" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY - echo "| Shard | HTML Report | Traces (on failure) |" >> $GITHUB_STEP_SUMMARY - echo "|-------|-------------|---------------------|" >> $GITHUB_STEP_SUMMARY - echo "| 1 | \`playwright-report-shard-1\` | \`traces-chromium-shard-1\` |" >> $GITHUB_STEP_SUMMARY - echo "| 2 | \`playwright-report-shard-2\` | \`traces-chromium-shard-2\` |" >> $GITHUB_STEP_SUMMARY - echo "| 3 | \`playwright-report-shard-3\` | \`traces-chromium-shard-3\` |" >> $GITHUB_STEP_SUMMARY - echo "| 4 | \`playwright-report-shard-4\` | \`traces-chromium-shard-4\` |" >> $GITHUB_STEP_SUMMARY + echo "| Browser | Shards | HTML Reports | Traces (on failure) |" >> $GITHUB_STEP_SUMMARY + echo "|---------|--------|--------------|---------------------|" >> $GITHUB_STEP_SUMMARY + echo "| Chromium | 1-4 | \`playwright-report-shard-{1..4}\` | \`traces-chromium-shard-{1..4}\` |" >> $GITHUB_STEP_SUMMARY + echo "| Firefox | 1-4 | \`playwright-report-shard-{1..4}\` | \`traces-firefox-shard-{1..4}\` |" >> $GITHUB_STEP_SUMMARY + echo "| WebKit | 1-4 | \`playwright-report-shard-{1..4}\` | \`traces-webkit-shard-{1..4}\` |" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "### How to View Reports" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY @@ -402,12 +389,14 @@ jobs: | Metric | Result | |--------|--------| - | Browser | Chromium | - | Shards | 4 | + | Browsers | Chromium, Firefox, WebKit | + | Shards per Browser | 4 | + | Total Jobs | 12 | | Status | ${status} | **Per-Shard HTML Reports** (easier to debug): - - \`playwright-report-shard-1\` through \`playwright-report-shard-4\` + - \`playwright-report-shard-{1..4}\` for each browser + - Trace artifacts: \`traces-{browser}-shard-{N}\` [๐Ÿ“Š View workflow run & download reports](${runUrl}) diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml deleted file mode 100644 index a84d8968..00000000 --- a/.github/workflows/playwright.yml +++ /dev/null @@ -1,336 +0,0 @@ -# Playwright E2E Tests -# Runs Playwright tests against PR Docker images after the build workflow completes -name: Playwright E2E Tests - -on: - push: - branches: - - main - - development - - 'feature/**' - paths: - - 'frontend/**' - - 'backend/**' - - 'tests/**' - - 'playwright.config.js' - - '.github/workflows/playwright.yml' - - pull_request: - branches: - - main - - development - - 'feature/**' - - workflow_run: - workflows: ["Docker Build, Publish & Test"] - types: - - completed - - workflow_dispatch: - inputs: - pr_number: - description: 'PR number to test (optional)' - required: false - type: string - -concurrency: - group: playwright-${{ github.event.workflow_run.head_branch || github.ref }} - cancel-in-progress: true - -jobs: - playwright: - name: E2E Tests - runs-on: ubuntu-latest - timeout-minutes: 20 - # Run for: manual dispatch, PR builds, or any push builds from docker-build - if: >- - github.event_name == 'workflow_dispatch' || - ((github.event.workflow_run.event == 'pull_request' || github.event.workflow_run.event == 'push') && - github.event.workflow_run.conclusion == 'success') - - env: - CHARON_ENV: development - CHARON_DEBUG: "1" - CHARON_ENCRYPTION_KEY: ${{ secrets.CHARON_CI_ENCRYPTION_KEY }} - # Emergency server enabled for triage; token supplied via GitHub secret (redacted) - CHARON_EMERGENCY_TOKEN: ${{ secrets.CHARON_EMERGENCY_TOKEN }} - CHARON_EMERGENCY_SERVER_ENABLED: "true" - PLAYWRIGHT_BASE_URL: http://localhost:8080 - - steps: - - name: Checkout repository - # actions/checkout v4.2.2 - uses: actions/checkout@0c366fd6a839edf440554fa01a7085ccba70ac98 - - - name: Extract PR number from workflow_run - id: pr-info - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then - # Manual dispatch - use input or fail gracefully - if [[ -n "${{ inputs.pr_number }}" ]]; then - echo "pr_number=${{ inputs.pr_number }}" >> "$GITHUB_OUTPUT" - echo "โœ… Using manually provided PR number: ${{ inputs.pr_number }}" - else - echo "โš ๏ธ No PR number provided for manual dispatch" - echo "pr_number=" >> "$GITHUB_OUTPUT" - fi - exit 0 - fi - - # Extract PR number from workflow_run context - HEAD_SHA="${{ github.event.workflow_run.head_sha }}" - echo "๐Ÿ” Looking for PR with head SHA: ${HEAD_SHA}" - - # Query GitHub API for PR associated with this commit - PR_NUMBER=$(gh api \ - -H "Accept: application/vnd.github+json" \ - -H "X-GitHub-Api-Version: 2022-11-28" \ - "/repos/${{ github.repository }}/commits/${HEAD_SHA}/pulls" \ - --jq '.[0].number // empty' 2>/dev/null || echo "") - - if [[ -n "${PR_NUMBER}" ]]; then - echo "pr_number=${PR_NUMBER}" >> "$GITHUB_OUTPUT" - echo "โœ… Found PR number: ${PR_NUMBER}" - else - echo "โš ๏ธ Could not find PR number for SHA: ${HEAD_SHA}" - echo "pr_number=" >> "$GITHUB_OUTPUT" - fi - - # Check if this is a push event (not a PR) - if [[ "${{ github.event.workflow_run.event }}" == "push" ]]; then - echo "is_push=true" >> "$GITHUB_OUTPUT" - echo "โœ… Detected push build from branch: ${{ github.event.workflow_run.head_branch }}" - else - echo "is_push=false" >> "$GITHUB_OUTPUT" - fi - - - name: Sanitize branch name - id: sanitize - run: | - # Sanitize branch name for use in Docker tags and artifact names - # Replace / with - to avoid invalid reference format errors - BRANCH="${{ github.event.workflow_run.head_branch || github.head_ref || github.ref_name }}" - SANITIZED=$(echo "$BRANCH" | tr '/' '-') - echo "branch=${SANITIZED}" >> "$GITHUB_OUTPUT" - echo "๐Ÿ“‹ Sanitized branch name: ${BRANCH} -> ${SANITIZED}" - - - name: Check for PR image artifact - id: check-artifact - if: steps.pr-info.outputs.pr_number != '' || steps.pr-info.outputs.is_push == 'true' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - # Determine artifact name based on event type - if [[ "${{ steps.pr-info.outputs.is_push }}" == "true" ]]; then - ARTIFACT_NAME="push-image" - else - PR_NUMBER="${{ steps.pr-info.outputs.pr_number }}" - ARTIFACT_NAME="pr-image-${PR_NUMBER}" - fi - RUN_ID="${{ github.event.workflow_run.id }}" - - echo "๐Ÿ” Checking for artifact: ${ARTIFACT_NAME}" - - if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then - # For manual dispatch, find the most recent workflow run with this artifact - RUN_ID=$(gh api \ - -H "Accept: application/vnd.github+json" \ - -H "X-GitHub-Api-Version: 2022-11-28" \ - "/repos/${{ github.repository }}/actions/workflows/docker-build.yml/runs?status=success&per_page=10" \ - --jq '.workflow_runs[0].id // empty' 2>/dev/null || echo "") - - if [[ -z "${RUN_ID}" ]]; then - echo "โš ๏ธ No successful workflow runs found" - echo "artifact_exists=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - fi - - echo "run_id=${RUN_ID}" >> "$GITHUB_OUTPUT" - - # Check if the artifact exists in the workflow run - ARTIFACT_ID=$(gh api \ - -H "Accept: application/vnd.github+json" \ - -H "X-GitHub-Api-Version: 2022-11-28" \ - "/repos/${{ github.repository }}/actions/runs/${RUN_ID}/artifacts" \ - --jq ".artifacts[] | select(.name == \"${ARTIFACT_NAME}\") | .id" 2>/dev/null || echo "") - - if [[ -n "${ARTIFACT_ID}" ]]; then - echo "artifact_exists=true" >> "$GITHUB_OUTPUT" - echo "artifact_id=${ARTIFACT_ID}" >> "$GITHUB_OUTPUT" - echo "โœ… Found artifact: ${ARTIFACT_NAME} (ID: ${ARTIFACT_ID})" - else - echo "artifact_exists=false" >> "$GITHUB_OUTPUT" - echo "โš ๏ธ Artifact not found: ${ARTIFACT_NAME}" - echo "โ„น๏ธ This is expected for non-PR builds or if the image was not uploaded" - fi - - - name: Skip if no artifact - if: (steps.pr-info.outputs.pr_number == '' && steps.pr-info.outputs.is_push != 'true') || steps.check-artifact.outputs.artifact_exists != 'true' - run: | - echo "โ„น๏ธ Skipping Playwright tests - no PR image artifact available" - echo "This is expected for:" - echo " - Pushes to main/release branches" - echo " - PRs where Docker build failed" - echo " - Manual dispatch without PR number" - exit 0 - - - name: Guard triage from coverage/Vite mode - if: steps.check-artifact.outputs.artifact_exists == 'true' - run: | - if [[ "${PLAYWRIGHT_BASE_URL:-}" =~ 5173 ]]; then - echo "โŒ Coverage/Vite base URL is disabled during triage: ${PLAYWRIGHT_BASE_URL}" - exit 1 - fi - case "${PLAYWRIGHT_COVERAGE:-}" in - 1|true|TRUE|True|yes|YES) - echo "โŒ Coverage collection is disabled during triage (PLAYWRIGHT_COVERAGE=${PLAYWRIGHT_COVERAGE})" - exit 1 - ;; - esac - echo "โœ… Coverage/Vite guard passed (PLAYWRIGHT_BASE_URL=${PLAYWRIGHT_BASE_URL:-unset})" - - - name: Log triage environment (non-secret) - if: steps.check-artifact.outputs.artifact_exists == 'true' - run: | - echo "CHARON_EMERGENCY_SERVER_ENABLED=${CHARON_EMERGENCY_SERVER_ENABLED}" - if [[ -n "${CHARON_EMERGENCY_TOKEN:-}" ]]; then - echo "CHARON_EMERGENCY_TOKEN=*** (GitHub secret configured)" - else - echo "CHARON_EMERGENCY_TOKEN not set; container will fall back to image default" - fi - echo "Ports bound: 8080 (app), 2019 (admin), 2020 (tier-2) on IPv4/IPv6 loopback" - echo "PLAYWRIGHT_BASE_URL=${PLAYWRIGHT_BASE_URL}" - - - name: Download PR image artifact - if: steps.check-artifact.outputs.artifact_exists == 'true' - # actions/download-artifact v4.1.8 - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 - with: - name: ${{ steps.pr-info.outputs.is_push == 'true' && 'push-image' || format('pr-image-{0}', steps.pr-info.outputs.pr_number) }} - run-id: ${{ steps.check-artifact.outputs.run_id }} - github-token: ${{ secrets.GITHUB_TOKEN }} - - - name: Load Docker image - if: steps.check-artifact.outputs.artifact_exists == 'true' - run: | - echo "๐Ÿ“ฆ Loading Docker image..." - docker load < charon-pr-image.tar - echo "โœ… Docker image loaded" - docker images | grep charon - - - name: Start Charon container - if: steps.check-artifact.outputs.artifact_exists == 'true' - run: | - echo "๐Ÿš€ Starting Charon container..." - - # Normalize image name (GitHub lowercases repository owner names in GHCR) - IMAGE_NAME=$(echo "${{ github.repository_owner }}/charon" | tr '[:upper:]' '[:lower:]') - if [[ "${{ steps.pr-info.outputs.is_push }}" == "true" ]]; then - # Use sanitized branch name for Docker tag (/ is invalid in tags) - IMAGE_REF="ghcr.io/${IMAGE_NAME}:${{ steps.sanitize.outputs.branch }}" - elif [[ -n "${{ steps.pr-info.outputs.pr_number }}" ]]; then - IMAGE_REF="ghcr.io/${IMAGE_NAME}:pr-${{ steps.pr-info.outputs.pr_number }}" - else - echo "โŒ ERROR: Cannot determine image reference" - echo " - is_push: ${{ steps.pr-info.outputs.is_push }}" - echo " - pr_number: ${{ steps.pr-info.outputs.pr_number }}" - echo " - branch: ${{ steps.sanitize.outputs.branch }}" - echo "" - echo "This can happen when:" - echo " 1. workflow_dispatch without pr_number input" - echo " 2. workflow_run triggered by non-PR, non-push event" - exit 1 - fi - - # Validate the image reference format - if [[ ! "${IMAGE_REF}" =~ ^ghcr\.io/[a-z0-9_-]+/[a-z0-9_-]+:[a-zA-Z0-9._-]+$ ]]; then - echo "โŒ ERROR: Invalid image reference format: ${IMAGE_REF}" - exit 1 - fi - - echo "๐Ÿ“ฆ Starting container with image: ${IMAGE_REF}" - docker run -d \ - --name charon-test \ - -p 8080:8080 \ - -p 127.0.0.1:2019:2019 \ - -p "[::1]:2019:2019" \ - -p 127.0.0.1:2020:2020 \ - -p "[::1]:2020:2020" \ - -e CHARON_ENV="${CHARON_ENV}" \ - -e CHARON_DEBUG="${CHARON_DEBUG}" \ - -e CHARON_ENCRYPTION_KEY="${CHARON_ENCRYPTION_KEY}" \ - -e CHARON_EMERGENCY_TOKEN="${CHARON_EMERGENCY_TOKEN}" \ - -e CHARON_EMERGENCY_SERVER_ENABLED="${CHARON_EMERGENCY_SERVER_ENABLED}" \ - -e CHARON_EMERGENCY_BIND="0.0.0.0:2020" \ - -e CHARON_EMERGENCY_USERNAME="admin" \ - -e CHARON_EMERGENCY_PASSWORD="changeme" \ - -e CHARON_SECURITY_TESTS_ENABLED="true" \ - "${IMAGE_REF}" - - echo "โœ… Container started" - - - name: Wait for health endpoint - if: steps.check-artifact.outputs.artifact_exists == 'true' - run: | - echo "โณ Waiting for Charon to be healthy..." - MAX_ATTEMPTS=30 - ATTEMPT=0 - - while [[ ${ATTEMPT} -lt ${MAX_ATTEMPTS} ]]; do - ATTEMPT=$((ATTEMPT + 1)) - echo "Attempt ${ATTEMPT}/${MAX_ATTEMPTS}..." - - if curl -sf http://localhost:8080/api/v1/health > /dev/null 2>&1; then - echo "โœ… Charon is healthy!" - exit 0 - fi - - sleep 2 - done - - echo "โŒ Health check failed after ${MAX_ATTEMPTS} attempts" - echo "๐Ÿ“‹ Container logs:" - docker logs charon-test - exit 1 - - - name: Setup Node.js - if: steps.check-artifact.outputs.artifact_exists == 'true' - # actions/setup-node v4.1.0 - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 - with: - node-version: 'lts/*' - cache: 'npm' - - - name: Install dependencies - if: steps.check-artifact.outputs.artifact_exists == 'true' - run: npm ci - - - name: Install Playwright browsers - if: steps.check-artifact.outputs.artifact_exists == 'true' - run: npx playwright install --with-deps chromium - - - name: Run Playwright tests - if: steps.check-artifact.outputs.artifact_exists == 'true' - env: - PLAYWRIGHT_BASE_URL: http://localhost:8080 - run: npx playwright test --project=chromium - - - name: Upload Playwright report - if: always() && steps.check-artifact.outputs.artifact_exists == 'true' - # actions/upload-artifact v4.4.3 - uses: actions/upload-artifact@47309c993abb98030a35d55ef7ff34b7fa1074b5 - with: - name: ${{ steps.pr-info.outputs.is_push == 'true' && format('playwright-report-{0}', steps.sanitize.outputs.branch) || format('playwright-report-pr-{0}', steps.pr-info.outputs.pr_number) }} - path: playwright-report/ - retention-days: 14 - - - name: Cleanup - if: always() && steps.check-artifact.outputs.artifact_exists == 'true' - run: | - echo "๐Ÿงน Cleaning up..." - docker stop charon-test 2>/dev/null || true - docker rm charon-test 2>/dev/null || true - echo "โœ… Cleanup complete" diff --git a/backend/internal/api/handlers/import_handler_test.go b/backend/internal/api/handlers/import_handler_test.go index d6c48ba4..bdf44659 100644 --- a/backend/internal/api/handlers/import_handler_test.go +++ b/backend/internal/api/handlers/import_handler_test.go @@ -10,6 +10,7 @@ import ( "path/filepath" "strings" "testing" + "time" "github.com/gin-gonic/gin" "github.com/google/uuid" @@ -1213,3 +1214,408 @@ func TestUpload_NormalizationFallback(t *testing.T) { assert.True(t, ok, "preview should contain hosts") assert.Greater(t, len(hosts), 0, "should have at least one parsed host from original content") } + +// TestCommit_OverwriteAction tests that overwrite preserves certificate ID +func TestCommit_OverwriteAction(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupImportTestDB(t) + + // Create existing host with certificate association + existingHost := models.ProxyHost{ + UUID: uuid.NewString(), + DomainNames: "ssl-site.com", + ForwardHost: "10.0.0.1", + ForwardPort: 80, + CertificateID: ptrToUint(42), // Existing certificate reference + } + db.Create(&existingHost) + + // Create session with host matching existing one + session := models.ImportSession{ + UUID: uuid.NewString(), + Status: "reviewing", + ParsedData: `{ + "hosts": [ + { + "domain_names": "ssl-site.com", + "forward_host": "192.168.1.100", + "forward_port": 8080, + "forward_scheme": "https" + } + ] + }`, + } + db.Create(&session) + + handler := handlers.NewImportHandler(db, "echo", "/tmp", "") + router := gin.New() + router.POST("/import/commit", handler.Commit) + + payload := map[string]any{ + "session_uuid": session.UUID, + "resolutions": map[string]string{ + "ssl-site.com": "overwrite", + }, + } + body, _ := json.Marshal(payload) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/import/commit", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var resp map[string]any + _ = json.Unmarshal(w.Body.Bytes(), &resp) + assert.Equal(t, float64(1), resp["updated"], "should update one host") + + // Verify the host was updated but certificate was preserved + var updatedHost models.ProxyHost + db.Where("domain_names = ?", "ssl-site.com").First(&updatedHost) + assert.Equal(t, "192.168.1.100", updatedHost.ForwardHost, "forward host should be updated") + assert.Equal(t, 8080, updatedHost.ForwardPort, "forward port should be updated") + assert.NotNil(t, updatedHost.CertificateID, "certificate ID should be preserved") + assert.Equal(t, uint(42), *updatedHost.CertificateID, "certificate ID value should be preserved") + assert.Equal(t, existingHost.UUID, updatedHost.UUID, "UUID should be preserved") +} + +// ptrToUint is a helper to create a pointer to uint +func ptrToUint(v uint) *uint { + return &v +} + +// TestCommit_RenameAction tests that rename appends suffix +func TestCommit_RenameAction(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupImportTestDB(t) + + // Create existing host + existingHost := models.ProxyHost{ + UUID: uuid.NewString(), + DomainNames: "app.example.com", + ForwardHost: "10.0.0.1", + ForwardPort: 80, + } + db.Create(&existingHost) + + // Create session with conflicting host + session := models.ImportSession{ + UUID: uuid.NewString(), + Status: "reviewing", + ParsedData: `{ + "hosts": [ + { + "domain_names": "app.example.com", + "forward_host": "192.168.1.100", + "forward_port": 9000, + "forward_scheme": "http" + } + ] + }`, + } + db.Create(&session) + + handler := handlers.NewImportHandler(db, "echo", "/tmp", "") + router := gin.New() + router.POST("/import/commit", handler.Commit) + + payload := map[string]any{ + "session_uuid": session.UUID, + "resolutions": map[string]string{ + "app.example.com": "rename", + }, + } + body, _ := json.Marshal(payload) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/import/commit", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var resp map[string]any + _ = json.Unmarshal(w.Body.Bytes(), &resp) + assert.Equal(t, float64(1), resp["created"], "should create one host with renamed domain") + + // Verify the renamed host was created + var renamedHost models.ProxyHost + err := db.Where("domain_names = ?", "app.example.com-imported").First(&renamedHost).Error + assert.NoError(t, err, "renamed host should exist") + assert.Equal(t, "192.168.1.100", renamedHost.ForwardHost) + assert.Equal(t, 9000, renamedHost.ForwardPort) + + // Verify original host is unchanged + var originalHost models.ProxyHost + err = db.Where("domain_names = ?", "app.example.com").First(&originalHost).Error + assert.NoError(t, err) + assert.Equal(t, "10.0.0.1", originalHost.ForwardHost) +} + +// TestGetPreview_WithConflictDetails tests preview returns detailed conflict info +func TestGetPreview_WithConflictDetails(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupImportTestDB(t) + tmpDir := t.TempDir() + mountPath := filepath.Join(tmpDir, "mounted.caddyfile") + + // Create a mounted Caddyfile + content := "conflict.example.com" + err := os.WriteFile(mountPath, []byte(content), 0o644) //nolint:gosec // G306: test file + assert.NoError(t, err) + + // Pre-create an existing host that conflicts + existingHost := models.ProxyHost{ + UUID: uuid.NewString(), + DomainNames: "conflict.example.com", + ForwardScheme: "http", + ForwardHost: "10.0.0.1", + ForwardPort: 80, + SSLForced: false, + Enabled: true, + } + db.Create(&existingHost) + + // Use fake caddy script that returns the conflicting host + cwd, _ := os.Getwd() + fakeCaddy := filepath.Join(cwd, "testdata", "fake_caddy_conflict.sh") + _ = os.Chmod(fakeCaddy, 0o755) //nolint:gosec // G302: test script needs exec permissions + + handler := handlers.NewImportHandler(db, fakeCaddy, tmpDir, mountPath) + router := gin.New() + router.GET("/import/preview", handler.GetPreview) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/import/preview", http.NoBody) + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var result map[string]any + err = json.Unmarshal(w.Body.Bytes(), &result) + assert.NoError(t, err) + + // Check for conflict_details + conflictDetails, ok := result["conflict_details"].(map[string]any) + assert.True(t, ok, "should have conflict_details") + + if len(conflictDetails) > 0 { + // Verify conflict contains existing and imported info + for domain, details := range conflictDetails { + assert.Equal(t, "conflict.example.com", domain) + detailsMap := details.(map[string]any) + assert.NotNil(t, detailsMap["existing"]) + assert.NotNil(t, detailsMap["imported"]) + } + } +} + +// TestSafeJoin_RejectsPathTraversal tests the safeJoin security function +func TestSafeJoin_PathTraversalCases(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupImportTestDB(t) + tmpDir := t.TempDir() + handler := handlers.NewImportHandler(db, "echo", tmpDir, "") + router := gin.New() + router.POST("/import/upload-multi", handler.UploadMulti) + + tests := []struct { + name string + filename string + expectStatus int + expectErrorIn string + }{ + { + name: "double dot prefix", + filename: "../etc/passwd", + expectStatus: http.StatusBadRequest, + expectErrorIn: "invalid filename", + }, + { + name: "hidden double dot", + filename: "sites/../../../etc/passwd", + expectStatus: http.StatusBadRequest, + expectErrorIn: "invalid filename", + }, + { + name: "absolute path", + filename: "/etc/passwd", + expectStatus: http.StatusBadRequest, + expectErrorIn: "invalid filename", + }, + { + name: "valid nested path", + filename: "sites/site1.conf", + expectStatus: http.StatusOK, // or StatusBadRequest for no hosts, but not path traversal error + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + payload := map[string]any{ + "files": []map[string]string{ + {"filename": "Caddyfile", "content": "import sites/*\n"}, + {"filename": tt.filename, "content": "test content"}, + }, + } + body, _ := json.Marshal(payload) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/import/upload-multi", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + + if tt.expectErrorIn != "" { + assert.Equal(t, tt.expectStatus, w.Code) + var resp map[string]any + _ = json.Unmarshal(w.Body.Bytes(), &resp) + assert.Contains(t, resp["error"], tt.expectErrorIn) + } + }) + } +} + +// TestCommit_SkipAction tests that skip/keep actions work correctly +func TestCommit_SkipAction(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupImportTestDB(t) + + session := models.ImportSession{ + UUID: uuid.NewString(), + Status: "reviewing", + ParsedData: `{ + "hosts": [ + { + "domain_names": "skip-me.com", + "forward_host": "192.168.1.1", + "forward_port": 80, + "forward_scheme": "http" + }, + { + "domain_names": "keep-existing.com", + "forward_host": "192.168.1.2", + "forward_port": 80, + "forward_scheme": "http" + } + ] + }`, + } + db.Create(&session) + + handler := handlers.NewImportHandler(db, "echo", "/tmp", "") + router := gin.New() + router.POST("/import/commit", handler.Commit) + + payload := map[string]any{ + "session_uuid": session.UUID, + "resolutions": map[string]string{ + "skip-me.com": "skip", + "keep-existing.com": "keep", + }, + } + body, _ := json.Marshal(payload) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/import/commit", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var resp map[string]any + _ = json.Unmarshal(w.Body.Bytes(), &resp) + assert.Equal(t, float64(2), resp["skipped"], "should skip two hosts") + assert.Equal(t, float64(0), resp["created"], "should not create any hosts") + + // Verify hosts were not created + var count int64 + db.Model(&models.ProxyHost{}).Where("domain_names IN ?", []string{"skip-me.com", "keep-existing.com"}).Count(&count) + assert.Equal(t, int64(0), count) +} + +// TestCommit_CustomNames tests that custom names are applied +func TestCommit_CustomNames(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupImportTestDB(t) + + session := models.ImportSession{ + UUID: uuid.NewString(), + Status: "reviewing", + ParsedData: `{ + "hosts": [ + { + "domain_names": "api.example.com", + "forward_host": "192.168.1.1", + "forward_port": 3000, + "forward_scheme": "http" + } + ] + }`, + } + db.Create(&session) + + handler := handlers.NewImportHandler(db, "echo", "/tmp", "") + router := gin.New() + router.POST("/import/commit", handler.Commit) + + payload := map[string]any{ + "session_uuid": session.UUID, + "resolutions": map[string]string{ + "api.example.com": "import", + }, + "names": map[string]string{ + "api.example.com": "Production API Server", + }, + } + body, _ := json.Marshal(payload) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/import/commit", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + // Verify the custom name was applied + var host models.ProxyHost + err := db.Where("domain_names = ?", "api.example.com").First(&host).Error + assert.NoError(t, err) + assert.Equal(t, "Production API Server", host.Name) +} + +// TestGetStatus_AlreadyCommittedMount tests that committed mounts show no pending +func TestGetStatus_AlreadyCommittedMount(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupImportTestDB(t) + tmpDir := t.TempDir() + mountPath := filepath.Join(tmpDir, "mounted.caddyfile") + + // Create mounted file + err := os.WriteFile(mountPath, []byte("example.com"), 0o644) //nolint:gosec // G306: test file + assert.NoError(t, err) + + // Create a committed session for this mount with a future time + now := time.Now().Add(1 * time.Hour) // Future time to simulate already committed + committedSession := models.ImportSession{ + UUID: uuid.NewString(), + Status: "committed", + SourceFile: mountPath, + CommittedAt: &now, + } + db.Create(&committedSession) + + handler := handlers.NewImportHandler(db, "echo", tmpDir, mountPath) + router := gin.New() + router.GET("/import/status", handler.GetStatus) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/import/status", http.NoBody) + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var resp map[string]any + _ = json.Unmarshal(w.Body.Bytes(), &resp) + assert.Equal(t, false, resp["has_pending"], "should not show pending for already committed mount") +} diff --git a/backend/internal/api/handlers/testdata/fake_caddy_conflict.sh b/backend/internal/api/handlers/testdata/fake_caddy_conflict.sh new file mode 100755 index 00000000..14abeed6 --- /dev/null +++ b/backend/internal/api/handlers/testdata/fake_caddy_conflict.sh @@ -0,0 +1,14 @@ +#!/bin/sh +if [ "$1" = "version" ]; then + echo "v2.0.0" + exit 0 +fi +if [ "$1" = "fmt" ]; then + exit 0 +fi +if [ "$1" = "adapt" ]; then + # Return a host that conflicts with existing (conflict.example.com) + echo "{\"apps\":{\"http\":{\"servers\":{\"srv0\":{\"routes\":[{\"match\":[{\"host\":[\"conflict.example.com\"]}],\"handle\":[{\"handler\":\"reverse_proxy\",\"upstreams\":[{\"dial\":\"192.168.1.100:9000\"}]}]}]}}}}}" + exit 0 +fi +exit 1 diff --git a/backend/internal/caddy/importer_test.go b/backend/internal/caddy/importer_test.go index 760ec863..32056c54 100644 --- a/backend/internal/caddy/importer_test.go +++ b/backend/internal/caddy/importer_test.go @@ -480,3 +480,334 @@ func TestImporter_NormalizeCaddyfile_ReadError(t *testing.T) { assert.Error(t, err) assert.Contains(t, err.Error(), "failed to read formatted file") } + +// TestParseCaddyfile_PathTraversal tests path traversal rejection +func TestParseCaddyfile_PathTraversal(t *testing.T) { + importer := NewImporter("caddy") + + tests := []struct { + name string + path string + expectError string + }{ + { + name: "double dot prefix", + path: "../etc/passwd", + expectError: "invalid caddyfile path", + }, + { + name: "just double dot", + path: "..", + expectError: "invalid caddyfile path", + }, + { + name: "empty path", + path: "", + expectError: "invalid caddyfile path", + }, + { + name: "dot only", + path: ".", + expectError: "invalid caddyfile path", + }, + { + name: "relative double dot", + path: "foo/../../../etc/passwd", + expectError: "invalid caddyfile path", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := importer.ParseCaddyfile(tt.path) + assert.Error(t, err) + assert.Contains(t, err.Error(), tt.expectError) + }) + } +} + +// TestExtractHosts_WebsocketHandler tests websocket header extraction +func TestExtractHosts_WebsocketHandler(t *testing.T) { + importer := NewImporter("caddy") + + // JSON config with websocket header + websocketJSON := []byte(`{ + "apps": { + "http": { + "servers": { + "srv0": { + "routes": [ + { + "match": [{"host": ["ws.example.com"]}], + "handle": [ + { + "handler": "reverse_proxy", + "upstreams": [{"dial": "127.0.0.1:8080"}], + "headers": { + "Upgrade": ["websocket"] + } + } + ] + } + ] + } + } + } + } + }`) + + result, err := importer.ExtractHosts(websocketJSON) + assert.NoError(t, err) + assert.Len(t, result.Hosts, 1) + assert.Equal(t, "ws.example.com", result.Hosts[0].DomainNames) + assert.True(t, result.Hosts[0].WebsocketSupport, "WebsocketSupport should be true when Upgrade header contains websocket") +} + +// TestExtractHosts_SubrouteHandler tests extraction of handlers from subroutes +func TestExtractHosts_SubrouteHandler(t *testing.T) { + importer := NewImporter("caddy") + + // JSON config with subroute containing reverse_proxy + subrouteJSON := []byte(`{ + "apps": { + "http": { + "servers": { + "srv0": { + "routes": [ + { + "match": [{"host": ["nested.example.com"]}], + "handle": [ + { + "handler": "subroute", + "routes": [ + { + "handle": [ + { + "handler": "reverse_proxy", + "upstreams": [{"dial": "backend:9000"}] + } + ] + } + ] + } + ] + } + ] + } + } + } + } + }`) + + result, err := importer.ExtractHosts(subrouteJSON) + assert.NoError(t, err) + assert.Len(t, result.Hosts, 1) + assert.Equal(t, "nested.example.com", result.Hosts[0].DomainNames) + assert.Equal(t, "backend", result.Hosts[0].ForwardHost) + assert.Equal(t, 9000, result.Hosts[0].ForwardPort) +} + +// TestBackupCaddyfile_PathTraversal tests backup path validation +func TestBackupCaddyfile_PathTraversal(t *testing.T) { + tmpDir := t.TempDir() + backupDir := filepath.Join(tmpDir, "backups") + + tests := []struct { + name string + originalPath string + expectError string + }{ + { + name: "double dot prefix", + originalPath: "../etc/passwd", + expectError: "invalid original path", + }, + { + name: "empty path", + originalPath: "", + expectError: "invalid original path", + }, + { + name: "dot only", + originalPath: ".", + expectError: "invalid original path", + }, + { + name: "relative traversal", + originalPath: "foo/../../../etc/passwd", + expectError: "invalid original path", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := BackupCaddyfile(tt.originalPath, backupDir) + assert.Error(t, err) + assert.Contains(t, err.Error(), tt.expectError) + }) + } +} + +// TestBackupCaddyfile_SourceNotReadable tests error when source can't be read +func TestBackupCaddyfile_SourceNotReadable(t *testing.T) { + tmpDir := t.TempDir() + backupDir := filepath.Join(tmpDir, "backups") + + // Non-existent file + _, err := BackupCaddyfile(filepath.Join(tmpDir, "nonexistent.txt"), backupDir) + assert.Error(t, err) + assert.Contains(t, err.Error(), "reading original file") +} + +// TestExtractHosts_SplitHostPortFallback tests the fallback parsing when net.SplitHostPort fails +func TestExtractHosts_SplitHostPortFallback(t *testing.T) { + // Enable the fallback branch + oldVal := forceSplitFallback + forceSplitFallback = true + defer func() { forceSplitFallback = oldVal }() + + importer := NewImporter("caddy") + + // Test with valid host:port format (fallback should still parse it) + validJSON := []byte(`{ + "apps": { + "http": { + "servers": { + "srv0": { + "routes": [ + { + "match": [{"host": ["fallback.example.com"]}], + "handle": [ + { + "handler": "reverse_proxy", + "upstreams": [{"dial": "backend:3000"}] + } + ] + } + ] + } + } + } + } + }`) + + result, err := importer.ExtractHosts(validJSON) + assert.NoError(t, err) + assert.Len(t, result.Hosts, 1) + assert.Equal(t, "backend", result.Hosts[0].ForwardHost) + assert.Equal(t, 3000, result.Hosts[0].ForwardPort) +} + +// TestExtractHosts_HostOnly tests fallback when dial is just a hostname without port +func TestExtractHosts_HostOnly(t *testing.T) { + // Enable the fallback branch + oldVal := forceSplitFallback + forceSplitFallback = true + defer func() { forceSplitFallback = oldVal }() + + importer := NewImporter("caddy") + + // Test with host only (no port) + hostOnlyJSON := []byte(`{ + "apps": { + "http": { + "servers": { + "srv0": { + "routes": [ + { + "match": [{"host": ["hostonly.example.com"]}], + "handle": [ + { + "handler": "reverse_proxy", + "upstreams": [{"dial": "backend-service"}] + } + ] + } + ] + } + } + } + } + }`) + + result, err := importer.ExtractHosts(hostOnlyJSON) + assert.NoError(t, err) + assert.Len(t, result.Hosts, 1) + assert.Equal(t, "backend-service", result.Hosts[0].ForwardHost) + assert.Equal(t, 80, result.Hosts[0].ForwardPort, "Should default to port 80") +} + +// TestExtractHosts_InvalidPortFallback tests fallback when port is invalid +func TestExtractHosts_InvalidPortFallback(t *testing.T) { + // Enable the fallback branch + oldVal := forceSplitFallback + forceSplitFallback = true + defer func() { forceSplitFallback = oldVal }() + + importer := NewImporter("caddy") + + // Test with invalid port + invalidPortJSON := []byte(`{ + "apps": { + "http": { + "servers": { + "srv0": { + "routes": [ + { + "match": [{"host": ["invalidport.example.com"]}], + "handle": [ + { + "handler": "reverse_proxy", + "upstreams": [{"dial": "backend:invalidport"}] + } + ] + } + ] + } + } + } + } + }`) + + result, err := importer.ExtractHosts(invalidPortJSON) + assert.NoError(t, err) + assert.Len(t, result.Hosts, 1) + assert.Equal(t, "backend", result.Hosts[0].ForwardHost) + assert.Equal(t, 80, result.Hosts[0].ForwardPort, "Should default to port 80 on invalid port") +} + +// TestExtractHosts_TLSConnectionPolicies tests SSL detection via TLS connection policies +func TestExtractHosts_TLSConnectionPolicies(t *testing.T) { + importer := NewImporter("caddy") + + // JSON config with TLS connection policies set + tlsJSON := []byte(`{ + "apps": { + "http": { + "servers": { + "srv0": { + "tls_connection_policies": [{"alpn": ["h2", "http/1.1"]}], + "routes": [ + { + "match": [{"host": ["secure.example.com"]}], + "handle": [ + { + "handler": "reverse_proxy", + "upstreams": [{"dial": "127.0.0.1:8443"}] + } + ] + } + ] + } + } + } + } + }`) + + result, err := importer.ExtractHosts(tlsJSON) + assert.NoError(t, err) + assert.Len(t, result.Hosts, 1) + assert.Equal(t, "secure.example.com", result.Hosts[0].DomainNames) + assert.True(t, result.Hosts[0].SSLForced, "SSLForced should be true when tls_connection_policies is set") + assert.Equal(t, "https", result.Hosts[0].ForwardScheme, "ForwardScheme should be https for SSL") +} diff --git a/frontend/src/hooks/__tests__/useImport.test.tsx b/frontend/src/hooks/__tests__/useImport.test.tsx index d087c105..1eac0ff7 100644 --- a/frontend/src/hooks/__tests__/useImport.test.tsx +++ b/frontend/src/hooks/__tests__/useImport.test.tsx @@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { renderHook, waitFor, act } from '@testing-library/react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import React from 'react' -import { useImport } from '../useImport' +import { useImport, QUERY_KEY } from '../useImport' import * as api from '../../api/import' // Mock the API @@ -14,7 +14,25 @@ vi.mock('../../api/import', () => ({ getImportStatus: vi.fn(), })) -const createWrapper = () => { +// Create wrapper with query client that we can inspect +const createWrapper = (queryClient?: QueryClient) => { + const client = queryClient ?? new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + return { + queryClient: client, + wrapper: ({ children }: { children: React.ReactNode }) => ( + {children} + ) + } +} + +// Legacy wrapper for backward compatibility +const createSimpleWrapper = () => { const queryClient = new QueryClient({ defaultOptions: { queries: { @@ -37,312 +55,933 @@ describe('useImport', () => { vi.clearAllMocks() }) - it('starts with no active session', async () => { - const { result } = renderHook(() => useImport(), { wrapper: createWrapper() }) + describe('basic operations', () => { + it('starts with no active session', async () => { + const { result } = renderHook(() => useImport(), { wrapper: createSimpleWrapper() }) - await waitFor(() => { + await waitFor(() => { + expect(result.current.loading).toBe(false) + expect(result.current.session).toBeNull() + }) + expect(result.current.error).toBeNull() + }) + + it('uploads content and creates session', async () => { + const mockSession = { + id: 'session-1', + state: 'reviewing' as const, + created_at: '2025-01-18T10:00:00Z', + updated_at: '2025-01-18T10:00:00Z', + } + + const mockPreviewData = { + hosts: [{ domain_names: 'test.com' }], + conflicts: [], + errors: [], + } + + const mockResponse = { + session: mockSession, + preview: mockPreviewData, + } + + vi.mocked(api.uploadCaddyfile).mockResolvedValue(mockResponse) + vi.mocked(api.getImportStatus).mockResolvedValue({ has_pending: true, session: mockSession }) + vi.mocked(api.getImportPreview).mockResolvedValue(mockResponse) + + const { result } = renderHook(() => useImport(), { wrapper: createSimpleWrapper() }) + + await act(async () => { + await result.current.upload('example.com { reverse_proxy localhost:8080 }') + }) + + await waitFor(() => { + expect(result.current.session).toEqual(mockSession) + }) + + expect(api.uploadCaddyfile).toHaveBeenCalledWith('example.com { reverse_proxy localhost:8080 }') expect(result.current.loading).toBe(false) - expect(result.current.session).toBeNull() - }) - expect(result.current.error).toBeNull() - }) - - it('uploads content and creates session', async () => { - const mockSession = { - id: 'session-1', - state: 'reviewing' as const, - created_at: '2025-01-18T10:00:00Z', - updated_at: '2025-01-18T10:00:00Z', - } - - const mockPreviewData = { - hosts: [{ domain_names: 'test.com' }], - conflicts: [], - errors: [], - } - - const mockResponse = { - session: mockSession, - preview: mockPreviewData, - } - - vi.mocked(api.uploadCaddyfile).mockResolvedValue(mockResponse) - vi.mocked(api.getImportStatus).mockResolvedValue({ has_pending: true, session: mockSession }) - vi.mocked(api.getImportPreview).mockResolvedValue(mockResponse) - - const { result } = renderHook(() => useImport(), { wrapper: createWrapper() }) - - await act(async () => { - await result.current.upload('example.com { reverse_proxy localhost:8080 }') }) - await waitFor(() => { - expect(result.current.session).toEqual(mockSession) + it('handles upload errors', async () => { + const mockError = new Error('Upload failed') + vi.mocked(api.uploadCaddyfile).mockRejectedValue(mockError) + + const { result } = renderHook(() => useImport(), { wrapper: createSimpleWrapper() }) + + let threw = false + await act(async () => { + try { + await result.current.upload('invalid') + } catch { + threw = true + } + }) + expect(threw).toBe(true) + + await waitFor(() => { + expect(result.current.error).toBe('Upload failed') + }) }) - expect(api.uploadCaddyfile).toHaveBeenCalledWith('example.com { reverse_proxy localhost:8080 }') - expect(result.current.loading).toBe(false) - }) - - it('handles upload errors', async () => { - const mockError = new Error('Upload failed') - vi.mocked(api.uploadCaddyfile).mockRejectedValue(mockError) - - const { result } = renderHook(() => useImport(), { wrapper: createWrapper() }) - - let threw = false - await act(async () => { - try { - await result.current.upload('invalid') - } catch { - threw = true + it('commits import with resolutions', async () => { + const mockSession = { + id: 'session-2', + state: 'reviewing' as const, + created_at: '2025-01-18T10:00:00Z', + updated_at: '2025-01-18T10:00:00Z', } - }) - expect(threw).toBe(true) - await waitFor(() => { - expect(result.current.error).toBe('Upload failed') - }) - }) + const mockResponse = { + session: mockSession, + preview: { hosts: [], conflicts: [], errors: [] }, + } - it('commits import with resolutions', async () => { - const mockSession = { - id: 'session-2', - state: 'reviewing' as const, - created_at: '2025-01-18T10:00:00Z', - updated_at: '2025-01-18T10:00:00Z', - } + let isCommitted = false + vi.mocked(api.uploadCaddyfile).mockResolvedValue(mockResponse) + vi.mocked(api.getImportStatus).mockImplementation(async () => { + if (isCommitted) return { has_pending: false } + return { has_pending: true, session: mockSession } + }) + vi.mocked(api.getImportPreview).mockResolvedValue(mockResponse) + vi.mocked(api.commitImport).mockImplementation(async () => { + isCommitted = true + return { created: 0, updated: 0, skipped: 0, errors: [] } + }) - const mockResponse = { - session: mockSession, - preview: { hosts: [], conflicts: [], errors: [] }, - } + const { result } = renderHook(() => useImport(), { wrapper: createSimpleWrapper() }) - let isCommitted = false - vi.mocked(api.uploadCaddyfile).mockResolvedValue(mockResponse) - vi.mocked(api.getImportStatus).mockImplementation(async () => { - if (isCommitted) return { has_pending: false } - return { has_pending: true, session: mockSession } - }) - vi.mocked(api.getImportPreview).mockResolvedValue(mockResponse) - vi.mocked(api.commitImport).mockImplementation(async () => { - isCommitted = true - return { created: 0, updated: 0, skipped: 0, errors: [] } + await act(async () => { + await result.current.upload('test') + }) + + await waitFor(() => { + expect(result.current.session).toEqual(mockSession) + }) + + await act(async () => { + await result.current.commit({ 'test.com': 'skip' }, { 'test.com': 'Test' }) + }) + + expect(api.commitImport).toHaveBeenCalledWith('session-2', { 'test.com': 'skip' }, { 'test.com': 'Test' }) + + await waitFor(() => { + expect(result.current.session).toBeNull() + }) }) - const { result } = renderHook(() => useImport(), { wrapper: createWrapper() }) + it('cancels active import session', async () => { + const mockSession = { + id: 'session-3', + state: 'reviewing' as const, + created_at: '2025-01-18T10:00:00Z', + updated_at: '2025-01-18T10:00:00Z', + } - await act(async () => { - await result.current.upload('test') + const mockResponse = { + session: mockSession, + preview: { hosts: [], conflicts: [], errors: [] }, + } + + let isCancelled = false + vi.mocked(api.uploadCaddyfile).mockResolvedValue(mockResponse) + vi.mocked(api.getImportStatus).mockImplementation(async () => { + if (isCancelled) return { has_pending: false } + return { has_pending: true, session: mockSession } + }) + vi.mocked(api.getImportPreview).mockResolvedValue(mockResponse) + vi.mocked(api.cancelImport).mockImplementation(async () => { + isCancelled = true + }) + + const { result } = renderHook(() => useImport(), { wrapper: createSimpleWrapper() }) + + await act(async () => { + await result.current.upload('test') + }) + + await waitFor(() => { + expect(result.current.session).toEqual(mockSession) + }) + + await act(async () => { + await result.current.cancel() + }) + + expect(api.cancelImport).toHaveBeenCalled() + await waitFor(() => { + expect(result.current.session).toBeNull() + }) }) - await waitFor(() => { - expect(result.current.session).toEqual(mockSession) + it('handles commit errors', async () => { + const mockSession = { + id: 'session-4', + state: 'reviewing' as const, + created_at: '2025-01-18T10:00:00Z', + updated_at: '2025-01-18T10:00:00Z', + } + + const mockResponse = { + session: mockSession, + preview: { hosts: [], conflicts: [], errors: [] }, + } + + vi.mocked(api.uploadCaddyfile).mockResolvedValue(mockResponse) + vi.mocked(api.getImportStatus).mockResolvedValue({ has_pending: true, session: mockSession }) + vi.mocked(api.getImportPreview).mockResolvedValue(mockResponse) + + const mockError = new Error('Commit failed') + vi.mocked(api.commitImport).mockRejectedValue(mockError) + + const { result } = renderHook(() => useImport(), { wrapper: createSimpleWrapper() }) + + await act(async () => { + await result.current.upload('test') + }) + + await waitFor(() => { + expect(result.current.session).toEqual(mockSession) + }) + + let threw = false + await act(async () => { + try { + await result.current.commit({}, {}) + } catch { + threw = true + } + }) + expect(threw).toBe(true) + + await waitFor(() => { + expect(result.current.error).toBe('Commit failed') + }) }) - await act(async () => { - await result.current.commit({ 'test.com': 'skip' }, { 'test.com': 'Test' }) - }) + it('captures and exposes commit result on success', async () => { + const mockSession = { + id: 'session-5', + state: 'reviewing' as const, + created_at: '2025-01-18T10:00:00Z', + updated_at: '2025-01-18T10:00:00Z', + } - expect(api.commitImport).toHaveBeenCalledWith('session-2', { 'test.com': 'skip' }, { 'test.com': 'Test' }) + const mockResponse = { + session: mockSession, + preview: { hosts: [], conflicts: [], errors: [] }, + } - await waitFor(() => { - expect(result.current.session).toBeNull() - }) - }) + const mockCommitResult = { + created: 3, + updated: 1, + skipped: 2, + errors: [], + } - it('cancels active import session', async () => { - const mockSession = { - id: 'session-3', - state: 'reviewing' as const, - created_at: '2025-01-18T10:00:00Z', - updated_at: '2025-01-18T10:00:00Z', - } + let isCommitted = false + vi.mocked(api.uploadCaddyfile).mockResolvedValue(mockResponse) + vi.mocked(api.getImportStatus).mockImplementation(async () => { + if (isCommitted) return { has_pending: false } + return { has_pending: true, session: mockSession } + }) + vi.mocked(api.getImportPreview).mockResolvedValue(mockResponse) + vi.mocked(api.commitImport).mockImplementation(async () => { + isCommitted = true + return mockCommitResult + }) - const mockResponse = { - session: mockSession, - preview: { hosts: [], conflicts: [], errors: [] }, - } + const { result } = renderHook(() => useImport(), { wrapper: createSimpleWrapper() }) - let isCancelled = false - vi.mocked(api.uploadCaddyfile).mockResolvedValue(mockResponse) - vi.mocked(api.getImportStatus).mockImplementation(async () => { - if (isCancelled) return { has_pending: false } - return { has_pending: true, session: mockSession } - }) - vi.mocked(api.getImportPreview).mockResolvedValue(mockResponse) - vi.mocked(api.cancelImport).mockImplementation(async () => { - isCancelled = true - }) + await act(async () => { + await result.current.upload('test') + }) - const { result } = renderHook(() => useImport(), { wrapper: createWrapper() }) + await waitFor(() => { + expect(result.current.session).toEqual(mockSession) + }) - await act(async () => { - await result.current.upload('test') - }) - - await waitFor(() => { - expect(result.current.session).toEqual(mockSession) - }) - - await act(async () => { - await result.current.cancel() - }) - - expect(api.cancelImport).toHaveBeenCalled() - await waitFor(() => { - expect(result.current.session).toBeNull() - }) - }) - - it('handles commit errors', async () => { - const mockSession = { - id: 'session-4', - state: 'reviewing' as const, - created_at: '2025-01-18T10:00:00Z', - updated_at: '2025-01-18T10:00:00Z', - } - - const mockResponse = { - session: mockSession, - preview: { hosts: [], conflicts: [], errors: [] }, - } - - vi.mocked(api.uploadCaddyfile).mockResolvedValue(mockResponse) - vi.mocked(api.getImportStatus).mockResolvedValue({ has_pending: true, session: mockSession }) - vi.mocked(api.getImportPreview).mockResolvedValue(mockResponse) - - const mockError = new Error('Commit failed') - vi.mocked(api.commitImport).mockRejectedValue(mockError) - - const { result } = renderHook(() => useImport(), { wrapper: createWrapper() }) - - await act(async () => { - await result.current.upload('test') - }) - - await waitFor(() => { - expect(result.current.session).toEqual(mockSession) - }) - - let threw = false - await act(async () => { - try { + await act(async () => { await result.current.commit({}, {}) - } catch { - threw = true + }) + + expect(result.current.commitResult).toEqual(mockCommitResult) + expect(result.current.commitSuccess).toBe(true) + }) + + it('clears commit result when clearCommitResult is called', async () => { + const mockSession = { + id: 'session-6', + state: 'reviewing' as const, + created_at: '2025-01-18T10:00:00Z', + updated_at: '2025-01-18T10:00:00Z', } - }) - expect(threw).toBe(true) - await waitFor(() => { - expect(result.current.error).toBe('Commit failed') + const mockResponse = { + session: mockSession, + preview: { hosts: [], conflicts: [], errors: [] }, + } + + const mockCommitResult = { + created: 2, + updated: 0, + skipped: 0, + errors: [], + } + + let isCommitted = false + vi.mocked(api.uploadCaddyfile).mockResolvedValue(mockResponse) + vi.mocked(api.getImportStatus).mockImplementation(async () => { + if (isCommitted) return { has_pending: false } + return { has_pending: true, session: mockSession } + }) + vi.mocked(api.getImportPreview).mockResolvedValue(mockResponse) + vi.mocked(api.commitImport).mockImplementation(async () => { + isCommitted = true + return mockCommitResult + }) + + const { result } = renderHook(() => useImport(), { wrapper: createSimpleWrapper() }) + + await act(async () => { + await result.current.upload('test') + }) + + await waitFor(() => { + expect(result.current.session).toEqual(mockSession) + }) + + await act(async () => { + await result.current.commit({}, {}) + }) + + expect(result.current.commitResult).toEqual(mockCommitResult) + + act(() => { + result.current.clearCommitResult() + }) + + expect(result.current.commitResult).toBeNull() + expect(result.current.commitSuccess).toBe(false) }) }) - it('captures and exposes commit result on success', async () => { - const mockSession = { - id: 'session-5', - state: 'reviewing' as const, - created_at: '2025-01-18T10:00:00Z', - updated_at: '2025-01-18T10:00:00Z', - } + describe('status query polling', () => { + it('should not poll when session is null', async () => { + vi.mocked(api.getImportStatus).mockResolvedValue({ has_pending: false }) - const mockResponse = { - session: mockSession, - preview: { hosts: [], conflicts: [], errors: [] }, - } + const { result } = renderHook(() => useImport(), { wrapper: createSimpleWrapper() }) - const mockCommitResult = { - created: 3, - updated: 1, - skipped: 2, - errors: [], - } + await waitFor(() => { + expect(result.current.loading).toBe(false) + }) - let isCommitted = false - vi.mocked(api.uploadCaddyfile).mockResolvedValue(mockResponse) - vi.mocked(api.getImportStatus).mockImplementation(async () => { - if (isCommitted) return { has_pending: false } - return { has_pending: true, session: mockSession } - }) - vi.mocked(api.getImportPreview).mockResolvedValue(mockResponse) - vi.mocked(api.commitImport).mockImplementation(async () => { - isCommitted = true - return mockCommitResult + // Initial call should happen + expect(api.getImportStatus).toHaveBeenCalledTimes(1) + + // Wait and verify no additional polls (since there's no session) + await new Promise(resolve => setTimeout(resolve, 100)) + expect(api.getImportStatus).toHaveBeenCalledTimes(1) }) - const { result } = renderHook(() => useImport(), { wrapper: createWrapper() }) + it('should poll status only when session state is reviewing', async () => { + const mockSession = { + id: 'session-poll', + state: 'reviewing' as const, + created_at: '2025-01-18T10:00:00Z', + updated_at: '2025-01-18T10:00:00Z', + } - await act(async () => { - await result.current.upload('test') + vi.mocked(api.getImportStatus).mockResolvedValue({ has_pending: true, session: mockSession }) + vi.mocked(api.getImportPreview).mockResolvedValue({ + session: mockSession, + preview: { hosts: [], conflicts: [], errors: [] }, + }) + + const { queryClient, wrapper } = createWrapper(new QueryClient({ + defaultOptions: { + queries: { + retry: false, + // Shorter refetch interval for testing + }, + }, + })) + + renderHook(() => useImport(), { wrapper }) + + await waitFor(() => { + expect(api.getImportStatus).toHaveBeenCalled() + }) + + // Verify the query is configured (we test the return value logic) + const queryState = queryClient.getQueryState(QUERY_KEY) + expect(queryState?.data).toEqual({ has_pending: true, session: mockSession }) }) - await waitFor(() => { - expect(result.current.session).toEqual(mockSession) - }) + it('should not poll when session state is transient', async () => { + const mockSession = { + id: 'session-transient', + state: 'transient' as const, + created_at: '2025-01-18T10:00:00Z', + updated_at: '2025-01-18T10:00:00Z', + } - await act(async () => { - await result.current.commit({}, {}) - }) + vi.mocked(api.getImportStatus).mockResolvedValue({ has_pending: true, session: mockSession }) + vi.mocked(api.getImportPreview).mockResolvedValue({ + session: mockSession, + preview: { hosts: [], conflicts: [], errors: [] }, + }) - expect(result.current.commitResult).toEqual(mockCommitResult) - expect(result.current.commitSuccess).toBe(true) + const { result } = renderHook(() => useImport(), { wrapper: createSimpleWrapper() }) + + await waitFor(() => { + expect(result.current.session).toEqual(mockSession) + }) + + // Initial call + preview query should happen + expect(api.getImportStatus).toHaveBeenCalledTimes(1) + }) }) - it('clears commit result when clearCommitResult is called', async () => { - const mockSession = { - id: 'session-6', - state: 'reviewing' as const, - created_at: '2025-01-18T10:00:00Z', - updated_at: '2025-01-18T10:00:00Z', - } + describe('preview query behavior', () => { + it('should enable preview query when session has uuid', async () => { + const mockSession = { + id: 'session-preview', + state: 'reviewing' as const, + created_at: '2025-01-18T10:00:00Z', + updated_at: '2025-01-18T10:00:00Z', + } - const mockResponse = { - session: mockSession, - preview: { hosts: [], conflicts: [], errors: [] }, - } + const mockPreview = { + session: mockSession, + preview: { hosts: [{ domain_names: 'example.com' }], conflicts: [], errors: [] }, + } - const mockCommitResult = { - created: 2, - updated: 0, - skipped: 0, - errors: [], - } + vi.mocked(api.getImportStatus).mockResolvedValue({ has_pending: true, session: mockSession }) + vi.mocked(api.getImportPreview).mockResolvedValue(mockPreview) - let isCommitted = false - vi.mocked(api.uploadCaddyfile).mockResolvedValue(mockResponse) - vi.mocked(api.getImportStatus).mockImplementation(async () => { - if (isCommitted) return { has_pending: false } - return { has_pending: true, session: mockSession } - }) - vi.mocked(api.getImportPreview).mockResolvedValue(mockResponse) - vi.mocked(api.commitImport).mockImplementation(async () => { - isCommitted = true - return mockCommitResult + const { result } = renderHook(() => useImport(), { wrapper: createSimpleWrapper() }) + + await waitFor(() => { + expect(result.current.session).toEqual(mockSession) + }) + + // Preview query should be called when session is active + await waitFor(() => { + expect(api.getImportPreview).toHaveBeenCalled() + }) }) - const { result } = renderHook(() => useImport(), { wrapper: createWrapper() }) + it('should disable preview query after commit succeeds', async () => { + const mockSession = { + id: 'session-disable-preview', + state: 'reviewing' as const, + created_at: '2025-01-18T10:00:00Z', + updated_at: '2025-01-18T10:00:00Z', + } - await act(async () => { - await result.current.upload('test') + const mockResponse = { + session: mockSession, + preview: { hosts: [], conflicts: [], errors: [] }, + } + + let commitCount = 0 + let previewCallsAfterCommit = 0 + + vi.mocked(api.uploadCaddyfile).mockResolvedValue(mockResponse) + vi.mocked(api.getImportStatus).mockImplementation(async () => { + return { has_pending: commitCount === 0, session: commitCount === 0 ? mockSession : undefined } + }) + vi.mocked(api.getImportPreview).mockImplementation(async () => { + if (commitCount > 0) previewCallsAfterCommit++ + return mockResponse + }) + vi.mocked(api.commitImport).mockImplementation(async () => { + commitCount++ + return { created: 1, updated: 0, skipped: 0, errors: [] } + }) + + const { result } = renderHook(() => useImport(), { wrapper: createSimpleWrapper() }) + + await act(async () => { + await result.current.upload('test') + }) + + await waitFor(() => { + expect(result.current.session).toEqual(mockSession) + }) + + await act(async () => { + await result.current.commit({}, {}) + }) + + expect(result.current.commitSuccess).toBe(true) + + // Preview query should not be called again after commit + await new Promise(resolve => setTimeout(resolve, 100)) + expect(previewCallsAfterCommit).toBe(0) }) + }) - await waitFor(() => { + describe('upload mutation behavior', () => { + it('should store preview from upload response immediately', async () => { + const mockSession = { + id: 'session-immediate', + state: 'reviewing' as const, + created_at: '2025-01-18T10:00:00Z', + updated_at: '2025-01-18T10:00:00Z', + } + + const mockPreviewData = { + hosts: [{ domain_names: 'immediate-test.com' }], + conflicts: [], + errors: [], + } + + const mockResponse = { + session: mockSession, + preview: mockPreviewData, + } + + vi.mocked(api.uploadCaddyfile).mockResolvedValue(mockResponse) + // Delay status query to simulate race condition + vi.mocked(api.getImportStatus).mockImplementation(async () => { + await new Promise(resolve => setTimeout(resolve, 500)) + return { has_pending: true, session: mockSession } + }) + vi.mocked(api.getImportPreview).mockImplementation(async () => { + await new Promise(resolve => setTimeout(resolve, 500)) + return mockResponse + }) + + const { result } = renderHook(() => useImport(), { wrapper: createSimpleWrapper() }) + + await act(async () => { + await result.current.upload('test') + }) + + // Preview should be available immediately from upload response, not waiting for queries expect(result.current.session).toEqual(mockSession) + expect(result.current.preview).toEqual(mockResponse) }) - await act(async () => { - await result.current.commit({}, {}) + it('should invalidate status query on upload success', async () => { + const mockSession = { + id: 'session-invalidate', + state: 'reviewing' as const, + created_at: '2025-01-18T10:00:00Z', + updated_at: '2025-01-18T10:00:00Z', + } + + const mockResponse = { + session: mockSession, + preview: { hosts: [], conflicts: [], errors: [] }, + } + + vi.mocked(api.uploadCaddyfile).mockResolvedValue(mockResponse) + vi.mocked(api.getImportStatus).mockResolvedValue({ has_pending: true, session: mockSession }) + vi.mocked(api.getImportPreview).mockResolvedValue(mockResponse) + + const { queryClient, wrapper } = createWrapper() + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') + + const { result } = renderHook(() => useImport(), { wrapper }) + + await act(async () => { + await result.current.upload('test content') + }) + + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: QUERY_KEY }) + }) + }) + + describe('commit mutation behavior', () => { + it('should invalidate proxy-hosts cache on commit success', async () => { + const mockSession = { + id: 'session-proxy-cache', + state: 'reviewing' as const, + created_at: '2025-01-18T10:00:00Z', + updated_at: '2025-01-18T10:00:00Z', + } + + const mockResponse = { + session: mockSession, + preview: { hosts: [], conflicts: [], errors: [] }, + } + + vi.mocked(api.uploadCaddyfile).mockResolvedValue(mockResponse) + vi.mocked(api.getImportStatus).mockResolvedValue({ has_pending: true, session: mockSession }) + vi.mocked(api.getImportPreview).mockResolvedValue(mockResponse) + vi.mocked(api.commitImport).mockResolvedValue({ created: 1, updated: 0, skipped: 0, errors: [] }) + + const { queryClient, wrapper } = createWrapper() + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') + + const { result } = renderHook(() => useImport(), { wrapper }) + + await act(async () => { + await result.current.upload('test') + }) + + await waitFor(() => { + expect(result.current.session).toEqual(mockSession) + }) + + await act(async () => { + await result.current.commit({}, {}) + }) + + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['proxy-hosts'] }) }) - expect(result.current.commitResult).toEqual(mockCommitResult) + it('should set commitSucceeded flag on commit success', async () => { + const mockSession = { + id: 'session-flag', + state: 'reviewing' as const, + created_at: '2025-01-18T10:00:00Z', + updated_at: '2025-01-18T10:00:00Z', + } - act(() => { - result.current.clearCommitResult() + const mockResponse = { + session: mockSession, + preview: { hosts: [], conflicts: [], errors: [] }, + } + + vi.mocked(api.uploadCaddyfile).mockResolvedValue(mockResponse) + vi.mocked(api.getImportStatus).mockResolvedValue({ has_pending: true, session: mockSession }) + vi.mocked(api.getImportPreview).mockResolvedValue(mockResponse) + vi.mocked(api.commitImport).mockResolvedValue({ created: 1, updated: 0, skipped: 0, errors: [] }) + + const { result } = renderHook(() => useImport(), { wrapper: createSimpleWrapper() }) + + await act(async () => { + await result.current.upload('test') + }) + + expect(result.current.commitSuccess).toBe(false) + + await act(async () => { + await result.current.commit({}, {}) + }) + + expect(result.current.commitSuccess).toBe(true) }) - expect(result.current.commitResult).toBeNull() - expect(result.current.commitSuccess).toBe(false) + it('should store commit result on success', async () => { + const mockSession = { + id: 'session-result', + state: 'reviewing' as const, + created_at: '2025-01-18T10:00:00Z', + updated_at: '2025-01-18T10:00:00Z', + } + + const mockResponse = { + session: mockSession, + preview: { hosts: [], conflicts: [], errors: [] }, + } + + const expectedResult = { created: 5, updated: 2, skipped: 1, errors: [] } + + vi.mocked(api.uploadCaddyfile).mockResolvedValue(mockResponse) + vi.mocked(api.getImportStatus).mockResolvedValue({ has_pending: true, session: mockSession }) + vi.mocked(api.getImportPreview).mockResolvedValue(mockResponse) + vi.mocked(api.commitImport).mockResolvedValue(expectedResult) + + const { result } = renderHook(() => useImport(), { wrapper: createSimpleWrapper() }) + + await act(async () => { + await result.current.upload('test') + }) + + await act(async () => { + await result.current.commit({}, {}) + }) + + expect(result.current.commitResult).toEqual(expectedResult) + }) + }) + + describe('cancel mutation behavior', () => { + it('should remove preview query on cancel success', async () => { + const mockSession = { + id: 'session-cancel-preview', + state: 'reviewing' as const, + created_at: '2025-01-18T10:00:00Z', + updated_at: '2025-01-18T10:00:00Z', + } + + const mockResponse = { + session: mockSession, + preview: { hosts: [], conflicts: [], errors: [] }, + } + + vi.mocked(api.uploadCaddyfile).mockResolvedValue(mockResponse) + vi.mocked(api.getImportStatus).mockResolvedValue({ has_pending: true, session: mockSession }) + vi.mocked(api.getImportPreview).mockResolvedValue(mockResponse) + vi.mocked(api.cancelImport).mockResolvedValue(undefined) + + const { queryClient, wrapper } = createWrapper() + const removeSpy = vi.spyOn(queryClient, 'removeQueries') + + const { result } = renderHook(() => useImport(), { wrapper }) + + await act(async () => { + await result.current.upload('test') + }) + + await waitFor(() => { + expect(result.current.session).toEqual(mockSession) + }) + + await act(async () => { + await result.current.cancel() + }) + + expect(removeSpy).toHaveBeenCalledWith({ queryKey: ['import-preview'] }) + }) + + it('should invalidate status query on cancel success', async () => { + const mockSession = { + id: 'session-cancel-status', + state: 'reviewing' as const, + created_at: '2025-01-18T10:00:00Z', + updated_at: '2025-01-18T10:00:00Z', + } + + const mockResponse = { + session: mockSession, + preview: { hosts: [], conflicts: [], errors: [] }, + } + + let cancelled = false + vi.mocked(api.uploadCaddyfile).mockResolvedValue(mockResponse) + vi.mocked(api.getImportStatus).mockImplementation(async () => { + if (cancelled) return { has_pending: false } + return { has_pending: true, session: mockSession } + }) + vi.mocked(api.getImportPreview).mockResolvedValue(mockResponse) + vi.mocked(api.cancelImport).mockImplementation(async () => { + cancelled = true + }) + + const { queryClient, wrapper } = createWrapper() + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') + + const { result } = renderHook(() => useImport(), { wrapper }) + + await act(async () => { + await result.current.upload('test') + }) + + await waitFor(() => { + expect(result.current.session).toEqual(mockSession) + }) + + await act(async () => { + await result.current.cancel() + }) + + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: QUERY_KEY }) + }) + }) + + describe('error handling', () => { + it('should aggregate errors from mutations', async () => { + const mockError = new Error('Status check failed') + vi.mocked(api.getImportStatus).mockRejectedValue(mockError) + + const { result } = renderHook(() => useImport(), { wrapper: createSimpleWrapper() }) + + await waitFor(() => { + expect(result.current.error).toBe('Status check failed') + }) + }) + + it('should exclude preview error after commit succeeded', async () => { + const mockSession = { + id: 'session-error-exclude', + state: 'reviewing' as const, + created_at: '2025-01-18T10:00:00Z', + updated_at: '2025-01-18T10:00:00Z', + } + + const mockResponse = { + session: mockSession, + preview: { hosts: [], conflicts: [], errors: [] }, + } + + vi.mocked(api.uploadCaddyfile).mockResolvedValue(mockResponse) + vi.mocked(api.getImportStatus).mockImplementation(async () => { + return { has_pending: false } + }) + vi.mocked(api.getImportPreview).mockRejectedValue(new Error('Preview not found')) + vi.mocked(api.commitImport).mockResolvedValue({ created: 1, updated: 0, skipped: 0, errors: [] }) + + const { result } = renderHook(() => useImport(), { wrapper: createSimpleWrapper() }) + + await act(async () => { + await result.current.upload('test') + }) + + await act(async () => { + await result.current.commit({}, {}) + }) + + // After commit succeeds, preview error should not be shown + expect(result.current.commitSuccess).toBe(true) + expect(result.current.error).toBeNull() + }) + + it('should handle cancel errors', async () => { + const mockSession = { + id: 'session-cancel-error', + state: 'reviewing' as const, + created_at: '2025-01-18T10:00:00Z', + updated_at: '2025-01-18T10:00:00Z', + } + + const mockResponse = { + session: mockSession, + preview: { hosts: [], conflicts: [], errors: [] }, + } + + vi.mocked(api.uploadCaddyfile).mockResolvedValue(mockResponse) + vi.mocked(api.getImportStatus).mockResolvedValue({ has_pending: true, session: mockSession }) + vi.mocked(api.getImportPreview).mockResolvedValue(mockResponse) + vi.mocked(api.cancelImport).mockRejectedValue(new Error('Cancel failed')) + + const { result } = renderHook(() => useImport(), { wrapper: createSimpleWrapper() }) + + await act(async () => { + await result.current.upload('test') + }) + + let threw = false + await act(async () => { + try { + await result.current.cancel() + } catch { + threw = true + } + }) + expect(threw).toBe(true) + + await waitFor(() => { + expect(result.current.error).toBe('Cancel failed') + }) + }) + }) + + describe('state management', () => { + it('should clear commit result and reset state on clearCommitResult', async () => { + const mockSession = { + id: 'session-clear', + state: 'reviewing' as const, + created_at: '2025-01-18T10:00:00Z', + updated_at: '2025-01-18T10:00:00Z', + } + + const mockResponse = { + session: mockSession, + preview: { hosts: [], conflicts: [], errors: [] }, + } + + vi.mocked(api.uploadCaddyfile).mockResolvedValue(mockResponse) + vi.mocked(api.getImportStatus).mockResolvedValue({ has_pending: false }) + vi.mocked(api.getImportPreview).mockResolvedValue(mockResponse) + vi.mocked(api.commitImport).mockResolvedValue({ created: 1, updated: 0, skipped: 0, errors: [] }) + + const { result } = renderHook(() => useImport(), { wrapper: createSimpleWrapper() }) + + await act(async () => { + await result.current.upload('test') + }) + + await act(async () => { + await result.current.commit({}, {}) + }) + + // Verify state after commit + expect(result.current.commitSuccess).toBe(true) + expect(result.current.commitResult).not.toBeNull() + + // Clear and verify reset + act(() => { + result.current.clearCommitResult() + }) + + expect(result.current.commitResult).toBeNull() + expect(result.current.commitSuccess).toBe(false) + }) + + it('should use upload preview when available', async () => { + const mockSession = { + id: 'session-upload-preview', + state: 'reviewing' as const, + created_at: '2025-01-18T10:00:00Z', + updated_at: '2025-01-18T10:00:00Z', + } + + const uploadPreviewData = { + hosts: [{ domain_names: 'upload-preview.com' }], + conflicts: [], + errors: [], + } + + const mockUploadResponse = { + session: mockSession, + preview: uploadPreviewData, + } + + const statusPreviewData = { + hosts: [{ domain_names: 'status-preview.com' }], + conflicts: [], + errors: [], + } + + vi.mocked(api.uploadCaddyfile).mockResolvedValue(mockUploadResponse) + vi.mocked(api.getImportStatus).mockResolvedValue({ has_pending: true, session: mockSession }) + // Return different preview from getImportPreview to verify upload preview is preferred + vi.mocked(api.getImportPreview).mockResolvedValue({ + session: mockSession, + preview: statusPreviewData, + }) + + const { result } = renderHook(() => useImport(), { wrapper: createSimpleWrapper() }) + + await act(async () => { + await result.current.upload('test') + }) + + // Should use the upload preview, not the status query preview + expect(result.current.preview).toEqual(mockUploadResponse) + }) + + it('should fallback to status query session when upload preview is null', async () => { + const mockSession = { + id: 'session-fallback', + state: 'reviewing' as const, + created_at: '2025-01-18T10:00:00Z', + updated_at: '2025-01-18T10:00:00Z', + } + + const mockPreview = { + session: mockSession, + preview: { hosts: [{ domain_names: 'fallback.com' }], conflicts: [], errors: [] }, + } + + // Don't upload, just have status return a session + vi.mocked(api.getImportStatus).mockResolvedValue({ has_pending: true, session: mockSession }) + vi.mocked(api.getImportPreview).mockResolvedValue(mockPreview) + + const { result } = renderHook(() => useImport(), { wrapper: createSimpleWrapper() }) + + await waitFor(() => { + expect(result.current.session).toEqual(mockSession) + }) + + // Should use status query session since no upload was performed + expect(result.current.session?.id).toBe('session-fallback') + }) }) })