diff --git a/cmd/server/tests/scanner_cancel_test.go b/cmd/server/tests/scanner_cancel_test.go new file mode 100644 index 00000000..be626a34 --- /dev/null +++ b/cmd/server/tests/scanner_cancel_test.go @@ -0,0 +1,182 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" + "testing" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// POST /api/scanner/cancel/:jobId is admin-only and must answer with +// distinct codes for unknown (404) and already-terminal (409) jobs. +func TestScanCancelEndpointSemantics(t *testing.T) { + setup := setupTestServer(t) + + client := &http.Client{} + + t.Run("cancel without admin role is forbidden", func(t *testing.T) { + url := fmt.Sprintf("%s/api/scanner/cancel/%s", setup.Server.URL, uuid.New().String()) + req, _ := http.NewRequest("POST", url, nil) + req.Header.Set("Authorization", "Bearer "+setup.RegularToken) + + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusForbidden, resp.StatusCode) + }) + + t.Run("cancel unknown job is not found", func(t *testing.T) { + url := fmt.Sprintf("%s/api/scanner/cancel/%s", setup.Server.URL, uuid.New().String()) + req, _ := http.NewRequest("POST", url, nil) + req.Header.Set("Authorization", "Bearer "+setup.Token) + + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + }) +} + +// Cancelling a manual scan mid-run must never corrupt library state: +// the abort skips the archive pass, so unvisited (but on-disk) files +// are not treated as missing. The canary item points at a file that is +// lexically LAST in the folder, so a mid-walk abort that (incorrectly) +// ran the archive pass would mark it missing and fail the assertion. +// The assertions hold under every interleaving — whether the cancel +// lands mid-scan or the scan already finished — so the test cannot +// flap against timing. +func TestScanCancelMidScanSafety(t *testing.T) { + setup := setupTestServer(t) + client := &http.Client{} + token := setup.Token + + // Test library (helper registers API-delete cleanup so the run + // leaves no residue in the dev database). + libraryID := createTestLibraryWithFolder(t, setup.Server, token, "Test Scan Cancel Library", false) + libUUID, err := uuid.Parse(libraryID) + require.NoError(t, err) + + // Folder on disk: many junk .epub files (zero bytes cannot parse as + // a zip/epub, so processMediaFile rejects each one and creates no + // rows) plus the canary file, lexically last. + dir := t.TempDir() + const fileCount = 1200 + junk := bytes.Repeat([]byte{0}, 1024) + for i := 0; i < fileCount; i++ { + name := filepath.Join(dir, fmt.Sprintf("book-%04d.epub", i)) + require.NoError(t, os.WriteFile(name, junk, 0o644)) + } + canaryRel := "zzz-last.epub" + require.NoError(t, os.WriteFile(filepath.Join(dir, canaryRel), junk, 0o644)) + + // Register the folder with the library. + folderURL := fmt.Sprintf("%s/api/libraries/%s/folders", setup.Server.URL, libraryID) + body, _ := json.Marshal(map[string]interface{}{"folder_path": dir}) + req, _ := http.NewRequest("POST", folderURL, bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + resp, err := client.Do(req) + require.NoError(t, err) + _ = resp.Body.Close() + require.Equal(t, http.StatusCreated, resp.StatusCode, "folder creation should succeed") + + // Pre-existing item whose file exists on disk. A normal completed + // scan never marks it missing; neither may a cancelled one. + _, err = setup.DBPool.Exec(context.Background(), + "INSERT INTO media_items (library_id, title, file_path) VALUES ($1, $2, $3)", + pgtype.UUID{Bytes: libUUID, Valid: true}, "Test Cancel Canary", canaryRel) + require.NoError(t, err) + + // Start the scan and cancel it as soon as it is accepted. + jobID := startScan(t, client, setup.Server.URL, token, libraryID) + + cancelURL := fmt.Sprintf("%s/api/scanner/cancel/%s", setup.Server.URL, jobID) + cancelReq, _ := http.NewRequest("POST", cancelURL, nil) + cancelReq.Header.Set("Authorization", "Bearer "+token) + cancelResp, err := client.Do(cancelReq) + require.NoError(t, err) + _ = cancelResp.Body.Close() + // 200 = cancelled mid-run; 409 = the scan already finished before + // the cancel landed. Both are acceptable; the assertions below hold + // either way. + assert.Contains(t, []int{http.StatusOK, http.StatusConflict}, cancelResp.StatusCode) + + // Poll to a terminal state. + final := pollJobToTerminal(t, client, setup.Server.URL, token, jobID) + assert.Contains(t, []string{"completed", "cancelled"}, final["status"], + "scan must finish cleanly or be cancelled, got %v (error: %v)", final["status"], final["error"]) + + // The safety invariant: no item was marked missing or archived, and + // the junk files created no phantom rows. + var items, missingSum, archived int + err = setup.DBPool.QueryRow(context.Background(), + `SELECT COUNT(*), + COALESCE(SUM(missing_scan_count), 0), + COUNT(*) FILTER (WHERE archived_at IS NOT NULL) + FROM media_items WHERE library_id = $1`, + pgtype.UUID{Bytes: libUUID, Valid: true}).Scan(&items, &missingSum, &archived) + require.NoError(t, err) + assert.Equal(t, 1, items, "junk files must not create media items") + assert.Zero(t, missingSum, "cancelled/completed scan must not mark on-disk files missing") + assert.Zero(t, archived, "no item may be archived by this scan") + + // Cancelling a terminal job is a conflict, whichever terminal state + // the job reached. + cancelReq2, _ := http.NewRequest("POST", cancelURL, nil) + cancelReq2.Header.Set("Authorization", "Bearer "+token) + cancelResp2, err := client.Do(cancelReq2) + require.NoError(t, err) + _ = cancelResp2.Body.Close() + assert.Equal(t, http.StatusConflict, cancelResp2.StatusCode) +} + +func startScan(t *testing.T, client *http.Client, baseURL, token, libraryID string) string { + url := fmt.Sprintf("%s/api/libraries/%s/scan", baseURL, libraryID) + req, _ := http.NewRequest("POST", url, nil) + req.Header.Set("Authorization", "Bearer "+token) + + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusAccepted, resp.StatusCode) + + var decoded map[string]interface{} + require.NoError(t, json.NewDecoder(resp.Body).Decode(&decoded)) + jobID, ok := decoded["job_id"].(string) + require.True(t, ok, "job_id should be a string") + require.NotEmpty(t, jobID) + return jobID +} + +func pollJobToTerminal(t *testing.T, client *http.Client, baseURL, token, jobID string) map[string]interface{} { + url := fmt.Sprintf("%s/api/scanner/status/%s", baseURL, jobID) + for i := 0; i < 30; i++ { + req, _ := http.NewRequest("GET", url, nil) + req.Header.Set("Authorization", "Bearer "+token) + + resp, err := client.Do(req) + require.NoError(t, err) + var status map[string]interface{} + err = json.NewDecoder(resp.Body).Decode(&status) + _ = resp.Body.Close() + require.NoError(t, err) + + switch status["status"] { + case "completed", "failed", "cancelled": + return status + } + time.Sleep(500 * time.Millisecond) + } + t.Fatalf("job %s did not reach a terminal state within 15s", jobID) + return nil +}