zapretgui/tests/test_dns_custom_servers.py
loop-uh 0cfff6a448
All checks were successful
Zapret source guards / validate (push) Successful in 10s
Windows-сборка / Dev Dev 21.1.7.66 опубликован
Свой DNS: обычная страница настроек во всю ширину — шапка с итогом введённого и ходом проверки, поля стандартными строками, тексты короче
Форма больше не маленькая карточка в углу страницы. Сверху шапка в стиле
панели «Сейчас» страницы DNS: значок своего сервера, название и строка
состояния. Строка сразу показывает, что получится из введённого (шифрованный
DNS или обычный, какие адреса), при сохранении — ход проверки с бегущей вокруг
значка кометой, а при неудаче — причину; поле с ошибкой подчёркивается.
Кнопки «Отмена» и «Добавить» стоят в шапке.

Поля — стандартные строки настроек (Win11ControlRow) во всю ширину: адрес DoH,
IP-адреса, название. В пустых полях видно, что подставится само: «Найдутся
сами» для адресов и имя сервера для названия.

Строка «адрес DoH и IP-адреса вместе» (так кладёт сервер в буфер «Копировать
DNS»), вставленная в одно поле, раскладывается по двум. Набор с клавиатуры
между полями не переносится; недописанный IP-адрес не считается именем сервера.

Высота формы считается для узкой ширины: длинная ошибка, перенесённая на третью
строку в узком окне, больше не отнимает высоту у строк под ней.

Значок панели DNS стал общим (DnsBadge) для двух страниц.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 01:23:23 +03:00

307 lines
13 KiB
Python

"""Правила своих DNS-серверов: разбор полей, список, плитки и команды сохранения."""
from __future__ import annotations
import unittest
from unittest.mock import patch
from dns import commands, custom_servers
from dns.custom_servers import (
CUSTOM_DNS_CATEGORY,
FIELD_ADDRESSES,
FIELD_DOH,
FIELD_NAME,
build_dns_providers_with_custom,
custom_doh_templates,
parse_doh_template,
read_form,
split_addresses,
)
from dns.doh_lookup import DohLookup
HOME = {"id": "home", "name": "Дом", "ipv4": ["192.168.1.1"], "ipv6": [], "doh": ""}
SECURE = {
"id": "secure",
"name": "dns.example.com",
"ipv4": ["203.0.113.5", "203.0.113.6"],
"ipv6": ["2001:db8::5"],
"doh": "https://dns.example.com/dns-query",
}
class DohTemplateTests(unittest.TestCase):
def test_address_is_parsed_into_host_port_and_path(self) -> None:
template = parse_doh_template(" https://DNS.Example.com/dns-query ")
self.assertEqual(
(template.url, template.host, template.port, template.path),
("https://dns.example.com/dns-query", "dns.example.com", 443, "/dns-query"),
)
self.assertFalse(template.host_is_address)
def test_scheme_and_path_may_be_omitted(self) -> None:
self.assertEqual(parse_doh_template("dns.example.com/abc123").url, "https://dns.example.com/abc123")
self.assertEqual(parse_doh_template("dns.example.com").url, "https://dns.example.com/dns-query")
def test_template_suffix_and_custom_port_are_understood(self) -> None:
self.assertEqual(
parse_doh_template("https://dns.example.com/dns-query{?dns}").url,
"https://dns.example.com/dns-query",
)
template = parse_doh_template("https://dns.example.com:444/dns-query")
self.assertEqual((template.port, template.url), (444, "https://dns.example.com:444/dns-query"))
def test_server_may_be_given_by_address(self) -> None:
self.assertTrue(parse_doh_template("https://9.9.9.9/dns-query").host_is_address)
template = parse_doh_template("https://[2620:fe::fe]/dns-query")
self.assertEqual((template.host, template.url), ("2620:fe::fe", "https://[2620:fe::fe]/dns-query"))
def test_wrong_addresses_are_rejected(self) -> None:
for text in (
"",
"http://dns.example.com/dns-query",
"https://dns example.com/dns-query",
"localhost",
"https://dns.example.com:99999/dns-query",
"https://user@dns.example.com/dns-query",
"https://dns.example.com/dns-query?dns=abc",
# Недописанный IP-адрес — не имя сервера.
"149.112.",
"149.112",
):
with self.subTest(text=text):
self.assertIsNone(parse_doh_template(text))
class FormTests(unittest.TestCase):
def test_addresses_are_split_by_family_in_written_order(self) -> None:
ipv4, ipv6, bad = split_addresses("8.8.8.8, 8.8.4.4;2001:4860:4860::8888 [2001:4860:4860::8844] кот 8.8.8.8")
self.assertEqual(ipv4, ["8.8.8.8", "8.8.4.4"])
self.assertEqual(ipv6, ["2001:4860:4860::8888", "2001:4860:4860::8844"])
self.assertEqual(bad, ["кот"])
def test_one_doh_line_is_enough_and_names_the_server(self) -> None:
form = read_form(doh="https://dns.example.com/dns-query")
self.assertEqual(form.error, "")
self.assertTrue(form.needs_lookup)
self.assertEqual(form.server["name"], "dns.example.com")
self.assertEqual(form.server["doh"], "https://dns.example.com/dns-query")
self.assertEqual((form.server["ipv4"], form.server["ipv6"]), ([], []))
self.assertTrue(form.server["id"].startswith("custom-"))
def test_addresses_alone_make_a_plain_server_named_by_first_address(self) -> None:
form = read_form(addresses="9.9.9.9 149.112.112.112 2620:fe::fe")
self.assertFalse(form.needs_lookup)
self.assertEqual(
form.server | {"id": ""},
{"id": "", "name": "9.9.9.9", "ipv4": ["9.9.9.9", "149.112.112.112"], "ipv6": ["2620:fe::fe"], "doh": ""},
)
def test_editing_keeps_the_server_id_and_cleans_the_name(self) -> None:
form = read_form(server_id="home", name=" Мой DNS ", addresses="10.0.0.1")
self.assertEqual((form.server["id"], form.server["name"]), ("home", "Мой DNS"))
def test_errors_name_the_field(self) -> None:
self.assertEqual(read_form().field, FIELD_DOH)
self.assertEqual(read_form(doh="http://dns.example.com").field, FIELD_DOH)
wrong = read_form(doh="https://dns.example.com/dns-query", addresses="9.9.9.9 кот")
self.assertEqual(wrong.field, FIELD_ADDRESSES)
self.assertIn("кот", wrong.error)
self.assertIsNone(wrong.server)
class PastedTextTests(unittest.TestCase):
def test_doh_with_addresses_is_split_into_two_fields(self) -> None:
self.assertEqual(
custom_servers.split_pasted("https://dns.example.com/dns-query, 203.0.113.5, 2001:db8::5"),
("https://dns.example.com/dns-query", "203.0.113.5 2001:db8::5"),
)
# Именно так кладёт сервер в буфер «Копировать DNS».
self.assertEqual(
custom_servers.split_pasted(custom_servers.clipboard_text(SECURE)),
("https://dns.example.com/dns-query", "203.0.113.5 203.0.113.6 2001:db8::5"),
)
def test_unclear_text_is_left_alone(self) -> None:
for text in (
"9.9.9.9 149.112.112.112",
"https://dns.example.com/dns-query",
"кот 9.9.9.9",
"a.example/q b.example/q 9.9.9.9",
"9.9.9.9 149.112.",
"",
):
with self.subTest(text=text):
self.assertIsNone(custom_servers.split_pasted(text))
class ServerListTests(unittest.TestCase):
def test_new_server_is_appended_and_known_id_is_replaced(self) -> None:
servers, error = custom_servers.upsert_server([HOME], SECURE)
self.assertEqual((error, [item["id"] for item in servers]), ("", ["home", "secure"]))
renamed = dict(HOME, name="Дача", ipv4=["10.0.0.1"])
servers, error = custom_servers.upsert_server(servers, renamed)
self.assertEqual(error, "")
self.assertEqual([(item["id"], item["name"]) for item in servers], [("home", "Дача"), ("secure", "dns.example.com")])
def test_name_cannot_repeat_another_server_or_a_builtin_one(self) -> None:
clash = dict(SECURE, name="дом")
servers, error = custom_servers.upsert_server([HOME], clash)
self.assertIn("«дом»", error)
self.assertEqual(servers, [HOME])
_servers, error = custom_servers.upsert_server([], dict(SECURE, name="Quad9"), reserved_names=["Quad9"])
self.assertIn("Quad9", error)
# Свой же сервер под прежним названием сохранить можно.
self.assertEqual(custom_servers.upsert_server([HOME], dict(HOME, ipv4=["10.0.0.1"]))[1], "")
def test_duplicate_gets_new_id_and_free_name(self) -> None:
servers = custom_servers.duplicate_server([HOME, dict(SECURE, name="Дом копия")], "home")
self.assertEqual(len(servers), 3)
self.assertEqual(servers[2]["name"], "Дом копия 2")
self.assertNotEqual(servers[2]["id"], "home")
self.assertEqual(servers[2]["ipv4"], ["192.168.1.1"])
self.assertEqual(custom_servers.duplicate_server([HOME], "missing"), [HOME])
def test_remove_drops_only_the_named_server(self) -> None:
self.assertEqual(custom_servers.remove_server([HOME, SECURE], "home"), [SECURE])
def test_clipboard_text_carries_doh_and_addresses(self) -> None:
self.assertEqual(custom_servers.clipboard_text(HOME), "192.168.1.1")
self.assertEqual(
custom_servers.clipboard_text(SECURE),
"https://dns.example.com/dns-query, 203.0.113.5, 203.0.113.6, 2001:db8::5",
)
self.assertEqual(custom_servers.addresses_text(SECURE), "203.0.113.5 203.0.113.6 2001:db8::5")
class ProviderTests(unittest.TestCase):
def test_custom_servers_become_a_group_with_doh(self) -> None:
providers = build_dns_providers_with_custom({"Основные": {"Quad9": {"ipv4": ["9.9.9.9"]}}}, [HOME, SECURE])
group = providers[CUSTOM_DNS_CATEGORY]
self.assertEqual(list(group), ["Дом", "dns.example.com"])
self.assertNotIn("doh", group["Дом"])
self.assertEqual(group["dns.example.com"]["doh"], "https://dns.example.com/dns-query")
self.assertEqual(group["dns.example.com"]["custom_id"], "secure")
self.assertIn("Quad9", providers["Основные"])
def test_doh_templates_cover_every_address_of_servers_with_doh(self) -> None:
self.assertEqual(
custom_doh_templates([HOME, SECURE]),
{
"203.0.113.5": "https://dns.example.com/dns-query",
"203.0.113.6": "https://dns.example.com/dns-query",
"2001:db8::5": "https://dns.example.com/dns-query",
},
)
class _Store:
"""Список своих DNS в памяти вместо settings.sqlite3."""
def __init__(self, servers=()):
self.servers = [dict(item) for item in servers]
def update(self, mutator):
self.servers = [dict(item) for item in mutator([dict(item) for item in self.servers])]
return [dict(item) for item in self.servers]
def read(self):
return [dict(item) for item in self.servers]
class CommandTests(unittest.TestCase):
def _store(self, servers=()):
store = _Store(servers)
for target, replacement in (
("settings.store.update_custom_dns_servers", store.update),
("settings.store.get_custom_dns_servers", store.read),
):
patcher = patch(target, side_effect=replacement)
patcher.start()
self.addCleanup(patcher.stop)
return store
def _lookup(self, result):
winapi = patch.multiple(
"dns.winapi",
internet_route=lambda: type("Route", (), {"has_ipv6": True})(),
is_doh_supported=lambda: True,
)
winapi.start()
self.addCleanup(winapi.stop)
patcher = patch("dns.doh_lookup.find_doh_addresses", return_value=result)
found = patcher.start()
self.addCleanup(patcher.stop)
return found
def test_server_with_addresses_is_saved_without_network(self) -> None:
store = self._store()
lookup = self._lookup(DohLookup(host="x"))
result = commands.save_custom_server(dict(HOME))
self.assertTrue(result.success)
self.assertEqual(result.server["name"], "Дом")
self.assertEqual([item["id"] for item in store.servers], ["home"])
lookup.assert_not_called()
def test_doh_only_server_gets_found_addresses(self) -> None:
store = self._store([HOME])
lookup = self._lookup(
DohLookup(host="dns.example.com", ipv4=("203.0.113.5",), ipv6=("2001:db8::5",), notice="оговорка")
)
cancel = object()
result = commands.save_custom_server(dict(SECURE, ipv4=[], ipv6=[]), cancel=cancel)
self.assertTrue(result.success)
self.assertEqual(result.notice, "оговорка")
self.assertEqual((result.server["ipv4"], result.server["ipv6"]), (["203.0.113.5"], ["2001:db8::5"]))
self.assertEqual(store.servers[1]["ipv4"], ["203.0.113.5"])
template = lookup.call_args.args[0]
self.assertEqual(template.host, "dns.example.com")
self.assertEqual(lookup.call_args.kwargs, {"ipv6": True, "doh_supported": True, "cancel": cancel})
def test_failed_lookup_saves_nothing_and_points_at_doh_field(self) -> None:
store = self._store([HOME])
self._lookup(DohLookup(host="dns.example.com", error="сервер молчит"))
result = commands.save_custom_server(dict(SECURE, ipv4=[], ipv6=[]))
self.assertFalse(result.success)
self.assertEqual((result.error, result.field), ("сервер молчит", FIELD_DOH))
self.assertEqual(store.servers, [HOME])
def test_taken_name_is_reported_for_the_name_field(self) -> None:
store = self._store([HOME])
self._lookup(DohLookup(host="x"))
clash = commands.save_custom_server(dict(SECURE, name="Дом"))
builtin = commands.save_custom_server(dict(SECURE, name="Quad9"))
self.assertEqual((clash.success, clash.field), (False, FIELD_NAME))
self.assertEqual((builtin.success, builtin.field), (False, FIELD_NAME))
self.assertEqual(clash.servers, (HOME,))
self.assertEqual(store.servers, [HOME])
def test_delete_and_duplicate_return_the_new_list(self) -> None:
store = self._store([HOME, SECURE])
copied = commands.duplicate_custom_server("home")
self.assertEqual([item["name"] for item in copied.servers], ["Дом", "dns.example.com", "Дом копия"])
deleted = commands.delete_custom_server("home")
self.assertTrue(deleted.success)
self.assertEqual([item["name"] for item in store.servers], ["dns.example.com", "Дом копия"])
if __name__ == "__main__":
unittest.main()