zapretgui/tests/test_path_report.py
loop-uh 47ff980330 Единый источник слов отчёта по сайту: карточки и текст берут строки из одного модуля
diagnostics/service_view.py решает, какие строки, в каком порядке и с каким уровнем говорятся про сайт: соединение, DNS, QUIC, скорость ответа, путь запроса, советы, замедление по имени. Карточки BlockCheck, текст отчёта и текст «Проверки домена» только рисуют результат, поэтому один и тот же факт читается везде одинаково. Новое измерение добавляется одной строкой в target_rows; tests/test_service_view.py ловит расхождение карточки и текста.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 10:29:28 -04:00

200 lines
9.7 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Путь запроса: шаги от DNS до передачи по фактам одного адреса, уровни и уверенность, без сети."""
from __future__ import annotations
import json
import unittest
from pathlib import Path
from blockcheck.ui.result_cards_model import build_cards
from blockcheck_words import report_with_words
from diagnostics import block_cause, engine, path_report, report_text
from diagnostics import protocol_probe as pp
from diagnostics import quic_probe, volume_probe
from diagnostics import transfer_probe as tp
from diagnostics.tls_probe import KIND_CERT, KIND_CONNECT, KIND_OK, KIND_RESET, ProbeResult
from diagnostics.verdict import DnsJudgement, DnsState, ReachState
S = tp.RequestSample
FIXTURE = Path(__file__).parent / "fixtures" / "blockcheck_report_sample.json"
IP = "203.0.113.5"
def _probe(**facts) -> engine._Probe:
probe = engine._Probe(target=engine.Target("x.com", "сайт", main=True), service="x", host="x.com")
probe.judgement = DnsJudgement(DnsState.OK, "ответ DNS совпал с эталоном")
probe.reference_ok = True
probe.reach = ProbeResult(ip=IP, kind=KIND_OK, status=200, body_size=500)
probe.reach_state = ReachState.OK
probe.tried = ((IP, "ok"),)
for key, value in facts.items():
setattr(probe, key, value)
return probe
def _by_key(probe) -> dict[str, path_report.PathStep]:
return {step.key: step for step in path_report.build(probe)}
class PathOrderTests(unittest.TestCase):
def test_five_steps_always_in_one_order(self) -> None:
steps = path_report.build(_probe())
self.assertEqual([step.key for step in steps], ["dns", "tcp", "tls", "quic", "data"])
self.assertEqual([step.title for step in steps][0], "DNS")
def test_step_as_dict_is_complete(self) -> None:
data = path_report.build(_probe())[0].as_dict()
self.assertEqual(sorted(data), ["confidence", "key", "level", "text", "title"])
class HealthyPathTests(unittest.TestCase):
def test_everything_open_has_no_breaking_step(self) -> None:
probe = _probe(
quic=quic_probe.QuicVerdict(quic_probe.QUIC_OK, "отвечает за 40 мс"),
volume=volume_probe.VolumeVerdict(volume_probe.VOLUME_OK, "получено 48 КБ без обрыва"),
transfer=tp.judge([S(ttfb_ms=300, fresh=True), *(S(received=5_000, ttfb_ms=ms) for ms in (20, 25, 30, 22, 24, 21))]),
)
steps = path_report.build(probe)
self.assertIsNone(path_report.breaking_step(steps))
self.assertEqual([step.level for step in steps], ["ok"] * 5)
self.assertEqual(path_report.chain_text(steps), "DNS ✅ → TCP ✅ → TLS ✅ → QUIC ✅ → передача ✅")
def test_unchecked_steps_are_unknown_and_not_a_breakage(self) -> None:
steps = path_report.build(_probe())
self.assertEqual({step.key: step.level for step in steps}["quic"], "unknown")
self.assertEqual({step.key: step.level for step in steps}["data"], "unknown")
self.assertIsNone(path_report.breaking_step(steps))
class TtkPathTests(unittest.TestCase):
def test_handshake_passes_and_only_the_transfer_breaks(self) -> None:
"""Сбой YouTube у ТТК: соединение и шифрование в порядке, а передача идёт с паузами."""
probe = _probe(
volume=volume_probe.VolumeVerdict(volume_probe.VOLUME_OK, "получено 48 КБ без обрыва"),
transfer=tp.judge([S(ttfb_ms=300, fresh=True), *(S(received=5_000, ttfb_ms=ms) for ms in (9, 42, 7, 12, 9, 9, 2_861, 2_900))]),
)
steps = path_report.build(probe)
broken = path_report.breaking_step(steps)
self.assertEqual((broken.key, broken.level), ("data", "warn"))
self.assertEqual(broken.confidence, tp.CONFIDENCE_HIGH)
self.assertEqual([step.level for step in steps[:3]], ["ok", "ok", "ok"])
self.assertTrue(path_report.chain_text(steps).endswith("передача ⚠️"))
class BrokenPathTests(unittest.TestCase):
def test_dpi_with_a_confident_cause_breaks_tls(self) -> None:
probe = _probe(
reach=ProbeResult(ip=IP, kind=KIND_RESET, detail="сброс"),
reach_state=ReachState.DPI,
cause=block_cause.Cause("by_name", "режут по имени сайта: с чужим именем сервер отвечает", True),
)
steps = _by_key(probe)
self.assertEqual((steps["tcp"].level, steps["tls"].level), ("ok", "fail"))
self.assertEqual(steps["tls"].confidence, tp.CONFIDENCE_HIGH)
self.assertIn("Режут по имени сайта", steps["tls"].text)
self.assertEqual(path_report.breaking_step(list(steps.values())).key, "tls")
def test_dpi_without_a_confident_cause_is_less_certain(self) -> None:
probe = _probe(reach=ProbeResult(ip=IP, kind=KIND_RESET), reach_state=ReachState.DPI)
self.assertEqual(_by_key(probe)["tls"].confidence, tp.CONFIDENCE_MEDIUM)
def test_closed_address_breaks_tcp_and_leaves_tls_unknown(self) -> None:
probe = _probe(
reach=ProbeResult(ip=IP, kind=KIND_CONNECT),
reach_state=ReachState.IP_BLOCK,
tried=((IP, "connect"), ("203.0.113.6", "connect")),
)
steps = _by_key(probe)
self.assertEqual((steps["tcp"].level, steps["tcp"].confidence), ("fail", tp.CONFIDENCE_HIGH))
self.assertIn("ни по одному из 2 адресов", steps["tcp"].text)
self.assertEqual(steps["tls"].level, "unknown")
def test_spoofed_dns_is_a_warning_on_the_first_step(self) -> None:
probe = _probe(judgement=DnsJudgement(DnsState.SPOOFED, "DNS подменяет адрес"))
steps = _by_key(probe)
self.assertEqual((steps["dns"].level, steps["dns"].text), ("warn", "DNS подменяет адрес"))
self.assertEqual(path_report.breaking_step(list(steps.values())).key, "dns")
def test_dns_without_reference_is_less_certain(self) -> None:
self.assertEqual(_by_key(_probe(reference_ok=False))["dns"].confidence, tp.CONFIDENCE_LOW)
self.assertEqual(_by_key(_probe())["dns"].confidence, tp.CONFIDENCE_HIGH)
def test_foreign_certificate_breaks_tls(self) -> None:
probe = _probe(
reach=ProbeResult(ip=IP, kind=KIND_CERT, cert_problem="сертификат выдан другому сайту"),
reach_state=ReachState.CERT,
)
self.assertEqual(_by_key(probe)["tls"].level, "fail")
def test_browser_fingerprint_block_is_named(self) -> None:
line = pp.ProtocolLine("browser", "Chrome", "fail", "сброс", "сброшено", code=pp.CODE_FINGERPRINT)
probe = _probe(protocols=(line,), reach=ProbeResult(ip=IP, kind=KIND_RESET), reach_state=ReachState.DPI)
step = _by_key(probe)["tls"]
self.assertEqual((step.level, step.confidence), ("fail", tp.CONFIDENCE_HIGH))
self.assertIn("почерку", step.text)
def test_cut_on_volume_breaks_the_data_step_not_tls(self) -> None:
probe = _probe(
reach_state=ReachState.FREEZE,
volume=volume_probe.VolumeVerdict(volume_probe.VOLUME_CUT, "соединение замирает после 14 КБ", cut_kb=14),
)
steps = _by_key(probe)
self.assertEqual((steps["tls"].level, steps["data"].level), ("ok", "fail"))
self.assertEqual(steps["data"].confidence, tp.CONFIDENCE_HIGH)
def test_blocked_quic_is_a_warning_and_silent_quic_is_unknown(self) -> None:
blocked = _by_key(_probe(quic=quic_probe.QuicVerdict(quic_probe.QUIC_BLOCKED_BY_NAME, "режут по имени")))["quic"]
silent = _by_key(_probe(quic=quic_probe.QuicVerdict(quic_probe.QUIC_SILENT, "не отвечает")))["quic"]
self.assertEqual((blocked.level, silent.level), ("warn", "unknown"))
class ReportAndCardTests(unittest.TestCase):
def test_target_report_carries_the_path_only_when_checked(self) -> None:
full = report_text.target_report(_probe())
bare = report_text.target_report(engine._Probe(target=engine.Target("x.com", "сайт"), service="x", host="x.com"))
self.assertEqual([step["key"] for step in full["path"]], ["dns", "tcp", "tls", "quic", "data"])
self.assertIsNone(bare["path"])
def test_text_report_has_the_path_section(self) -> None:
lines = report_text.probe_lines(_probe(), full=True)
self.assertIn("Путь запроса", lines)
after = lines[lines.index("Путь запроса") + 1 :]
self.assertTrue(after[0].startswith(" ✅ DNS:"))
self.assertTrue(any(line.startswith(" ✅ Соединение (TCP):") for line in after))
def test_card_gets_a_path_section_for_the_main_address(self) -> None:
report = json.loads(FIXTURE.read_text(encoding="utf-8"))
target = report["services"][0]["targets"][0]
target["main"] = True
target["path"] = [step.as_dict() for step in path_report.build(_probe())]
cards = build_cards(report_with_words(report))
sections = [section for card in cards for section in getattr(card, "sections", ()) if section.title == "Путь запроса"]
self.assertEqual(len(sections), 1)
self.assertEqual([line.name for line in sections[0].lines][0], "DNS")
self.assertIn("уверенность высокая", sections[0].lines[0].text)
def test_old_report_has_no_path_section(self) -> None:
report = json.loads(FIXTURE.read_text(encoding="utf-8"))
cards = build_cards(report_with_words(report))
self.assertFalse([section for card in cards for section in getattr(card, "sections", ()) if section.title == "Путь запроса"])
if __name__ == "__main__":
unittest.main()