This commit is contained in:
jze9
2026-06-01 17:05:07 +05:00
parent 89c3500151
commit 070261e947
7 changed files with 1288 additions and 0 deletions

521
test_adversarial.py Normal file
View File

@@ -0,0 +1,521 @@
"""
Adversarial tests — написаны ДО исправлений.
Часть из них должна упасть и показать реальные баги.
Запуск: python3 -m pytest test_adversarial.py -v
"""
from __future__ import annotations
import os
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
import openpyxl
import main
from main import _normalize_kn, parse_pdf_extract, rename_extract, process_all, FIASSearcher
from selenium.common.exceptions import WebDriverException
FOUND_DATA = {
"full_name": "ул. Тестовая, 1",
"status": "Актуальный",
"obj_type": "Здание",
"fias_id": "aaaa-bbbb-cccc-dddd-eeee",
"pdf_url": None,
}
def make_xlsx(tmp_path, rows, headers=("КН", "Вид ОН")) -> str:
p = tmp_path / "in.xlsx"
wb = openpyxl.Workbook()
ws = wb.active
ws.append(list(headers))
for r in rows:
ws.append(list(r))
wb.save(str(p))
return str(p)
def get_result_rows(tmp_path):
files = list(Path(tmp_path).rglob("Результаты_ФИАС_*.xlsx"))
assert files
return list(openpyxl.load_workbook(str(files[0])).active.iter_rows(min_row=2, values_only=True))
def run(tmp_path, rows, headers=("КН", "Вид ОН"), searcher=None):
xlsx = make_xlsx(tmp_path, rows, headers)
if searcher is None:
searcher = MagicMock()
searcher.search.return_value = None
with patch("main.FIASSearcher", return_value=searcher):
process_all(xlsx, str(tmp_path), log=lambda _: None)
return get_result_rows(tmp_path)
def pdf_mock(text):
page = MagicMock()
page.extract_text.return_value = text
ctx = MagicMock()
ctx.__enter__ = lambda s: ctx
ctx.__exit__ = MagicMock(return_value=False)
ctx.pages = [page]
return ctx
# ─────────────────────────────────────────────────────────────────────────────
# rename_extract — path safety
# ─────────────────────────────────────────────────────────────────────────────
class TestRenameExtractPathSafety:
def _src(self, tmp_path):
f = tmp_path / "_tmp.pdf"
f.write_bytes(b"%PDF")
return str(f)
def test_slash_in_kn_does_not_escape_output_dir(self, tmp_path):
"""
Слэш в КН создаёт путь вида output_dir/ГАР_01/01_... —
подпапки не существует → FileNotFoundError.
БАГ: kn.replace(':', '_') не трогает '/'.
"""
result = rename_extract(self._src(tmp_path), "01/01:0001001:1", "25.03.2024", str(tmp_path))
assert Path(result).parent == tmp_path, (
f"Файл создан вне output_dir: {Path(result).parent}"
)
def test_dotdot_in_kn_does_not_escape_output_dir(self, tmp_path):
"""
КН с '../' может вывести файл за пределы сессионной папки.
"""
result = rename_extract(self._src(tmp_path), "../evil:1:1:1", "25.03.2024", str(tmp_path))
assert tmp_path in Path(result).parents or Path(result).parent == tmp_path, (
f"Path traversal: {result}"
)
def test_backslash_in_kn_does_not_break_path(self, tmp_path):
"""Обратный слэш (Windows-данные в Excel) не должен ломать путь."""
result = rename_extract(self._src(tmp_path), "77\\01:0001001:1", "25.03.2024", str(tmp_path))
assert Path(result).exists()
# ─────────────────────────────────────────────────────────────────────────────
# _normalize_kn — граничные случаи
# ─────────────────────────────────────────────────────────────────────────────
class TestNormalizeKnEdge:
def test_only_colons(self):
"""':::' остаётся ':::' — не падает."""
assert _normalize_kn(":::") == ":::"
def test_numeric_value_from_excel(self):
"""
Excel иногда хранит числа без двоеточий (авто-формат).
Функция не должна добавлять двоеточия — это не КН.
"""
result = _normalize_kn(770100010001) # int, без двоеточий
assert ":" not in result
assert result == "770100010001"
def test_float_from_excel(self):
"""Число с плавающей точкой из Excel — не должно превращаться в 'None'."""
result = _normalize_kn(77.0)
assert result != "None"
assert "." in result or result.isdigit()
def test_empty_string(self):
assert _normalize_kn("") == ""
def test_newlines_inside_kn_removed(self):
"""Перенос строки внутри значения ячейки."""
assert _normalize_kn("77:01\n:0001001:1") == "77:01:0001001:1"
# ─────────────────────────────────────────────────────────────────────────────
# parse_pdf_extract — регрессии и граничные случаи
# ─────────────────────────────────────────────────────────────────────────────
class TestParsePdfExtractEdge:
UUID = "12345678-abcd-abcd-abcd-123456789abc"
def _parse(self, tmp_path, text):
p = str(tmp_path / "t.pdf")
Path(p).touch()
with patch("pdfplumber.open", return_value=pdf_mock(text)):
return parse_pdf_extract(p, log=lambda _: None)
def test_multiple_uuids_keyword_match_beats_fallback(self, tmp_path):
"""
В тексте два UUID; у второго есть ключевое слово 'номер'.
Должен быть выбран второй, а не первый.
"""
uuid_noise = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
uuid_correct = "11111111-2222-3333-4444-555555555555"
_, num = self._parse(tmp_path, f"Прочее {uuid_noise}\nномер выписки {uuid_correct}")
assert num == uuid_correct, f"Выбран неправильный UUID: {num}"
def test_short_uuid_not_matched(self, tmp_path):
"""UUID с неправильным числом символов не должен матчиться."""
short = "12345678-abcd-abcd-abcd-12345678" # последняя группа слишком короткая
_, num = self._parse(tmp_path, f"номер {short}")
assert num != short
def test_page_extract_text_returns_none(self, tmp_path):
"""
pdfplumber может вернуть None для страницы без текста.
Код делает `or ""` — не должен падать.
"""
page = MagicMock()
page.extract_text.return_value = None # явный None
ctx = MagicMock()
ctx.__enter__ = lambda s: ctx
ctx.__exit__ = MagicMock(return_value=False)
ctx.pages = [page]
p = str(tmp_path / "t.pdf")
Path(p).touch()
with patch("pdfplumber.open", return_value=ctx):
date, num = parse_pdf_extract(p, log=lambda _: None)
assert date is None
assert num is None
def test_date_regex_does_not_validate_calendar(self, tmp_path):
"""
Регулярка проверяет только цифровой формат ДД.ММ.ГГГГ,
а не реальность даты — '99.99.9999' пройдёт.
Тест документирует это поведение (не баг, но нужно знать).
"""
date, _ = self._parse(tmp_path, "дата выписки 99.99.9999")
assert date == "99.99.9999" # regexp не валидирует, принимает любые цифры
# ─────────────────────────────────────────────────────────────────────────────
# process_all — нештатные входные данные
# ─────────────────────────────────────────────────────────────────────────────
class TestProcessAllEdge:
def test_short_data_rows_no_crash(self, tmp_path):
"""
Строка данных короче заголовка (vid_col существует, но ячейка пустая).
openpyxl дополняет None — не должно быть IndexError.
"""
wb = openpyxl.Workbook()
ws = wb.active
ws.append(["КН", "Вид ОН"])
ws.append(["77:01:0001001:1"]) # только 1 ячейка, нет vid_on
p = str(tmp_path / "in.xlsx")
wb.save(p)
ms = MagicMock()
ms.search.return_value = None
with patch("main.FIASSearcher", return_value=ms):
process_all(p, str(tmp_path), log=lambda _: None)
rows = get_result_rows(tmp_path)
assert len(rows) == 1
assert rows[0][1] is None # vid_on должен быть None, не IndexError
def test_header_at_row5_boundary_found(self, tmp_path):
"""Заголовок на строке 5 (граница поиска) — должен найтись."""
wb = openpyxl.Workbook()
ws = wb.active
for _ in range(4):
ws.append(["мусор"])
ws.append(["КН"]) # строка 5
ws.append(["77:01:0001001:1"])
p = str(tmp_path / "in.xlsx")
wb.save(p)
ms = MagicMock()
ms.search.return_value = FOUND_DATA
with patch("main.FIASSearcher", return_value=ms):
process_all(p, str(tmp_path), log=lambda _: None)
ms.search.assert_called()
def test_header_at_row6_not_found_logs_error(self, tmp_path):
"""Заголовок на строке 6 (за пределами поиска) — должен залогировать ошибку."""
wb = openpyxl.Workbook()
ws = wb.active
for _ in range(5):
ws.append(["мусор"])
ws.append(["КН"]) # строка 6 — вне окна поиска
ws.append(["77:01:0001001:1"])
p = str(tmp_path / "in.xlsx")
wb.save(p)
logs = []
ms = MagicMock()
with patch("main.FIASSearcher", return_value=ms):
process_all(p, str(tmp_path), log=logs.append)
assert any("не найдена" in l for l in logs)
ms.search.assert_not_called()
def test_differently_formatted_duplicates_searched_once(self, tmp_path):
"""
'77:01:0001001:1' и '77 : 01 : 0001001 : 1' нормализуются одинаково —
должны считаться дублями и вести к одному поиску (+ retry = max 2 вызовов).
"""
ms = MagicMock()
ms.search.return_value = FOUND_DATA
xlsx = make_xlsx(tmp_path, [
("77:01:0001001:1",),
("77 : 01 : 0001001 : 1",),
], headers=("КН",))
with patch("main.FIASSearcher", return_value=ms):
process_all(xlsx, str(tmp_path), log=lambda _: None)
assert ms.search.call_count == 1, (
f"Ожидался 1 вызов (дубли дедуплицированы), получено {ms.search.call_count}"
)
def test_exception_on_first_attempt_not_retried_marks_error(self, tmp_path):
"""
WebDriverException на 1-й попытке НЕ вызывает повтор —
КН сразу пишется как 'ошибка'.
Это ограничение текущей логики (retry работает только для None).
"""
attempts = {"n": 0}
def boom(kn):
attempts["n"] += 1
if attempts["n"] == 1:
raise WebDriverException("stale")
return FOUND_DATA # вторая попытка дала бы результат, но не дойдёт
ms = MagicMock()
ms.search.side_effect = boom
rows = run(tmp_path, [("77:01:0001001:1", None)], searcher=ms)
assert rows[0][2] == "ошибка"
assert ms.search.call_count == 1 # второй шанс не даётся
def test_kn_with_slash_in_excel_does_not_crash_process_all(self, tmp_path):
"""
Если ячейка Excel содержит КН со слэшем (некорректные данные),
download_pdf не должен завершаться path traversal или крашем.
Ошибка должна быть поймана и записана в Excel.
"""
data_with_pdf = {**FOUND_DATA, "pdf_url": "https://fias.nalog.ru/Export/ExportPdfStatement?id=1"}
ms = MagicMock()
ms.search.return_value = data_with_pdf
ms.download_pdf.return_value = False # скачивание не удалось — не важно
# Главное — не должно быть необработанного исключения
rows = run(tmp_path, [("01/01:0001001:1", None)], searcher=ms)
# Строка должна быть в результатах (найдена, пусть без PDF)
assert len(rows) == 1
def test_output_xlsx_created_even_when_no_kns(self, tmp_path):
"""
При пустом списке КН результирующий файл всё равно должен существовать.
До исправления: wb_out.save() вызывался только внутри цикла → файл не создавался.
"""
run(tmp_path, [])
files = list(Path(tmp_path).rglob("Результаты_ФИАС_*.xlsx"))
assert files, "Файл результатов не создан при пустом входном xlsx"
def test_search_result_fias_id_empty_string_not_crashes(self, tmp_path):
"""
fias_id = '' (сайт вернул пустую ячейку) — не должно падать.
openpyxl хранит пустую строку как пустую ячейку и читает обратно как None —
это поведение библиотеки, не баг кода.
"""
ms = MagicMock()
ms.search.return_value = {**FOUND_DATA, "fias_id": ""}
rows = run(tmp_path, [("77:01:0001001:1", None)], searcher=ms)
# Пустая строка → пустая ячейка → None при чтении (openpyxl behavior)
assert rows[0][2] in ("", None)
# Другие поля должны быть на месте
assert rows[0][4] == "ул. Тестовая, 1"
def test_vid_on_value_preserved_in_output(self, tmp_path):
"""Значение «Вид ОН» из Excel должно попасть в итоговую таблицу."""
ms = MagicMock()
ms.search.return_value = FOUND_DATA
rows = run(tmp_path, [("77:01:0001001:1", "Здание")], searcher=ms)
assert rows[0][1] == "Здание"
def test_none_kn_cell_skipped(self, tmp_path):
"""Пустые ячейки КН не попадают в список и не ищутся."""
wb = openpyxl.Workbook()
ws = wb.active
ws.append(["КН"])
ws.append([None])
ws.append([""])
ws.append([" "])
ws.append(["77:01:0001001:1"]) # единственный валидный
p = str(tmp_path / "in.xlsx")
wb.save(p)
ms = MagicMock()
ms.search.return_value = None
with patch("main.FIASSearcher", return_value=ms):
process_all(p, str(tmp_path), log=lambda _: None)
assert ms.search.call_count <= 2 # только 1 КН × 2 retry
# ─────────────────────────────────────────────────────────────────────────────
# FIASSearcher — парсинг результатов, граничные случаи
# ─────────────────────────────────────────────────────────────────────────────
def cells_of(texts):
return [MagicMock(text=t) for t in texts]
def tr(texts, html=""):
row = MagicMock()
row.find_elements.side_effect = lambda by, tag: cells_of(texts) if tag == "td" else []
row.get_attribute.return_value = html
return row
def th_row():
row = MagicMock()
row.find_elements.return_value = []
return row
def run_search_rows(rows):
"""Прогоняет логику парсинга search() с замоканным Selenium."""
s = FIASSearcher(log=lambda _: None)
driver = MagicMock()
driver.find_elements.side_effect = lambda by, sel: (
rows if "ResultPagerDataPlaceholder" in sel else []
)
driver.find_element.return_value = MagicMock()
s.driver = driver
s.wait = MagicMock()
s.wait.until.return_value = MagicMock()
with patch("main.WebDriverWait") as wdw, patch("main.time"):
inst = MagicMock()
wdw.return_value = inst
calls = [0]
def until_se(cond):
calls[0] += 1
if calls[0] <= 2:
from selenium.common.exceptions import TimeoutException
raise TimeoutException()
return MagicMock()
inst.until.side_effect = until_se
return s.search("77:01:0001001:1")
DATA_CELLS = ["Адрес", "Актуальный", "Здание", "x", "y", "fias-id-here"]
class TestFIASSearcherEdge:
def test_whitespace_only_cells_stripped(self):
"""Ячейки с пробелами должны возвращаться как пустые строки после strip()."""
cells = [" Адрес ", " ", "Здание", "x", "y", " fias "]
row = tr(cells)
result = run_search_rows([row])
assert result is not None
assert result["full_name"] == "Адрес"
assert result["status"] == ""
assert result["fias_id"] == "fias"
def test_six_cells_exactly_accepted(self):
"""Ровно 6 ячеек — граница проверки len(cells) >= 6."""
row = tr(DATA_CELLS) # ровно 6
result = run_search_rows([row])
assert result is not None
def test_five_cells_returns_none(self):
"""5 ячеек — меньше минимума, must return None."""
row = tr(DATA_CELLS[:5])
result = run_search_rows([row])
assert result is None
def test_many_header_rows_then_data(self):
"""Несколько заголовочных строк перед строкой данных — пропускаются все."""
result = run_search_rows([th_row(), th_row(), th_row(), tr(DATA_CELLS)])
assert result is not None
assert result["full_name"] == "Адрес"
def test_pdf_url_with_single_quotes_in_html(self):
"""URL в одинарных кавычках — rstrip должен убрать кавычку."""
html = "href='/Export/ExportPdfStatement?id=42&amp;t=pdf'"
row = tr(DATA_CELLS, html=html)
result = run_search_rows([row])
assert result["pdf_url"] == "https://fias.nalog.ru/Export/ExportPdfStatement?id=42&t=pdf"
def test_pdf_url_ampersand_decoded(self):
"""&amp; в HTML должен быть раскодирован в & в URL."""
html = 'href="/Export/ExportPdfStatement?a=1&amp;b=2"'
row = tr(DATA_CELLS, html=html)
result = run_search_rows([row])
assert "&amp;" not in (result["pdf_url"] or "")
assert "a=1&b=2" in (result["pdf_url"] or "")
def test_no_rows_returns_none(self):
assert run_search_rows([]) is None
def test_only_header_rows_returns_none(self):
result = run_search_rows([th_row(), th_row()])
assert result is None
# ─────────────────────────────────────────────────────────────────────────────
# tmp_pdf path safety
# ─────────────────────────────────────────────────────────────────────────────
class TestTmpPdfPathSafety:
def test_slash_in_kn_tmp_pdf_stays_in_session_dir(self, tmp_path):
"""
КН со слэшем не должен создавать tmp_pdf вне session_dir.
До исправления: f"_tmp_{kn.replace(':', '_')}.pdf" оставлял '/' → path traversal.
"""
data_with_pdf = {**FOUND_DATA, "pdf_url": "https://fias.nalog.ru/Export/ExportPdfStatement?id=1"}
ms = MagicMock()
ms.search.return_value = data_with_pdf
captured_paths = []
original_download = ms.download_pdf
def capture_path(url, path):
captured_paths.append(path)
return False # не скачиваем реально
ms.download_pdf.side_effect = capture_path
with patch("main.FIASSearcher", return_value=ms):
process_all(
make_xlsx(tmp_path, [("01/999:0001001:1", None)]),
str(tmp_path),
log=lambda _: None,
)
# Хотя бы один путь должен был быть передан, и все они должны быть внутри tmp_path
assert captured_paths, "download_pdf не был вызван"
session_dir = str(tmp_path)
for p in captured_paths:
resolved = str(Path(p).resolve())
assert resolved.startswith(str(Path(session_dir).resolve())), (
f"tmp_pdf вышел за пределы session_dir: {p}"
)
def test_dotdot_in_kn_tmp_pdf_stays_in_session_dir(self, tmp_path):
"""../ в КН не должен выводить tmp_pdf за пределы session_dir."""
data_with_pdf = {**FOUND_DATA, "pdf_url": "https://fias.nalog.ru/Export/ExportPdfStatement?id=1"}
ms = MagicMock()
ms.search.return_value = data_with_pdf
captured_paths = []
def capture_path(url, path):
captured_paths.append(path)
return False
ms.download_pdf.side_effect = capture_path
with patch("main.FIASSearcher", return_value=ms):
process_all(
make_xlsx(tmp_path, [("../evil:0001001:1", None)]),
str(tmp_path),
log=lambda _: None,
)
assert captured_paths, "download_pdf не был вызван"
for p in captured_paths:
resolved = str(Path(p).resolve())
assert resolved.startswith(str(Path(str(tmp_path)).resolve())), (
f"tmp_pdf вышел за пределы session_dir: {p}"
)