zapretgui/tests/test_volume_probe.py
loop-uh d8ec842d4f
All checks were successful
Zapret source guards / validate (push) Successful in 9s
Проба объёма: один повтор, если подключиться не вышло с первого раза
На видеоузле YouTube отчёт показывал «не удалось соединиться повторно — объём не проверен»: узел мог не принять подключение сразу после предыдущих проб. После короткой паузы подключение пробуется ещё раз, а если и оно не вышло — итог прежний.

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

290 lines
12 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.

from __future__ import annotations
import shutil
import socket
import ssl
import subprocess
import tempfile
import threading
import unittest
from pathlib import Path
from unittest.mock import patch
from diagnostics import volume_probe as vp
from utils.socket_cancel import SocketCancel
HOST = "volume.test"
def _run(kind: str, received: int = 0, requests: int = 1) -> vp.VolumeRun:
return vp.VolumeRun(kind, received, requests)
PASSED = _run(vp.RUN_PASSED, 49_500, 4)
class JudgeTests(unittest.TestCase):
def test_enough_volume_means_no_cut(self) -> None:
verdict = vp.judge(vp.VolumeFacts(PASSED))
self.assertEqual(verdict.code, vp.VOLUME_OK)
self.assertIn("48 КБ без обрыва", verdict.text)
def test_cut_needs_two_stalls_in_the_window(self) -> None:
verdict = vp.judge(vp.VolumeFacts(_run(vp.RUN_STALLED, 14_500, 20), _run(vp.RUN_STALLED, 13_800, 41)))
self.assertEqual(verdict.code, vp.VOLUME_CUT)
self.assertEqual(verdict.cut_kb, 14)
self.assertIn("дважды подряд, на 14 и 13 КБ", verdict.text)
def test_reset_in_the_window_counts_like_a_stall(self) -> None:
verdict = vp.judge(vp.VolumeFacts(_run(vp.RUN_RESET, 16_000), _run(vp.RUN_STALLED, 15_000)))
self.assertEqual(verdict.code, vp.VOLUME_CUT)
def test_single_stall_is_not_a_verdict(self) -> None:
"""Один раз замерло, а при повторе прошло или упало иначе — случайность, а не блокировка."""
stalled = _run(vp.RUN_STALLED, 14_500)
for second in (PASSED, _run(vp.RUN_CLOSED, 3_000), _run(vp.RUN_STALLED, 2_000), None):
with self.subTest(second=second):
self.assertEqual(vp.judge(vp.VolumeFacts(stalled, second)).code, vp.VOLUME_UNKNOWN)
self.assertIn("случайный сбой", vp.judge(vp.VolumeFacts(stalled, PASSED)).text)
def test_window_passed_without_stall_means_no_cut(self) -> None:
"""Запросы кончились раньше 48 КБ, но окно обрыва пройдено — обрыва нет."""
verdict = vp.judge(vp.VolumeFacts(_run(vp.RUN_CLOSED, 40_000, 60)))
self.assertEqual(verdict.code, vp.VOLUME_OK)
def test_stall_outside_the_window_is_not_a_cut(self) -> None:
for received in (0, 2_000, 37_000):
with self.subTest(received=received):
verdict = vp.judge(vp.VolumeFacts(_run(vp.RUN_STALLED, received)))
self.assertEqual(verdict.code, vp.VOLUME_UNKNOWN)
def test_server_that_gives_too_little_cannot_be_checked(self) -> None:
verdict = vp.judge(vp.VolumeFacts(_run(vp.RUN_CLOSED, 4_000, 60)))
self.assertEqual(verdict.code, vp.VOLUME_UNKNOWN)
self.assertIn("не проверить", verdict.text)
def test_interrupted_probe_gives_no_verdict(self) -> None:
cancelled = _run(vp.RUN_CANCELLED)
self.assertEqual(vp.judge(vp.VolumeFacts(cancelled)).code, vp.VOLUME_UNKNOWN)
self.assertEqual(vp.judge(vp.VolumeFacts(_run(vp.RUN_STALLED, 14_000), cancelled)).code, vp.VOLUME_UNKNOWN)
class CollectTests(unittest.TestCase):
def _collect(self, *runs: vp.VolumeRun):
calls: list[str] = []
queue = list(runs)
def download(_host, _ip, _path="/", *, method="GET", **_kwargs):
calls.append(method)
return queue.pop(0)
with patch.object(vp, "download", download):
return vp.collect(HOST, "127.0.0.1", "/", cancel=SocketCancel()), calls
def test_second_attempt_only_after_a_stall_in_the_window(self) -> None:
facts, calls = self._collect(PASSED)
self.assertEqual((calls, facts.second), (["GET"], None))
facts, calls = self._collect(_run(vp.RUN_CLOSED, 3_000))
self.assertEqual((calls, facts.second), (["GET"], None))
def test_failed_connection_is_tried_once_more_after_a_pause(self) -> None:
with patch.object(vp.time, "sleep") as pause:
facts, calls = self._collect(_run(vp.RUN_FAILED), PASSED)
self.assertEqual(calls, ["GET", "GET"])
self.assertEqual(facts.first.kind, vp.RUN_PASSED)
pause.assert_called_once_with(vp.RETRY_PAUSE_S)
def test_connection_that_fails_twice_stays_failed(self) -> None:
with patch.object(vp.time, "sleep"):
facts, calls = self._collect(_run(vp.RUN_FAILED), _run(vp.RUN_FAILED))
self.assertEqual((calls, facts.first.kind, facts.second), (["GET", "GET"], vp.RUN_FAILED, None))
def test_stall_is_rechecked_with_another_kind_of_request(self) -> None:
"""Повтор идёт запросами HEAD: до того же объёма нужно другое число запросов."""
second = _run(vp.RUN_STALLED, 14_000, 40)
facts, calls = self._collect(_run(vp.RUN_STALLED, 15_000, 20), second)
self.assertEqual(calls, ["GET", "HEAD"])
self.assertIs(facts.second, second)
@unittest.skipUnless(shutil.which("openssl"), "нужен openssl для учебного сертификата")
class DownloadTests(unittest.TestCase):
"""Настоящий набор объёма на учебном сервере с шифрованием."""
@classmethod
def setUpClass(cls) -> None:
cls._folder = tempfile.TemporaryDirectory()
folder = Path(cls._folder.name)
cls.cert, cls.key = folder / "cert.pem", folder / "key.pem"
subprocess.run(
["openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "2", "-keyout", str(cls.key),
"-out", str(cls.cert), "-subj", f"/CN={HOST}", "-addext", f"subjectAltName=DNS:{HOST}"],
check=True,
capture_output=True,
)
@classmethod
def tearDownClass(cls) -> None:
cls._folder.cleanup()
def _serve(self, handle) -> int:
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
context.load_cert_chain(str(self.cert), str(self.key))
listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
listener.bind(("127.0.0.1", 0))
listener.listen(1)
listener.settimeout(3)
self.addCleanup(listener.close)
def _run_server() -> None:
try:
connection, _address = listener.accept()
with context.wrap_socket(connection, server_side=True) as tls:
handle(tls)
except (OSError, ssl.SSLError):
pass
threading.Thread(target=_run_server, daemon=True).start()
return listener.getsockname()[1]
def _download(self, port: int, **kwargs) -> vp.VolumeRun:
from utils import chrome_https
# Запросы идут «как Chrome»: учебный сертификат — единственный корень доверия.
with patch.object(chrome_https, "_ca_bundle", return_value=str(self.cert)):
return vp.download(HOST, "127.0.0.1", "/page", port=port, timeout=2.0, **kwargs)
@staticmethod
def _requests(tls):
"""Отдаёт запросы по одному, пока клиент их шлёт."""
data = b""
while True:
while b"\r\n\r\n" not in data:
chunk = tls.recv(65536)
if not chunk:
return
data += chunk
head, _sep, data = data.partition(b"\r\n\r\n")
yield head
def test_many_small_answers_add_up_on_one_connection(self) -> None:
seen: list[bytes] = []
def handle(tls) -> None:
for head in self._requests(tls):
seen.append(head)
tls.sendall(b"HTTP/1.1 301 Moved\r\nContent-Length: 900\r\n\r\n" + b"x" * 900)
result = self._download(self._serve(handle))
self.assertEqual(result.kind, vp.RUN_PASSED)
self.assertGreaterEqual(result.received, vp.TARGET_BYTES)
# Объём набран запросами по одному соединению, а не одним ответом.
self.assertGreater(result.requests, 30)
self.assertTrue(seen[0].startswith(b"GET /page HTTP/1.1\r\n"))
# Запрос браузерный: заголовки Chrome на Windows, а не «Mozilla/5.0» программы.
self.assertIn(b"Chrome/150", seen[0])
self.assertIn(b'sec-ch-ua-platform: "Windows"', seen[0])
def test_chunked_answers_are_read_to_the_end(self) -> None:
def handle(tls) -> None:
for _head in self._requests(tls):
tls.sendall(
b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n"
b"7d0\r\n" + b"a" * 2000 + b"\r\n" + b"7d0\r\n" + b"b" * 2000 + b"\r\n0\r\n\r\n"
)
result = self._download(self._serve(handle))
self.assertEqual(result.kind, vp.RUN_PASSED)
# Ответ с заголовками — чуть больше 4 КБ, до 48 КБ нужно ровно тринадцать: каждый
# ответ дочитан до конца, и следующий запрос не съехал.
self.assertEqual(result.requests, 13)
def test_connection_that_freezes_is_stalled_with_its_volume(self) -> None:
release = threading.Event()
self.addCleanup(release.set)
def handle(tls) -> None:
sent = 0
for _head in self._requests(tls):
if sent >= 14_000:
release.wait(3)
return
tls.sendall(b"HTTP/1.1 200 OK\r\nContent-Length: 1960\r\n\r\n" + b"x" * 1960)
sent += 2000
result = self._download(self._serve(handle), stall=0.4)
self.assertEqual(result.kind, vp.RUN_STALLED)
self.assertTrue(vp.CUT_MIN_BYTES <= result.received <= vp.CUT_MAX_BYTES, result.received)
self.assertEqual(vp.judge(vp.VolumeFacts(result, result)).code, vp.VOLUME_CUT)
def test_server_closing_after_answer_is_not_a_stall(self) -> None:
def handle(tls) -> None:
next(self._requests(tls))
tls.sendall(b"HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: 500\r\n\r\n" + b"x" * 500)
result = self._download(self._serve(handle))
self.assertEqual((result.kind, result.requests), (vp.RUN_CLOSED, 1))
self.assertEqual(vp.judge(vp.VolumeFacts(result)).code, vp.VOLUME_UNKNOWN)
def test_tiny_answers_stop_the_probe_early(self) -> None:
"""Сервер отвечает по 150 байт: объём не набрать, десятки запросов слать незачем."""
def handle(tls) -> None:
for _head in self._requests(tls):
tls.sendall(b"HTTP/1.1 204 No Content\r\nX-Pad: " + b"p" * 110 + b"\r\n\r\n")
result = self._download(self._serve(handle))
self.assertEqual((result.kind, result.requests), (vp.RUN_CLOSED, vp.ESTIMATE_AFTER))
self.assertEqual(vp.judge(vp.VolumeFacts(result)).code, vp.VOLUME_UNKNOWN)
def test_head_requests_carry_no_body(self) -> None:
methods: list[bytes] = []
def handle(tls) -> None:
for head in self._requests(tls):
methods.append(head.split(b" ", 1)[0])
# Длина тела названа, но самого тела в ответе на HEAD нет.
tls.sendall(b"HTTP/1.1 200 OK\r\nContent-Length: 50000\r\nX-Pad: " + b"p" * 800 + b"\r\n\r\n")
result = self._download(self._serve(handle), method="HEAD", target=8_000)
self.assertEqual(result.kind, vp.RUN_PASSED)
self.assertEqual(set(methods), {b"HEAD"})
self.assertGreater(result.requests, 5)
def test_big_body_is_not_read_past_the_target(self) -> None:
def handle(tls) -> None:
next(self._requests(tls))
try:
tls.sendall(b"HTTP/1.1 200 OK\r\nContent-Length: 5000000\r\n\r\n" + b"x" * 5_000_000)
except (OSError, ssl.SSLError):
pass
result = self._download(self._serve(handle))
self.assertEqual((result.kind, result.requests), (vp.RUN_PASSED, 1))
self.assertLess(result.received, 1_000_000)
def test_cancelled_probe_reports_cancel(self) -> None:
token = SocketCancel()
token.cancel()
self.assertEqual(vp.download(HOST, "127.0.0.1", "/", port=9, cancel=token).kind, vp.RUN_CANCELLED)
if __name__ == "__main__":
unittest.main()