Some checks failed
Deploy / deploy (push) Failing after 18s
Users running large scans can now pause (keep partial results, resume
later), cancel (stop permanently, partial results preserved), or resume
a paused scan which races through cache hits before continuing.
Backend:
- Extend scans.status CHECK to include 'paused' and 'cancelled'
- Add _migrate_add_pause_cancel_status() table-recreation migration
- scan_processor: _running_tasks/_cancel_reasons registries,
cancel_scan_task/pause_scan_task/stop_scan_task helpers,
CancelledError handler in process_scan(), start_resume_processor()
- api_server: POST /scans/{id}/pause|cancel|resume endpoints with
rate limits (30/min pause+cancel, 10/min resume); list_scans now
accepts paused/cancelled as status filter values
Frontend:
- Scan.status type extended with 'paused' | 'cancelled'
- scanApi.pause/cancel/resume added
- StatusChip: amber PauseCircle chip for paused, grey Ban for cancelled
- ScanDetails: context-aware action row with inline-confirm for
Pause and Cancel; Resume button for paused scans
Tests: 129 total (58 new) across test_scan_control.py,
test_scan_processor_control.py, and additions to existing suites
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
128 lines
4.4 KiB
Python
128 lines
4.4 KiB
Python
"""
|
|
Tests for scan_processor task registry and control functions.
|
|
|
|
Tests cancel_scan_task, pause_scan_task, stop_scan_task, and the
|
|
done-callback that removes tasks from the registry on completion.
|
|
"""
|
|
|
|
import asyncio
|
|
import pytest
|
|
import sys
|
|
import os
|
|
from unittest.mock import MagicMock
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
|
|
|
from scan_processor import (
|
|
_running_tasks,
|
|
_cancel_reasons,
|
|
cancel_scan_task,
|
|
pause_scan_task,
|
|
stop_scan_task,
|
|
)
|
|
|
|
|
|
class TestScanProcessorControl:
|
|
"""Tests for task registry and cancel/pause/stop functions."""
|
|
|
|
def teardown_method(self, _method):
|
|
"""Clean up any test state from _running_tasks and _cancel_reasons."""
|
|
for key in [9001, 8001, 8002, 7001]:
|
|
_running_tasks.pop(key, None)
|
|
_cancel_reasons.pop(key, None)
|
|
|
|
# ── cancel_scan_task ───────────────────────────────────────────────────
|
|
|
|
def test_cancel_scan_task_returns_false_when_no_task(self):
|
|
"""Returns False when no task is registered for the given scan id."""
|
|
result = cancel_scan_task(99999)
|
|
assert result is False
|
|
|
|
def test_cancel_scan_task_returns_true_when_task_exists(self):
|
|
"""Returns True and calls task.cancel() when a live task is registered."""
|
|
mock_task = MagicMock()
|
|
mock_task.done.return_value = False
|
|
_running_tasks[9001] = mock_task
|
|
|
|
result = cancel_scan_task(9001)
|
|
|
|
assert result is True
|
|
mock_task.cancel.assert_called_once()
|
|
|
|
def test_cancel_scan_task_returns_false_for_completed_task(self):
|
|
"""Returns False when the registered task is already done."""
|
|
mock_task = MagicMock()
|
|
mock_task.done.return_value = True
|
|
_running_tasks[9001] = mock_task
|
|
|
|
result = cancel_scan_task(9001)
|
|
|
|
assert result is False
|
|
mock_task.cancel.assert_not_called()
|
|
|
|
# ── pause_scan_task ────────────────────────────────────────────────────
|
|
|
|
def test_pause_sets_cancel_reason_paused(self):
|
|
"""pause_scan_task sets _cancel_reasons[id] = 'paused'."""
|
|
mock_task = MagicMock()
|
|
mock_task.done.return_value = False
|
|
_running_tasks[8001] = mock_task
|
|
|
|
pause_scan_task(8001)
|
|
|
|
assert _cancel_reasons.get(8001) == 'paused'
|
|
|
|
def test_pause_calls_cancel_on_task(self):
|
|
"""pause_scan_task triggers cancellation of the underlying task."""
|
|
mock_task = MagicMock()
|
|
mock_task.done.return_value = False
|
|
_running_tasks[8001] = mock_task
|
|
|
|
result = pause_scan_task(8001)
|
|
|
|
assert result is True
|
|
mock_task.cancel.assert_called_once()
|
|
|
|
# ── stop_scan_task ─────────────────────────────────────────────────────
|
|
|
|
def test_stop_sets_cancel_reason_cancelled(self):
|
|
"""stop_scan_task sets _cancel_reasons[id] = 'cancelled'."""
|
|
mock_task = MagicMock()
|
|
mock_task.done.return_value = False
|
|
_running_tasks[8002] = mock_task
|
|
|
|
stop_scan_task(8002)
|
|
|
|
assert _cancel_reasons.get(8002) == 'cancelled'
|
|
|
|
def test_stop_calls_cancel_on_task(self):
|
|
"""stop_scan_task triggers cancellation of the underlying task."""
|
|
mock_task = MagicMock()
|
|
mock_task.done.return_value = False
|
|
_running_tasks[8002] = mock_task
|
|
|
|
result = stop_scan_task(8002)
|
|
|
|
assert result is True
|
|
mock_task.cancel.assert_called_once()
|
|
|
|
# ── done callback ──────────────────────────────────────────────────────
|
|
|
|
def test_task_removed_from_registry_on_completion(self):
|
|
"""The done-callback registered by start_scan_processor removes the task."""
|
|
|
|
async def run():
|
|
async def quick():
|
|
return
|
|
|
|
task = asyncio.create_task(quick())
|
|
_running_tasks[7001] = task
|
|
task.add_done_callback(lambda _: _running_tasks.pop(7001, None))
|
|
await task
|
|
# Yield to let done callbacks fire
|
|
await asyncio.sleep(0)
|
|
return 7001 not in _running_tasks
|
|
|
|
result = asyncio.run(run())
|
|
assert result is True
|