235 lines
8.5 KiB
Python
235 lines
8.5 KiB
Python
|
|
"""Tests for scanner/__main__.py — CLI commands, worst_level, file-lock."""
|
||
|
|
import json
|
||
|
|
from pathlib import Path
|
||
|
|
from tempfile import TemporaryDirectory
|
||
|
|
from unittest.mock import MagicMock, patch
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
|
||
|
|
from scanner.__main__ import _worst_level, _LEVEL_ORDER
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# _worst_level
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
class TestWorstLevel:
|
||
|
|
def test_all_green(self):
|
||
|
|
assert _worst_level(["green", "green"]) == "green"
|
||
|
|
|
||
|
|
def test_green_yellow(self):
|
||
|
|
assert _worst_level(["green", "yellow"]) == "yellow"
|
||
|
|
|
||
|
|
def test_green_red(self):
|
||
|
|
assert _worst_level(["green", "red"]) == "red"
|
||
|
|
|
||
|
|
def test_yellow_red(self):
|
||
|
|
assert _worst_level(["yellow", "red"]) == "red"
|
||
|
|
|
||
|
|
def test_red_yellow(self):
|
||
|
|
assert _worst_level(["red", "yellow"]) == "red"
|
||
|
|
|
||
|
|
def test_single_green(self):
|
||
|
|
assert _worst_level(["green"]) == "green"
|
||
|
|
|
||
|
|
def test_empty_returns_green(self):
|
||
|
|
assert _worst_level([]) == "green"
|
||
|
|
|
||
|
|
def test_many_levels(self):
|
||
|
|
assert _worst_level(["green", "green", "yellow", "green", "red"]) == "red"
|
||
|
|
|
||
|
|
|
||
|
|
class TestLevelOrder:
|
||
|
|
def test_order_values(self):
|
||
|
|
assert _LEVEL_ORDER["green"] == 0
|
||
|
|
assert _LEVEL_ORDER["yellow"] == 1
|
||
|
|
assert _LEVEL_ORDER["red"] == 2
|
||
|
|
|
||
|
|
def test_order_increasing(self):
|
||
|
|
assert _LEVEL_ORDER["green"] < _LEVEL_ORDER["yellow"] < _LEVEL_ORDER["red"]
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# cmd_scan file-lock
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
class TestCmdScanFileLock:
|
||
|
|
@patch("scanner.__main__._crawl_and_extract")
|
||
|
|
@patch("scanner.__main__.BaselineManager")
|
||
|
|
def test_scan_without_data_dir_no_crash(self, mock_bm, mock_crawl):
|
||
|
|
"""cmd_scan ohne data_dir sollte keinen Lock versuchen."""
|
||
|
|
from scanner.__main__ import cmd_scan
|
||
|
|
|
||
|
|
mock_crawl.return_value = ([], [])
|
||
|
|
mock_bm_instance = MagicMock()
|
||
|
|
mock_bm_instance.baseline_exists.return_value = False
|
||
|
|
mock_bm.return_value = mock_bm_instance
|
||
|
|
|
||
|
|
cfg = {"target": "https://test.de", "data_dir": None}
|
||
|
|
args = MagicMock(config="test/config.yaml")
|
||
|
|
result = cmd_scan(args, cfg)
|
||
|
|
assert result == 0
|
||
|
|
|
||
|
|
@patch("scanner.__main__._crawl_and_extract")
|
||
|
|
@patch("scanner.__main__.BaselineManager")
|
||
|
|
def test_scan_with_data_dir_creates_lock(self, mock_bm, mock_crawl):
|
||
|
|
"""cmd_scan mit data_dir erstellt .scan.lock."""
|
||
|
|
from scanner.__main__ import cmd_scan
|
||
|
|
import fcntl
|
||
|
|
|
||
|
|
with TemporaryDirectory() as tmp:
|
||
|
|
lock_path = Path(tmp) / ".scan.lock"
|
||
|
|
mock_crawl.return_value = ([], [])
|
||
|
|
mock_bm_instance = MagicMock()
|
||
|
|
mock_bm_instance.baseline_exists.return_value = False
|
||
|
|
mock_bm.return_value = mock_bm_instance
|
||
|
|
|
||
|
|
cfg = {"target": "https://test.de", "data_dir": tmp}
|
||
|
|
args = MagicMock(config="test/config.yaml")
|
||
|
|
result = cmd_scan(args, cfg)
|
||
|
|
|
||
|
|
assert result == 0
|
||
|
|
# Lock wurde erstellt und nach Scan wieder entfernt (oder leer)
|
||
|
|
assert lock_path.exists()
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# cmd_scan baseline_exists
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
class TestCmdScanNoBaseline:
|
||
|
|
@patch("scanner.__main__._crawl_and_extract")
|
||
|
|
@patch("scanner.__main__.BaselineManager")
|
||
|
|
def test_no_baseline_returns_zero(self, mock_bm, mock_crawl):
|
||
|
|
from scanner.__main__ import cmd_scan
|
||
|
|
|
||
|
|
with TemporaryDirectory() as tmp:
|
||
|
|
mock_crawl.return_value = ([{"url": "https://test.de/"}], [])
|
||
|
|
mock_bm_instance = MagicMock()
|
||
|
|
mock_bm_instance.baseline_exists.return_value = False
|
||
|
|
mock_bm.return_value = mock_bm_instance
|
||
|
|
|
||
|
|
cfg = {"target": "https://test.de", "data_dir": tmp}
|
||
|
|
args = MagicMock(config="test/config.yaml")
|
||
|
|
result = cmd_scan(args, cfg)
|
||
|
|
assert result == 0
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# _crawl_and_extract
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
class TestCrawlAndExtract:
|
||
|
|
@patch("scanner.__main__.Crawler")
|
||
|
|
def test_empty_crawl(self, mock_crawler_cls):
|
||
|
|
from scanner.__main__ import _crawl_and_extract
|
||
|
|
|
||
|
|
mock_crawler = MagicMock()
|
||
|
|
mock_crawler.crawl.return_value = {
|
||
|
|
"target": "https://test.de",
|
||
|
|
"pages": [],
|
||
|
|
"errors": [{"url": "https://test.de/", "error": "timeout"}],
|
||
|
|
}
|
||
|
|
mock_crawler_cls.return_value = mock_crawler
|
||
|
|
|
||
|
|
cfg = {
|
||
|
|
"target": "https://test.de",
|
||
|
|
"crawl": {"max_pages": 10, "delay_seconds": 0.1, "skip_extensions": [],
|
||
|
|
"exclude_paths": [], "include_paths": [], "sitemap": False},
|
||
|
|
"request_timeout": 10,
|
||
|
|
"user_agent": "test",
|
||
|
|
}
|
||
|
|
pages, errors = _crawl_and_extract(cfg)
|
||
|
|
assert len(pages) == 0
|
||
|
|
assert len(errors) == 1
|
||
|
|
|
||
|
|
@patch("scanner.__main__.Crawler")
|
||
|
|
def test_crawl_with_pages(self, mock_crawler_cls):
|
||
|
|
from scanner.__main__ import _crawl_and_extract
|
||
|
|
|
||
|
|
mock_crawler = MagicMock()
|
||
|
|
mock_crawler.crawl.return_value = {
|
||
|
|
"target": "https://test.de",
|
||
|
|
"pages": [
|
||
|
|
{"url": "https://test.de/", "status": 200,
|
||
|
|
"html": "<html><body>Test</body></html>", "headers": {}},
|
||
|
|
],
|
||
|
|
"errors": [],
|
||
|
|
}
|
||
|
|
mock_crawler_cls.return_value = mock_crawler
|
||
|
|
|
||
|
|
cfg = {
|
||
|
|
"target": "https://test.de",
|
||
|
|
"crawl": {"max_pages": 10, "delay_seconds": 0.1, "skip_extensions": [],
|
||
|
|
"exclude_paths": [], "include_paths": [], "sitemap": False},
|
||
|
|
"request_timeout": 10,
|
||
|
|
"user_agent": "test",
|
||
|
|
}
|
||
|
|
pages, errors = _crawl_and_extract(cfg)
|
||
|
|
assert len(pages) == 1
|
||
|
|
assert len(errors) == 0
|
||
|
|
assert "text" in pages[0] # extract_page wurde aufgerufen
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# _run_content_check
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
class TestRunContentCheck:
|
||
|
|
def test_empty_baseline_and_snapshot(self):
|
||
|
|
from scanner.__main__ import _run_content_check
|
||
|
|
|
||
|
|
cfg = {
|
||
|
|
"target": "https://test.de",
|
||
|
|
"config_dir": "/tmp/test/config",
|
||
|
|
"security_headers": [],
|
||
|
|
}
|
||
|
|
snap = {"pages": {}, "errors": []}
|
||
|
|
baseline = {"manifest": {}, "pages": {}}
|
||
|
|
|
||
|
|
diff, assessment, wl_violations, missing_sec = _run_content_check(cfg, snap, baseline)
|
||
|
|
assert assessment["level"] == "green"
|
||
|
|
assert assessment["score"] == 0
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Score aggregation in cmd_scan
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
class TestScoreAggregation:
|
||
|
|
@patch("scanner.__main__._crawl_and_extract")
|
||
|
|
@patch("scanner.__main__.BaselineManager")
|
||
|
|
@patch("scanner.__main__._run_content_check")
|
||
|
|
@patch("scanner.__main__._build_report")
|
||
|
|
@patch("scanner.__main__._write_report")
|
||
|
|
@patch("scanner.__main__._print_summary")
|
||
|
|
@patch("scanner.__main__.send_alert")
|
||
|
|
def test_scan_aggregates_levels(self, mock_alert, mock_print, mock_write,
|
||
|
|
mock_build, mock_check, mock_bm, mock_crawl):
|
||
|
|
from scanner.__main__ import cmd_scan
|
||
|
|
|
||
|
|
with TemporaryDirectory() as tmp:
|
||
|
|
mock_crawl.return_value = ([{"url": "https://test.de/"}], [])
|
||
|
|
|
||
|
|
mock_bm_instance = MagicMock()
|
||
|
|
mock_bm_instance.baseline_exists.return_value = True
|
||
|
|
mock_bm_instance.load_baseline.return_value = {"manifest": {}, "pages": {}}
|
||
|
|
mock_bm.return_value = mock_bm_instance
|
||
|
|
|
||
|
|
# content check: yellow
|
||
|
|
mock_check.return_value = (
|
||
|
|
{"new_external_domains": [], "page_diffs": []},
|
||
|
|
{"level": "yellow", "score": 30, "reasons": ["Test"], "exit_code": 1},
|
||
|
|
[], {}
|
||
|
|
)
|
||
|
|
|
||
|
|
cfg = {
|
||
|
|
"target": "https://test.de",
|
||
|
|
"data_dir": tmp,
|
||
|
|
"reports_dir": f"{tmp}/reports",
|
||
|
|
"ai_analysis": {},
|
||
|
|
"periodic_checks": {"enabled": False},
|
||
|
|
}
|
||
|
|
args = MagicMock(config="test/config.yaml")
|
||
|
|
result = cmd_scan(args, cfg)
|
||
|
|
assert result == 1 # exit_code für yellow
|