From 64b402c06b81a86a320f226aeeb2ef1217bc88b0 Mon Sep 17 00:00:00 2001 From: Maciek Date: Mon, 20 Jul 2026 10:40:07 +0200 Subject: [PATCH 01/67] chore(tests): add pytest/ruff config, gate failover suite, pin ipv6-test.com, drop obsolete password workaround Signed-off-by: Maciek --- tests/config/knot.config.yaml | 1 + tests/config/testhosts.txt | 5 +++- tests/conftest.py | 3 +-- tests/libs/export_import_helpers.py | 24 ++---------------- tests/pyproject.toml | 38 +++++++++++++++++++++++++++++ 5 files changed, 46 insertions(+), 25 deletions(-) create mode 100644 tests/pyproject.toml diff --git a/tests/config/knot.config.yaml b/tests/config/knot.config.yaml index af060936..a59be9ac 100644 --- a/tests/config/knot.config.yaml +++ b/tests/config/knot.config.yaml @@ -20,6 +20,7 @@ local-data: test.com: [104.18.74.230] ads.wp.pl: [212.77.99.7] svctest-google.com: [8.8.8.8] + ipv6-test.com: [2001:41d0:701:1100::29c8] cache: size-max: 256M diff --git a/tests/config/testhosts.txt b/tests/config/testhosts.txt index d93e8a8a..19ca0f30 100644 --- a/tests/config/testhosts.txt +++ b/tests/config/testhosts.txt @@ -3,4 +3,7 @@ 104.18.74.230 test.com 212.77.99.7 ads.wp.pl # Services/ASN blocking tests — 8.8.8.8 is always AS15169 (Google) in GeoIP. -8.8.8.8 svctest-google.com \ No newline at end of file +8.8.8.8 svctest-google.com +# IP-phase custom-rule tests (test_ip_custom_rules.py, test_custom_rules.py) +# assume this exact AAAA — pinned so they don't depend on live external DNS. +2001:41d0:701:1100::29c8 ipv6-test.com \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index 353802d7..6f73b2e6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -289,9 +289,8 @@ def save_container_logs(compose: DockerCompose, output_dir: str) -> None: timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") for container in containers: + container_name = container.Name try: - # Get container name and logs - container_name = container.Name stdout, stderr = compose.get_logs(container_name) # Create log file with timestamp diff --git a/tests/libs/export_import_helpers.py b/tests/libs/export_import_helpers.py index 35e31b37..8baa97b9 100644 --- a/tests/libs/export_import_helpers.py +++ b/tests/libs/export_import_helpers.py @@ -25,30 +25,10 @@ import moddns.api_client as client import moddns.configuration as api_config from moddns import RequestsLoginBody +from helpers import generate_complex_password from libs.settings import get_settings -# Special-char set matching the API's `reSpecialChar` regex in -# api/internal/validator/validator.go:23. `helpers.generate_complex_password` -# draws from string.punctuation, which can pick characters outside this set -# (e.g. apostrophe, backslash) and cause flaky registration failures — -# regenerate here with a constrained pool so account creation is deterministic. -_PASSWORD_SPECIALS = "!@#$%^&*(),;.?:{}[]|<>_-" - - -def _stable_complex_password(length: int = 16) -> str: - pool = string.ascii_letters + string.digits + _PASSWORD_SPECIALS - parts = [ - random.choice(string.ascii_uppercase), - random.choice(string.ascii_lowercase), - random.choice(string.digits), - random.choice(_PASSWORD_SPECIALS), - ] - parts.extend(random.choice(pool) for _ in range(length - 4)) - random.shuffle(parts) - return "".join(parts) - - # --------------------------------------------------------------------------- # Account creation that retains the password (needed for reauth) # --------------------------------------------------------------------------- @@ -71,7 +51,7 @@ def create_account_with_password() -> tuple[Any, str, str, str]: email = ( f"test{''.join(random.choice(string.digits) for _ in range(5))}@ivpn.net" ) - password = _stable_complex_password() + password = generate_complex_password() subscription_id, pa_cookie = create_temp_subscription() diff --git a/tests/pyproject.toml b/tests/pyproject.toml new file mode 100644 index 00000000..abc33a00 --- /dev/null +++ b/tests/pyproject.toml @@ -0,0 +1,38 @@ +# Test-suite tooling config. pytest resolves this as its rootdir config when +# run from tests/ (e.g. `make test_ci` → `pytest -s dns_tests/`). + +[tool.pytest.ini_options] +# pytest-asyncio: strict was previously only the library default; pin it so +# async tests must carry @pytest.mark.asyncio explicitly. +asyncio_mode = "strict" +# The redis_failover tests stop/start Redis containers and take minutes of +# fixed waits — run them deliberately with `pytest -m redis_failover`. +addopts = '-m "not redis_failover"' +markers = [ + "integration: end-to-end flows requiring the full docker stack", + "redis_failover: destructive tests that stop/start the Redis replica container (excluded by default)", +] +# A typo'd marker name should fail loudly instead of silently never deselecting. +filterwarnings = [ + "error::pytest.PytestUnknownMarkWarning", +] + +[tool.ruff] +target-version = "py311" +extend-exclude = ["moddns_client", "venv", "__pycache__", "docker_logs"] + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "F", # pyflakes + "W", # pycodestyle warnings + "I", # isort + "B", # bugbear + "UP", # pyupgrade (e.g. deprecated datetime.utcnow) + "SIM", # simplify + "ASYNC", # blocking calls inside async functions + "RUF", +] +ignore = [ + "E501", # tests carry long table-data / assertion-message lines +] From 673c9d7b7662f742c5516ce3923176187ed4e5ef Mon Sep 17 00:00:00 2001 From: Maciek Date: Mon, 20 Jul 2026 12:26:34 +0200 Subject: [PATCH 02/67] test(e2e): add DNSLib.wait_until polling to close the Redis master/replica propagation race Signed-off-by: Maciek --- tests/dns_tests/test_blocklists.py | 16 +++-- tests/dns_tests/test_cross_phase_filtering.py | 13 ++-- tests/dns_tests/test_custom_rules.py | 18 +++-- .../dns_tests/test_custom_rules_precedence.py | 70 +++++++++++-------- tests/dns_tests/test_dnssec.py | 16 +++-- tests/dns_tests/test_ip_custom_rules.py | 14 ++-- tests/dns_tests/test_services.py | 48 +++++++------ tests/dns_tests/test_subdomain_blocking.py | 21 +++--- tests/libs/dns_lib.py | 67 +++++++++++++++++- tests/libs/profile_helpers.py | 4 +- 10 files changed, 200 insertions(+), 87 deletions(-) diff --git a/tests/dns_tests/test_blocklists.py b/tests/dns_tests/test_blocklists.py index e0ad4256..8e159628 100644 --- a/tests/dns_tests/test_blocklists.py +++ b/tests/dns_tests/test_blocklists.py @@ -1,7 +1,7 @@ from ipaddress import ip_address import pytest -from libs.dns_lib import DNSLib +from libs.dns_lib import DNSLib, is_blocked, is_resolved from libs.settings import get_settings from dns.rdatatype import A import redis @@ -97,7 +97,11 @@ async def test_blocklist_blocking( resp.data.settings.privacy.blocklists[0] == TEST_BLOCKLIST_ID ), "Threat Intelligence Feeds blocklist is not enabled for profile" - resp = await self.dns_lib.send_doh_request(profile_id, domain, A) + if expected_blocked: + resp = await self.dns_lib.wait_until(profile_id, domain, A, is_blocked) + else: + # NOTE: negative assertion — cannot poll; may read pre-mutation state (see DNSLib.wait_until docstring) + resp = await self.dns_lib.send_doh_request(profile_id, domain, A) ip_addr = resp.answer[0].to_text().split(" ")[-1] if expected_blocked: assert ( @@ -118,7 +122,7 @@ async def test_blocklist_disable_unblocks_domain( profiles_instance = api.ProfileApi(api_client) profile_id = account.profiles[0] - resp = await self.dns_lib.send_doh_request(profile_id, TEST_DOMAIN, A) + resp = await self.dns_lib.wait_until(profile_id, TEST_DOMAIN, A, is_blocked) ip_addr = resp.answer[0].to_text().split(" ")[-1] assert ( ip_addr == "0.0.0.0" @@ -145,7 +149,7 @@ async def test_blocklist_disable_unblocks_domain( len(get_resp.data.settings.privacy.blocklists) == 0 ), "Blocklist still enabled after disabling" - resp2 = await self.dns_lib.send_doh_request(profile_id, TEST_DOMAIN, A) + resp2 = await self.dns_lib.wait_until(profile_id, TEST_DOMAIN, A, is_resolved) ip_addr2 = resp2.answer[0].to_text().split(" ")[-1] assert ( ip_address(ip_addr2) and ip_addr2 != "0.0.0.0" @@ -168,8 +172,8 @@ async def test_blocklist_subdomain_behavior( profile_id = resp.data.profile_id # Parent domain should be blocked - resp_parent = await self.dns_lib.send_doh_request( - profile_id, TEST_DOMAIN, A + resp_parent = await self.dns_lib.wait_until( + profile_id, TEST_DOMAIN, A, is_blocked ) ip_parent = resp_parent.answer[0].to_text().split(" ")[-1] assert ( diff --git a/tests/dns_tests/test_cross_phase_filtering.py b/tests/dns_tests/test_cross_phase_filtering.py index ca65e1f6..9fb1b5f6 100644 --- a/tests/dns_tests/test_cross_phase_filtering.py +++ b/tests/dns_tests/test_cross_phase_filtering.py @@ -10,7 +10,7 @@ """ import pytest -from libs.dns_lib import DNSLib +from libs.dns_lib import DNSLib, is_blocked from libs.settings import get_settings from libs.profile_helpers import ( ProfileHelpers, @@ -66,6 +66,7 @@ async def test_domain_allow_overrides_services_block( ) self._block_service(p, profile_id, [SVC_GOOGLE_ID]) + # NOTE: negative assertion — cannot poll; may read pre-mutation state (see DNSLib.wait_until docstring) resp = await self.dns_lib.send_doh_request( profile_id, SVC_GOOGLE_DOMAIN, A ) @@ -91,6 +92,7 @@ async def test_domain_allow_overrides_ip_block( self._create_custom_rule(p, profile_id, "allow", TEST_DOMAIN) self._create_custom_rule(p, profile_id, "block", TEST_IP) + # NOTE: negative assertion — cannot poll; may read pre-mutation state (see DNSLib.wait_until docstring) resp = await self.dns_lib.send_doh_request(profile_id, TEST_DOMAIN, A) ip_str = extract_ip(resp) assert ip_str != "0.0.0.0", ( @@ -114,6 +116,7 @@ async def test_domain_allow_overrides_blocklist_and_ip_block( self._create_custom_rule(p, profile_id, "allow", TEST_DOMAIN) self._create_custom_rule(p, profile_id, "block", TEST_IP) + # NOTE: negative assertion — cannot poll; may read pre-mutation state (see DNSLib.wait_until docstring) resp = await self.dns_lib.send_doh_request(profile_id, TEST_DOMAIN, A) ip_str = extract_ip(resp) assert ip_str != "0.0.0.0", ( @@ -144,6 +147,7 @@ async def test_domain_allow_overrides_blocklist_and_services_block( ) self._block_service(p, profile_id, [SVC_GOOGLE_ID]) + # NOTE: negative assertion — cannot poll; may read pre-mutation state (see DNSLib.wait_until docstring) resp = await self.dns_lib.send_doh_request( profile_id, SVC_GOOGLE_DOMAIN, A ) @@ -174,6 +178,7 @@ async def test_ip_allow_overrides_services_with_domain_allow( self._block_service(p, profile_id, [SVC_GOOGLE_ID]) self._create_custom_rule(p, profile_id, "allow", SVC_GOOGLE_IP) + # NOTE: negative assertion — cannot poll; may read pre-mutation state (see DNSLib.wait_until docstring) resp = await self.dns_lib.send_doh_request( profile_id, SVC_GOOGLE_DOMAIN, A ) @@ -210,7 +215,7 @@ async def test_domain_block_ignores_ip_allow(self, create_account_and_login): self._create_custom_rule(p, profile_id, "block", TEST_DOMAIN) self._create_custom_rule(p, profile_id, "allow", TEST_IP) - resp = await self.dns_lib.send_doh_request(profile_id, TEST_DOMAIN, A) + resp = await self.dns_lib.wait_until(profile_id, TEST_DOMAIN, A, is_blocked) ip_str = extract_ip(resp) assert ip_str == "0.0.0.0", ( f"#24: Domain block must be terminal -- IP allow should be " @@ -232,7 +237,7 @@ async def test_blocklist_block_ignores_ip_allow( # Default blocklist (TEST_BLOCKLIST_ID) is already enabled on new profiles. self._create_custom_rule(p, profile_id, "allow", TEST_IP) - resp = await self.dns_lib.send_doh_request(profile_id, TEST_DOMAIN, A) + resp = await self.dns_lib.wait_until(profile_id, TEST_DOMAIN, A, is_blocked) ip_str = extract_ip(resp) assert ip_str == "0.0.0.0", ( f"#19 variant: Blocklist block must be terminal -- IP allow " @@ -265,7 +270,7 @@ async def test_default_block_ignores_ip_allow(self, create_account_and_login): ) self._create_custom_rule(p, profile_id, "allow", TEST_IP) - resp = await self.dns_lib.send_doh_request(profile_id, TEST_DOMAIN, A) + resp = await self.dns_lib.wait_until(profile_id, TEST_DOMAIN, A, is_blocked) ip_str = extract_ip(resp) assert ip_str == "0.0.0.0", ( f"Default block must be terminal -- IP allow should be inert; " diff --git a/tests/dns_tests/test_custom_rules.py b/tests/dns_tests/test_custom_rules.py index 843319cb..001fb44d 100644 --- a/tests/dns_tests/test_custom_rules.py +++ b/tests/dns_tests/test_custom_rules.py @@ -1,7 +1,7 @@ from ipaddress import ip_address, IPv6Address import pytest -from libs.dns_lib import DNSLib +from libs.dns_lib import DNSLib, is_blocked from libs.settings import get_settings from dns.rdataclass import IN from dns.rdatatype import A, AAAA @@ -125,6 +125,7 @@ async def test_blocking_custom_rule_answer( ur_resp.status_code == 201 ), f"Custom rule creation failed for {test_domain} with status code: {ur_resp.status_code}" + waited = False for query, expected_value in queries.items(): # Determine if we should send an A or AAAA query try: @@ -137,10 +138,17 @@ async def test_blocking_custom_rule_answer( else: record_type = A - # Send DNS query - resp = await self.dns_lib.send_doh_request( - profile_id, query, record_type - ) + # Send DNS query. The first query whose block outcome depends on + # the rule just created polls for replication to catch up. + if expected_value in ("0.0.0.0", "::") and not waited: + resp = await self.dns_lib.wait_until( + profile_id, query, record_type, is_blocked + ) + waited = True + else: + resp = await self.dns_lib.send_doh_request( + profile_id, query, record_type + ) # Blocked expectations: ensure an answer and it matches the block IP if expected_value in ("0.0.0.0", "::"): assert resp.answer, f"Expected a blocked answer for {query}" diff --git a/tests/dns_tests/test_custom_rules_precedence.py b/tests/dns_tests/test_custom_rules_precedence.py index 5651cb30..97088962 100644 --- a/tests/dns_tests/test_custom_rules_precedence.py +++ b/tests/dns_tests/test_custom_rules_precedence.py @@ -1,7 +1,7 @@ from ipaddress import ip_address import pytest -from libs.dns_lib import DNSLib +from libs.dns_lib import DNSLib, is_blocked, is_resolved from libs.settings import get_settings from dns.rdatatype import A import redis @@ -125,8 +125,8 @@ async def test_custom_allow_overrides_blocklist_block( ) # Confirm the domain is blocked by the blocklist before adding the custom rule - resp_blocked = await self.dns_lib.send_doh_request( - profile_id, TEST_DOMAIN, A + resp_blocked = await self.dns_lib.wait_until( + profile_id, TEST_DOMAIN, A, is_blocked ) ip_blocked = resp_blocked.answer[0].to_text().split(" ")[-1] assert ( @@ -139,7 +139,7 @@ async def test_custom_allow_overrides_blocklist_block( ) # Query again -- custom allow should override blocklist block - resp = await self.dns_lib.send_doh_request(profile_id, TEST_DOMAIN, A) + resp = await self.dns_lib.wait_until(profile_id, TEST_DOMAIN, A, is_resolved) assert resp.answer, f"Expected an answer for {TEST_DOMAIN}" ip_addr = resp.answer[0].to_text().split(" ")[-1] assert ip_addr != "0.0.0.0", ( @@ -173,8 +173,8 @@ async def test_custom_allow_overrides_subdomain_blocklist_block( ) # Confirm subdomain is blocked by inherited blocklist rule - resp_blocked = await self.dns_lib.send_doh_request( - profile_id, TEST_SUBDOMAIN, A + resp_blocked = await self.dns_lib.wait_until( + profile_id, TEST_SUBDOMAIN, A, is_blocked ) ip_blocked = resp_blocked.answer[0].to_text().split(" ")[-1] assert ( @@ -189,6 +189,7 @@ async def test_custom_allow_overrides_subdomain_blocklist_block( # Query again -- custom allow should override subdomain blocklist match. # Note: sub.example.com may not exist in DNS (NXDOMAIN / empty answer), # which is fine -- we only verify it's not actively blocked (0.0.0.0). + # NOTE: negative assertion — cannot poll; may read pre-mutation state (see DNSLib.wait_until docstring) resp = await self.dns_lib.send_doh_request(profile_id, TEST_SUBDOMAIN, A) if resp.answer: ip_addr = resp.answer[0].to_text().split(" ")[-1] @@ -221,8 +222,8 @@ async def test_custom_wildcard_allow_overrides_blocklist( ) # Confirm subdomain is blocked before adding wildcard allow - resp_blocked = await self.dns_lib.send_doh_request( - profile_id, TEST_SUBDOMAIN, A + resp_blocked = await self.dns_lib.wait_until( + profile_id, TEST_SUBDOMAIN, A, is_blocked ) ip_blocked = resp_blocked.answer[0].to_text().split(" ")[-1] assert ( @@ -237,6 +238,7 @@ async def test_custom_wildcard_allow_overrides_blocklist( # Query subdomain -- wildcard allow should override blocklist. # Note: sub.example.com may not exist in DNS (NXDOMAIN / empty answer), # which is fine -- we only verify it's not actively blocked (0.0.0.0). + # NOTE: negative assertion — cannot poll; may read pre-mutation state (see DNSLib.wait_until docstring) resp = await self.dns_lib.send_doh_request(profile_id, TEST_SUBDOMAIN, A) if resp.answer: ip_addr = resp.answer[0].to_text().split(" ")[-1] @@ -273,7 +275,9 @@ async def test_custom_block_on_non_blocklisted_domain( profiles_instance, profile_id, "block", "facebook.com" ) - resp = await self.dns_lib.send_doh_request(profile_id, "facebook.com", A) + resp = await self.dns_lib.wait_until( + profile_id, "facebook.com", A, is_blocked + ) assert resp.answer, "Expected a blocked answer for facebook.com" ip_addr = resp.answer[0].to_text().split(" ")[-1] assert ( @@ -303,7 +307,9 @@ async def test_default_block_rule_blocks_all(self, create_account_and_login): # Set default_rule to block self._set_default_rule(profiles_instance, profile_id, "block") - resp = await self.dns_lib.send_doh_request(profile_id, "google.com", A) + resp = await self.dns_lib.wait_until( + profile_id, "google.com", A, is_blocked + ) assert resp.answer, "Expected a blocked answer for google.com" ip_addr = resp.answer[0].to_text().split(" ")[-1] assert ( @@ -337,8 +343,8 @@ async def test_custom_allow_overrides_default_block( self._set_default_rule(profiles_instance, profile_id, "block") # Confirm facebook.com is blocked by default rule - resp_blocked = await self.dns_lib.send_doh_request( - profile_id, "facebook.com", A + resp_blocked = await self.dns_lib.wait_until( + profile_id, "facebook.com", A, is_blocked ) ip_blocked = resp_blocked.answer[0].to_text().split(" ")[-1] assert ( @@ -351,7 +357,9 @@ async def test_custom_allow_overrides_default_block( ) # Query again -- custom allow should override default block - resp = await self.dns_lib.send_doh_request(profile_id, "facebook.com", A) + resp = await self.dns_lib.wait_until( + profile_id, "facebook.com", A, is_resolved + ) assert resp.answer, "Expected an answer for facebook.com" ip_addr = resp.answer[0].to_text().split(" ")[-1] assert ip_addr != "0.0.0.0", ( @@ -388,8 +396,8 @@ async def test_blocklist_block_with_default_block( self._set_default_rule(profiles_instance, profile_id, "block") # Blocklisted domain should be blocked (both blocklist and default rule) - resp_blocklisted = await self.dns_lib.send_doh_request( - profile_id, TEST_DOMAIN, A + resp_blocklisted = await self.dns_lib.wait_until( + profile_id, TEST_DOMAIN, A, is_blocked ) assert ( resp_blocklisted.answer @@ -400,8 +408,8 @@ async def test_blocklist_block_with_default_block( ), f"Expected {TEST_DOMAIN} to be blocked, got {ip_blocklisted}" # Non-blocklisted domain should also be blocked (by default rule) - resp_non_blocklisted = await self.dns_lib.send_doh_request( - profile_id, "google.com", A + resp_non_blocklisted = await self.dns_lib.wait_until( + profile_id, "google.com", A, is_blocked ) assert ( resp_non_blocklisted.answer @@ -448,8 +456,8 @@ async def test_exact_custom_block_does_not_block_www_subdomain( ) # facebook.com itself should be blocked - resp_exact = await self.dns_lib.send_doh_request( - profile_id, "facebook.com", A + resp_exact = await self.dns_lib.wait_until( + profile_id, "facebook.com", A, is_blocked ) assert resp_exact.answer, "Expected a blocked answer for facebook.com" ip_exact = resp_exact.answer[0].to_text().split(" ")[-1] @@ -492,8 +500,8 @@ async def test_wildcard_custom_block_blocks_www_subdomain( ) # facebook.com itself should be blocked - resp_root = await self.dns_lib.send_doh_request( - profile_id, "facebook.com", A + resp_root = await self.dns_lib.wait_until( + profile_id, "facebook.com", A, is_blocked ) assert resp_root.answer, "Expected a blocked answer for facebook.com" ip_root = resp_root.answer[0].to_text().split(" ")[-1] @@ -535,8 +543,8 @@ async def test_dot_prefix_custom_block_blocks_www_subdomain( ) # facebook.com itself should be blocked - resp_root = await self.dns_lib.send_doh_request( - profile_id, "facebook.com", A + resp_root = await self.dns_lib.wait_until( + profile_id, "facebook.com", A, is_blocked ) assert resp_root.answer, "Expected a blocked answer for facebook.com" ip_root = resp_root.answer[0].to_text().split(" ")[-1] @@ -601,7 +609,13 @@ async def test_custom_block_subdomain_matching_matrix( profiles_instance, profile_id, "block", pattern ) - resp = await self.dns_lib.send_doh_request(profile_id, subdomain, A) + if expect_blocked: + resp = await self.dns_lib.wait_until( + profile_id, subdomain, A, is_blocked + ) + else: + # NOTE: negative assertion — cannot poll; may read pre-mutation state (see DNSLib.wait_until docstring) + resp = await self.dns_lib.send_doh_request(profile_id, subdomain, A) if expect_blocked: assert resp.answer, f"Expected a blocked answer for {subdomain}" @@ -644,8 +658,8 @@ async def test_include_mode_auto_prepends_wildcard( ) # facebook.com itself should be blocked - resp_root = await self.dns_lib.send_doh_request( - profile_id, "facebook.com", A + resp_root = await self.dns_lib.wait_until( + profile_id, "facebook.com", A, is_blocked ) assert resp_root.answer, "Expected a blocked answer for facebook.com" ip_root = resp_root.answer[0].to_text().split(" ")[-1] @@ -689,8 +703,8 @@ async def test_exact_mode_does_not_block_subdomain( ) # facebook.com itself should be blocked - resp_root = await self.dns_lib.send_doh_request( - profile_id, "facebook.com", A + resp_root = await self.dns_lib.wait_until( + profile_id, "facebook.com", A, is_blocked ) assert resp_root.answer, "Expected a blocked answer for facebook.com" ip_root = resp_root.answer[0].to_text().split(" ")[-1] diff --git a/tests/dns_tests/test_dnssec.py b/tests/dns_tests/test_dnssec.py index c85e9ddc..08dff276 100644 --- a/tests/dns_tests/test_dnssec.py +++ b/tests/dns_tests/test_dnssec.py @@ -1,7 +1,7 @@ from ipaddress import ip_address import pytest -from libs.dns_lib import DNSLib +from libs.dns_lib import DNSLib, is_resolved from libs.settings import get_settings from dns.rdataclass import IN from dns.rdatatype import A, RRSIG @@ -44,7 +44,7 @@ async def test_valid_dnssec_answer(self, create_account_and_login): not profile.settings.security.dnssec.send_do_bit ), "DO bit is enabled by default for new profiles but should be disabled" - resp = await self.dns_lib.send_doh_request(profile_id, "example.com", "A") + resp = await self.dns_lib.wait_until(profile_id, "example.com", "A", is_resolved) assert ( len(resp.answer) == 1 ) # 1 answers since DNSSEC is configured on example.com @@ -77,7 +77,9 @@ async def test_valid_dnssec_answer(self, create_account_and_login): resp.status_code == 200 ), f"Profile DNSSEC settings update failed with status code: {resp.status_code} and payload {resp.data}" - resp = await self.dns_lib.send_doh_request(profile_id, "example.com", "A") + resp = await self.dns_lib.wait_until( + profile_id, "example.com", "A", lambda r: len(r.answer) == 2 + ) assert ( len(resp.answer) == 2 ) # 2 answers since DNSSEC is configured on example.com @@ -102,7 +104,9 @@ async def test_invalid_dnssec_answer(self, create_account_and_login): assert len(account.profiles) == 1 profile_id = account.profiles[0] - resp = await self.dns_lib.send_doh_request(profile_id, "dnssec-failed.org", "A") + resp = await self.dns_lib.wait_until( + profile_id, "dnssec-failed.org", "A", lambda r: r.rcode() == SERVFAIL + ) assert ( len(resp.answer) == 0 ) # No answers since DNSSEC check failed on dnssec-failed.org @@ -166,7 +170,9 @@ async def test_answer_no_dnssec(self, test_domain, expected_results): assert ( resp.status_code == 200 ), f"Profile DNSSEC settings update failed with status code: {resp.status_code} and payload {resp.data}" - resp = await self.dns_lib.send_doh_request(profile_id, test_domain, "A") + resp = await self.dns_lib.wait_until( + profile_id, test_domain, "A", lambda r: r.flags & CD + ) assert len(resp.answer) == expected_results["resp_length"] assert resp.rcode() == expected_results["rcode"] assert resp.answer[0].rdtype == expected_results["rdtype"] diff --git a/tests/dns_tests/test_ip_custom_rules.py b/tests/dns_tests/test_ip_custom_rules.py index db8ca344..422d59f8 100644 --- a/tests/dns_tests/test_ip_custom_rules.py +++ b/tests/dns_tests/test_ip_custom_rules.py @@ -15,7 +15,7 @@ from ipaddress import ip_address import pytest -from libs.dns_lib import DNSLib +from libs.dns_lib import DNSLib, is_blocked from libs.settings import get_settings from dns.rdatatype import A, AAAA @@ -78,8 +78,8 @@ async def test_block_matching_ipv4(self, create_account_and_login): self._create_custom_rule(p, profile_id, "block", TEST_IPV4) - resp = await self.dns_lib.send_doh_request( - profile_id, TEST_IPV4_DOMAIN, A + resp = await self.dns_lib.wait_until( + profile_id, TEST_IPV4_DOMAIN, A, is_blocked ) assert resp.answer, f"Expected a blocked answer for {TEST_IPV4_DOMAIN}" ip_addr = resp.answer[0].to_text().split(" ")[-1] @@ -104,8 +104,8 @@ async def test_block_matching_ipv6(self, create_account_and_login): self._create_custom_rule(p, profile_id, "block", TEST_IPV6) - resp = await self.dns_lib.send_doh_request( - profile_id, TEST_IPV6_DOMAIN, AAAA + resp = await self.dns_lib.wait_until( + profile_id, TEST_IPV6_DOMAIN, AAAA, is_blocked ) assert resp.answer, f"Expected a blocked answer for {TEST_IPV6_DOMAIN}" ip_addr = resp.answer[0].to_text().split(" ")[-1] @@ -133,6 +133,7 @@ async def test_block_nonmatching_ip_does_not_block( # Block an IP from TEST-NET that no real domain resolves to. self._create_custom_rule(p, profile_id, "block", NONEXISTENT_IPV4) + # NOTE: negative assertion — cannot poll; may read pre-mutation state (see DNSLib.wait_until docstring) resp = await self.dns_lib.send_doh_request( profile_id, "google.com", A ) @@ -162,6 +163,7 @@ async def test_ip_block_does_not_affect_unrelated_domain( self._create_custom_rule(p, profile_id, "block", TEST_IPV4) + # NOTE: negative assertion — cannot poll; may read pre-mutation state (see DNSLib.wait_until docstring) resp = await self.dns_lib.send_doh_request( profile_id, "google.com", A ) @@ -188,6 +190,7 @@ async def test_allow_matching_ipv4(self, create_account_and_login): self._create_custom_rule(p, profile_id, "allow", TEST_IPV4) + # NOTE: negative assertion — cannot poll; may read pre-mutation state (see DNSLib.wait_until docstring) resp = await self.dns_lib.send_doh_request( profile_id, TEST_IPV4_DOMAIN, A ) @@ -223,6 +226,7 @@ async def test_domain_allow_overrides_ip_block( # Block the IP it resolves to. self._create_custom_rule(p, profile_id, "block", TEST_IPV4) + # NOTE: negative assertion — cannot poll; may read pre-mutation state (see DNSLib.wait_until docstring) resp = await self.dns_lib.send_doh_request( profile_id, TEST_IPV4_DOMAIN, A ) diff --git a/tests/dns_tests/test_services.py b/tests/dns_tests/test_services.py index f9e98dce..1756787d 100644 --- a/tests/dns_tests/test_services.py +++ b/tests/dns_tests/test_services.py @@ -15,7 +15,7 @@ """ import pytest -from libs.dns_lib import DNSLib +from libs.dns_lib import DNSLib, is_blocked, is_resolved from libs.settings import get_settings from libs.profile_helpers import ( ProfileHelpers, @@ -68,7 +68,7 @@ async def test_services_block_by_asn(self, create_account_and_login): profile_id = self._create_profile(p, "svc_block") self._block_service(p, profile_id, [SVC_GOOGLE_ID]) - resp = await self.dns_lib.send_doh_request(profile_id, SVC_GOOGLE_DOMAIN, A) + resp = await self.dns_lib.wait_until(profile_id, SVC_GOOGLE_DOMAIN, A, is_blocked) ip_str = extract_ip(resp) assert ip_str == "0.0.0.0", ( f"Services block for {SVC_GOOGLE_ID} did not block " @@ -92,6 +92,7 @@ async def test_services_block_does_not_affect_other_asn( profile_id = self._create_profile(p, "svc_other_asn") self._block_service(p, profile_id, [SVC_GOOGLE_ID]) + # NOTE: negative assertion — cannot poll; may read pre-mutation state (see DNSLib.wait_until docstring) resp = await self.dns_lib.send_doh_request(profile_id, TEST_DOMAIN, A) ip_str = extract_ip(resp) assert ip_str != "0.0.0.0", ( @@ -114,13 +115,13 @@ async def test_services_unblock_restores_resolution(self, create_account_and_log self._block_service(p, profile_id, [SVC_GOOGLE_ID]) # Verify blocked first. - resp = await self.dns_lib.send_doh_request(profile_id, SVC_GOOGLE_DOMAIN, A) + resp = await self.dns_lib.wait_until(profile_id, SVC_GOOGLE_DOMAIN, A, is_blocked) assert extract_ip(resp) == "0.0.0.0", "Expected blocked before unblock" # Unblock. self._unblock_service(p, profile_id, [SVC_GOOGLE_ID]) - resp = await self.dns_lib.send_doh_request(profile_id, SVC_GOOGLE_DOMAIN, A) + resp = await self.dns_lib.wait_until(profile_id, SVC_GOOGLE_DOMAIN, A, is_resolved) ip_str = extract_ip(resp) assert ip_str != "0.0.0.0", ( f"After unblocking {SVC_GOOGLE_ID}, {SVC_GOOGLE_DOMAIN} should " @@ -164,7 +165,7 @@ async def test_services_block_by_alias(self, create_account_and_login): profile_id = self._create_profile(p, "svc_block_alias") self._block_service(p, profile_id, [SVC_GOOGLE_ALIAS_ID]) - resp = await self.dns_lib.send_doh_request(profile_id, SVC_GOOGLE_DOMAIN, A) + resp = await self.dns_lib.wait_until(profile_id, SVC_GOOGLE_DOMAIN, A, is_blocked) ip_str = extract_ip(resp) assert ip_str == "0.0.0.0", ( f"Alias block for {SVC_GOOGLE_ALIAS_ID} did not block " @@ -189,6 +190,7 @@ async def test_services_block_by_alias_does_not_affect_other_asn( profile_id = self._create_profile(p, "svc_alias_other_asn") self._block_service(p, profile_id, [SVC_GOOGLE_ALIAS_ID]) + # NOTE: negative assertion — cannot poll; may read pre-mutation state (see DNSLib.wait_until docstring) resp = await self.dns_lib.send_doh_request(profile_id, TEST_DOMAIN, A) ip_str = extract_ip(resp) assert ip_str != "0.0.0.0", ( @@ -213,12 +215,12 @@ async def test_services_unblock_by_alias_restores_resolution( profile_id = self._create_profile(p, "svc_unblock_alias") self._block_service(p, profile_id, [SVC_GOOGLE_ALIAS_ID]) - resp = await self.dns_lib.send_doh_request(profile_id, SVC_GOOGLE_DOMAIN, A) + resp = await self.dns_lib.wait_until(profile_id, SVC_GOOGLE_DOMAIN, A, is_blocked) assert extract_ip(resp) == "0.0.0.0", "Expected blocked before unblock" self._unblock_service(p, profile_id, [SVC_GOOGLE_ALIAS_ID]) - resp = await self.dns_lib.send_doh_request(profile_id, SVC_GOOGLE_DOMAIN, A) + resp = await self.dns_lib.wait_until(profile_id, SVC_GOOGLE_DOMAIN, A, is_resolved) ip_str = extract_ip(resp) assert ip_str != "0.0.0.0", ( f"After unblocking alias {SVC_GOOGLE_ALIAS_ID}, " @@ -260,7 +262,7 @@ async def test_apple_services_block_by_asn(self, create_account_and_login): profile_id = self._create_profile(p, "svc_block_apple") self._block_service(p, profile_id, [SVC_APPLE_ID]) - resp = await self.dns_lib.send_doh_request(profile_id, SVC_APPLE_DOMAIN, A) + resp = await self.dns_lib.wait_until(profile_id, SVC_APPLE_DOMAIN, A, is_blocked) ip_str = extract_ip(resp) assert ip_str == "0.0.0.0", ( f"Services block for {SVC_APPLE_ID} did not block " @@ -302,8 +304,8 @@ async def test_microsoft_services_block_by_asn(self, create_account_and_login): profile_id = self._create_profile(p, "svc_block_msft") self._block_service(p, profile_id, [SVC_MICROSOFT_ID]) - resp = await self.dns_lib.send_doh_request( - profile_id, SVC_MICROSOFT_DOMAIN, A + resp = await self.dns_lib.wait_until( + profile_id, SVC_MICROSOFT_DOMAIN, A, is_blocked ) ip_str = extract_ip(resp) assert ip_str == "0.0.0.0", ( @@ -341,6 +343,7 @@ async def test_ip_allow_overrides_services_block(self, create_account_and_login) # Allow the specific IP that svctest-google.com resolves to. self._create_custom_rule(p, profile_id, "allow", SVC_GOOGLE_IP) + # NOTE: negative assertion — cannot poll; may read pre-mutation state (see DNSLib.wait_until docstring) resp = await self.dns_lib.send_doh_request(profile_id, SVC_GOOGLE_DOMAIN, A) ip_str = extract_ip(resp) assert ip_str != "0.0.0.0", ( @@ -373,7 +376,7 @@ async def test_asn_custom_block(self, create_account_and_login): self._create_custom_rule(p, profile_id, "block", "AS15169") - resp = await self.dns_lib.send_doh_request(profile_id, SVC_GOOGLE_DOMAIN, A) + resp = await self.dns_lib.wait_until(profile_id, SVC_GOOGLE_DOMAIN, A, is_blocked) ip_str = extract_ip(resp) assert ip_str == "0.0.0.0", ( f"ASN block for AS15169 did not block {SVC_GOOGLE_DOMAIN}; " @@ -393,6 +396,7 @@ async def test_asn_custom_block_does_not_affect_other_asn( self._create_custom_rule(p, profile_id, "block", "AS15169") + # NOTE: negative assertion — cannot poll; may read pre-mutation state (see DNSLib.wait_until docstring) resp = await self.dns_lib.send_doh_request(profile_id, TEST_DOMAIN, A) ip_str = extract_ip(resp) assert ip_str != "0.0.0.0", ( @@ -416,6 +420,7 @@ async def test_asn_allow_overrides_services_block(self, create_account_and_login self._block_service(p, profile_id, [SVC_GOOGLE_ID]) self._create_custom_rule(p, profile_id, "allow", "AS15169") + # NOTE: negative assertion — cannot poll; may read pre-mutation state (see DNSLib.wait_until docstring) resp = await self.dns_lib.send_doh_request(profile_id, SVC_GOOGLE_DOMAIN, A) ip_str = extract_ip(resp) assert ip_str != "0.0.0.0", ( @@ -461,8 +466,8 @@ async def test_services_block_https_query_no_ip_hints( profile_id = self._create_profile(p, "svc_https_hints") self._block_service(p, profile_id, [SVC_GOOGLE_ID]) - resp = await self.dns_lib.send_doh_request( - profile_id, REAL_GOOGLE_DOMAIN, HTTPS + resp = await self.dns_lib.wait_until( + profile_id, REAL_GOOGLE_DOMAIN, HTTPS, lambda r: bool(r.answer) ) # HTTPS records without IP hints (e.g. alpn-only) are safe @@ -497,8 +502,8 @@ async def test_services_no_block_real_domain_https_query( profile_id = self._create_profile(p, "svc_real_https_noblock") # Do NOT block any service. - resp = await self.dns_lib.send_doh_request( - profile_id, REAL_GOOGLE_DOMAIN, HTTPS + resp = await self.dns_lib.wait_until( + profile_id, REAL_GOOGLE_DOMAIN, HTTPS, lambda r: bool(r.answer) ) assert resp.answer, ( @@ -547,8 +552,8 @@ async def test_https_hints_precondition(self, create_account_and_login): profile_id = self._create_profile(p, "https_hints_pre") - resp = await self.dns_lib.send_doh_request( - profile_id, REAL_HTTPS_HINTS_DOMAIN, HTTPS + resp = await self.dns_lib.wait_until( + profile_id, REAL_HTTPS_HINTS_DOMAIN, HTTPS, lambda r: bool(r.answer) ) assert resp.answer, ( f"HTTPS query for {REAL_HTTPS_HINTS_DOMAIN} returned empty answer" @@ -583,8 +588,9 @@ async def test_asn_block_catches_https_ipv4hint(self, create_account_and_login): profile_id = self._create_profile(p, "https_hints_asn") self._create_custom_rule(p, profile_id, "block", "AS13335") - resp = await self.dns_lib.send_doh_request( - profile_id, REAL_HTTPS_HINTS_DOMAIN, HTTPS + resp = await self.dns_lib.wait_until( + profile_id, REAL_HTTPS_HINTS_DOMAIN, HTTPS, + lambda r: r.rcode() == dns.rcode.NOERROR and not r.answer, ) # When the proxy extracts ipv4hint IPs from the HTTPS record # and matches them against the ASN custom rule, the query @@ -620,8 +626,8 @@ async def test_asn_block_also_blocks_a_record(self, create_account_and_login): profile_id = self._create_profile(p, "https_hints_a") self._create_custom_rule(p, profile_id, "block", "AS13335") - resp = await self.dns_lib.send_doh_request( - profile_id, REAL_HTTPS_HINTS_DOMAIN, A + resp = await self.dns_lib.wait_until( + profile_id, REAL_HTTPS_HINTS_DOMAIN, A, is_blocked ) ip_str = extract_ip(resp) assert ip_str == "0.0.0.0", ( diff --git a/tests/dns_tests/test_subdomain_blocking.py b/tests/dns_tests/test_subdomain_blocking.py index 6e638b29..a23af272 100644 --- a/tests/dns_tests/test_subdomain_blocking.py +++ b/tests/dns_tests/test_subdomain_blocking.py @@ -2,7 +2,7 @@ import uuid import pytest -from libs.dns_lib import DNSLib +from libs.dns_lib import DNSLib, is_blocked, is_resolved from libs.settings import get_settings from dns.rdatatype import A import redis @@ -113,7 +113,7 @@ async def test_parent_domain_blocked( _, cookie = create_account_and_login profile_id = self._create_profile(cookie) - resp = await self.dns_lib.send_doh_request(profile_id, TEST_DOMAIN, A) + resp = await self.dns_lib.wait_until(profile_id, TEST_DOMAIN, A, is_blocked) assert _is_blocked( resp ), f"Blocklisted parent domain {TEST_DOMAIN} was not blocked (expected 0.0.0.0)" @@ -131,7 +131,7 @@ async def test_subdomain_blocked_by_default( _, cookie = create_account_and_login profile_id = self._create_profile(cookie) - resp = await self.dns_lib.send_doh_request(profile_id, TEST_SUBDOMAIN, A) + resp = await self.dns_lib.wait_until(profile_id, TEST_SUBDOMAIN, A, is_blocked) assert _is_blocked( resp ), f"Subdomain {TEST_SUBDOMAIN} was not blocked by default (expected 0.0.0.0)" @@ -149,7 +149,7 @@ async def test_www_subdomain_blocked( profile_id = self._create_profile(cookie) domain = f"www.{TEST_DOMAIN}" - resp = await self.dns_lib.send_doh_request(profile_id, domain, A) + resp = await self.dns_lib.wait_until(profile_id, domain, A, is_blocked) assert _is_blocked( resp ), f"www subdomain {domain} was not blocked (expected 0.0.0.0)" @@ -167,7 +167,7 @@ async def test_deep_subdomain_blocked( profile_id = self._create_profile(cookie) domain = f"a.b.{TEST_DOMAIN}" - resp = await self.dns_lib.send_doh_request(profile_id, domain, A) + resp = await self.dns_lib.wait_until(profile_id, domain, A, is_blocked) assert _is_blocked( resp ), f"Deep subdomain {domain} was not blocked (expected 0.0.0.0)" @@ -187,7 +187,7 @@ async def test_subdomain_allowed_when_rule_disabled( self._set_blocklists_subdomains_rule(cookie, profile_id, "allow") - resp = await self.dns_lib.send_doh_request(profile_id, TEST_SUBDOMAIN, A) + resp = await self.dns_lib.wait_until(profile_id, TEST_SUBDOMAIN, A, is_resolved) assert _is_not_blocked( resp ), f"Subdomain {TEST_SUBDOMAIN} was still blocked after setting blocklists_subdomains_rule to 'allow'" @@ -207,21 +207,21 @@ async def test_subdomain_rule_toggle( profile_id = self._create_profile(cookie) # Step 1: default setting is "block" - resp1 = await self.dns_lib.send_doh_request(profile_id, TEST_SUBDOMAIN, A) + resp1 = await self.dns_lib.wait_until(profile_id, TEST_SUBDOMAIN, A, is_blocked) assert _is_blocked( resp1 ), f"Step 1 failed: {TEST_SUBDOMAIN} should be blocked with default blocklists_subdomains_rule" # Step 2: switch to "allow" self._set_blocklists_subdomains_rule(cookie, profile_id, "allow") - resp2 = await self.dns_lib.send_doh_request(profile_id, TEST_SUBDOMAIN, A) + resp2 = await self.dns_lib.wait_until(profile_id, TEST_SUBDOMAIN, A, is_resolved) assert _is_not_blocked( resp2 ), f"Step 2 failed: {TEST_SUBDOMAIN} should not be blocked after setting blocklists_subdomains_rule to 'allow'" # Step 3: switch back to "block" self._set_blocklists_subdomains_rule(cookie, profile_id, "block") - resp3 = await self.dns_lib.send_doh_request(profile_id, TEST_SUBDOMAIN, A) + resp3 = await self.dns_lib.wait_until(profile_id, TEST_SUBDOMAIN, A, is_blocked) assert _is_blocked( resp3 ), f"Step 3 failed: {TEST_SUBDOMAIN} should be blocked again after restoring blocklists_subdomains_rule to 'block'" @@ -238,6 +238,7 @@ async def test_unrelated_domain_not_blocked( _, cookie = create_account_and_login profile_id = self._create_profile(cookie) + # NOTE: negative assertion — cannot poll; may read pre-mutation state (see DNSLib.wait_until docstring) resp = await self.dns_lib.send_doh_request(profile_id, "facebook.com", A) assert resp.answer, "Expected an answer for unrelated domain facebook.com" ip_addr = resp.answer[0].to_text().split(" ")[-1] @@ -266,7 +267,7 @@ async def test_multiple_subdomain_levels_blocked( _, cookie = create_account_and_login profile_id = self._create_profile(cookie) - resp = await self.dns_lib.send_doh_request(profile_id, subdomain, A) + resp = await self.dns_lib.wait_until(profile_id, subdomain, A, is_blocked) assert _is_blocked( resp ), f"Subdomain {subdomain} was not blocked (expected 0.0.0.0)" diff --git a/tests/libs/dns_lib.py b/tests/libs/dns_lib.py index 3ec85ce1..dd379a33 100644 --- a/tests/libs/dns_lib.py +++ b/tests/libs/dns_lib.py @@ -1,10 +1,41 @@ +import asyncio import time +from typing import Callable, Optional import httpx from dns import resolver, message from dns.query import https as query_https from dns.message import Message, ShortHeader +# Sentinel answers the proxy returns for blocked domains. +BLOCKED_IPV4 = "0.0.0.0" +BLOCKED_IPV6 = "::" +BLOCKED_IPS = (BLOCKED_IPV4, BLOCKED_IPV6) + + +def first_answer_ip(resp: Message) -> Optional[str]: + """First IP string from the answer section, or None if there is no answer.""" + if not resp.answer: + return None + return resp.answer[0].to_text().split(" ")[-1] + + +def is_blocked(resp: Message) -> bool: + """The answer is one of the proxy's block sentinels.""" + return first_answer_ip(resp) in BLOCKED_IPS + + +def is_resolved(resp: Message) -> bool: + """There is an answer and it is not a block sentinel.""" + ip = first_answer_ip(resp) + return ip is not None and ip not in BLOCKED_IPS + + +def answer_ip_is(expected: str) -> Callable[[Message], bool]: + """Predicate factory: the first answer IP equals ``expected``.""" + return lambda resp: first_answer_ip(resp) == expected + + class DNSLib: def __init__(self, server: str): self.server = server @@ -34,5 +65,39 @@ async def send_doh_request_with_retry( except (ShortHeader, httpx.ConnectError, httpx.ReadError, OSError) as e: last_err = e if attempt < retries - 1: - time.sleep(delay) + await asyncio.sleep(delay) raise last_err + + async def wait_until( + self, profile_id: str, domain: str, record_type: str, + predicate: Callable[[Message], bool], + *, timeout: float = 10.0, interval: float = 0.25, + ) -> Message: + """Poll a DoH query until ``predicate(resp)`` is truthy or ``timeout`` expires. + + Returns the last response either way — callers keep their normal + assertions after the wait, so a timeout surfaces as the usual assertion + failure carrying the real (stale) answer. + + Why this exists: the API writes profile settings to the Redis master + while the proxy reads the replica, so a profile/rule/blocklist mutation + is not visible to DNS resolution until replication catches up. Route the + first query after any mutation through this helper. + + Only poll for POSITIVE conditions. A negative assertion ("must NOT be + blocked") polled this way passes instantly on a stale read that predates + the mutation ever applying — instead, first wait for a companion + positive effect of the same mutation to propagate, then assert the + negative with a plain query. + """ + deadline = time.monotonic() + timeout + while True: + resp = await self.send_doh_request(profile_id, domain, record_type) + try: + if predicate(resp): + return resp + except Exception: + pass # e.g. malformed/partial answer while state is still stale + if time.monotonic() >= deadline: + return resp + await asyncio.sleep(interval) diff --git a/tests/libs/profile_helpers.py b/tests/libs/profile_helpers.py index 0c5c5ba4..dffb90fc 100644 --- a/tests/libs/profile_helpers.py +++ b/tests/libs/profile_helpers.py @@ -2,7 +2,7 @@ import uuid -from libs.dns_lib import DNSLib +from libs.dns_lib import DNSLib, is_blocked from dns.rdatatype import A import moddns.api_client as client @@ -132,7 +132,7 @@ async def _services_available_probe(dns_lib, profiles_api): id=probe_id, service_ids=svc_body ) - dns_resp = await dns_lib.send_doh_request(probe_id, SVC_GOOGLE_DOMAIN, A) + dns_resp = await dns_lib.wait_until(probe_id, SVC_GOOGLE_DOMAIN, A, is_blocked) ip_str = extract_ip(dns_resp) return ip_str == "0.0.0.0" except Exception: From c1ac19da7f41c6530120d691da948769f24b5ad1 Mon Sep 17 00:00:00 2001 From: Maciek Date: Mon, 20 Jul 2026 12:36:55 +0200 Subject: [PATCH 03/67] chore(tests): rename integration-test mentions to backend E2E tests Signed-off-by: Maciek --- .github/workflows/integration_tests.yml | 4 ++-- Makefile | 2 +- README-dev.md | 2 +- README.md | 2 +- certs/README.md | 6 +++--- tests/bootstrap/geolite/README.md | 2 +- tests/bootstrap/mock-preauth/server.py | 2 +- tests/bootstrap/services/catalog.yml | 2 +- tests/dns_tests/infra/test_redis_failover.py | 2 +- tests/dns_tests/test_connection_status.py | 4 ++-- tests/dns_tests/test_cross_phase_filtering.py | 2 +- tests/dns_tests/test_custom_rules_precedence.py | 2 +- tests/dns_tests/test_profile_export_import_contract.py | 2 +- tests/dns_tests/test_signup_reset.py | 2 +- tests/docs/REDIS_SETUP.md | 2 +- tests/libs/export_import_helpers.py | 2 +- tests/libs/profile_helpers.py | 2 +- 17 files changed, 21 insertions(+), 21 deletions(-) diff --git a/.github/workflows/integration_tests.yml b/.github/workflows/integration_tests.yml index ee638e1a..64e2e699 100644 --- a/.github/workflows/integration_tests.yml +++ b/.github/workflows/integration_tests.yml @@ -1,4 +1,4 @@ -name: Smoke / integration tests +name: Smoke / backend E2E tests on: push: @@ -64,5 +64,5 @@ jobs: fi done < tests/.env - - name: Run integration tests + - name: Run backend E2E tests run: cd tests/; make test_ci diff --git a/Makefile b/Makefile index 5c81a3f1..42ad41d3 100644 --- a/Makefile +++ b/Makefile @@ -137,7 +137,7 @@ gen_ts_client: ## Generates the typescript client from swagger spec. rm -rf app/src/api/client/ || true docker run -v ${CWD}:/app -w /app/api/docs --user $$(id -u):$$(id -g) --rm openapitools/openapi-generator-cli generate --package-name idns -i swagger.yaml -g typescript-axios -o /app/app/src/api/client --skip-validate-spec -build_tests_image: ## Builds the smoke / integration tests image. +build_tests_image: ## Builds the smoke / backend E2E tests image. docker build -f tests/Dockerfile -t dns_tests:latest . dev_tests: ## Starts the development tests docker container. diff --git a/README-dev.md b/README-dev.md index 05cfd28e..9dfd2822 100644 --- a/README-dev.md +++ b/README-dev.md @@ -41,7 +41,7 @@ mkcert automatically installs its root CA into the system trust store, so browse > [!NOTE] > The certificates committed under `certs/` (`moddns.dev+4.pem` / `moddns.dev+4-key.pem`, signed by -> `moddns_dev_development_CA.crt`) are what the integration tests use. mkcert is only needed if you want a +> `moddns_dev_development_CA.crt`) are what the backend E2E tests use. mkcert is only needed if you want a > CA your **browser** trusts automatically for local dev. See `certs/README.md` for the regeneration recipe. ## Local DNS overrides with dnsmasq diff --git a/README.md b/README.md index 10f78c3b..fefb7e01 100644 --- a/README.md +++ b/README.md @@ -149,7 +149,7 @@ make test ``` (See `proxy/Makefile` for additional targets like `make lint`, `make dev`, etc.) -### Integration tests (`tests/`) +### Backend E2E tests (`tests/`) ```bash python -m venv tests/venv source tests/venv/bin/activate diff --git a/certs/README.md b/certs/README.md index d9266577..989736fa 100644 --- a/certs/README.md +++ b/certs/README.md @@ -3,9 +3,9 @@ This directory contains certificates necessary for local development and testing. -1. `private_key.pem` and `certificate.pem` are used in API unit tests and integration tests (mobileconfig generation). -2. `moddns.dev+4.pem` and `moddns.dev+4-key.pem` are the TLS server cert/key (SANs: `moddns.dev`, `*.moddns.dev`, `localhost`, `127.0.0.1`, `::1`) used for local development and in integration tests. The proxy serves them for DoH/DoT/DoQ on `moddns.dev`. -3. `moddns_dev_development_CA.crt` is the root CA that signed the cert above. It is trusted by the integration test client (both locally via `tests/Dockerfile` and in the GitHub workflow) so `https://moddns.dev` validates. +1. `private_key.pem` and `certificate.pem` are used in API unit tests and backend E2E tests (mobileconfig generation). +2. `moddns.dev+4.pem` and `moddns.dev+4-key.pem` are the TLS server cert/key (SANs: `moddns.dev`, `*.moddns.dev`, `localhost`, `127.0.0.1`, `::1`) used for local development and in backend E2E tests. The proxy serves them for DoH/DoT/DoQ on `moddns.dev`. +3. `moddns_dev_development_CA.crt` is the root CA that signed the cert above. It is trusted by the backend E2E test client (both locally via `tests/Dockerfile` and in the GitHub workflow) so `https://moddns.dev` validates. #### Regenerating on expiry diff --git a/tests/bootstrap/geolite/README.md b/tests/bootstrap/geolite/README.md index 5f1f6ba0..9b93c2f0 100644 --- a/tests/bootstrap/geolite/README.md +++ b/tests/bootstrap/geolite/README.md @@ -1,4 +1,4 @@ -These are stub .mmdb files for integration tests, NOT full GeoLite2 databases. +These are stub .mmdb files for backend E2E tests, NOT full GeoLite2 databases. They contain only two entries (AS15169 Google, AS13335 Cloudflare). City lookups return empty records but won't crash. diff --git a/tests/bootstrap/mock-preauth/server.py b/tests/bootstrap/mock-preauth/server.py index d260b0ed..0e4d8d84 100644 --- a/tests/bootstrap/mock-preauth/server.py +++ b/tests/bootstrap/mock-preauth/server.py @@ -1,4 +1,4 @@ -"""Minimal mock preauth server for integration tests. +"""Minimal mock preauth server for backend E2E tests. Stores preauth entries in memory. The test creates entries via POST /entry, and the API service fetches them via GET /. diff --git a/tests/bootstrap/services/catalog.yml b/tests/bootstrap/services/catalog.yml index 31d2c145..809a6686 100644 --- a/tests/bootstrap/services/catalog.yml +++ b/tests/bootstrap/services/catalog.yml @@ -1,4 +1,4 @@ -# Services catalog for integration tests. +# Services catalog for backend E2E tests. # Copied from bootstrap/services/catalog.yml — keep in sync. # # Test infrastructure: diff --git a/tests/dns_tests/infra/test_redis_failover.py b/tests/dns_tests/infra/test_redis_failover.py index ca966610..9b244b04 100644 --- a/tests/dns_tests/infra/test_redis_failover.py +++ b/tests/dns_tests/infra/test_redis_failover.py @@ -1,5 +1,5 @@ """ -Redis Read-Replica Failover Integration Test +Redis Read-Replica Failover Backend E2E Test Verifies that the proxy falls back to the Redis master (via sentinel) when its co-located read replica becomes unavailable, and switches back when the diff --git a/tests/dns_tests/test_connection_status.py b/tests/dns_tests/test_connection_status.py index e025e81f..e6db177f 100644 --- a/tests/dns_tests/test_connection_status.py +++ b/tests/dns_tests/test_connection_status.py @@ -1,5 +1,5 @@ """ -Integration tests for DNS Connection Status Check feature. +Backend E2E tests for DNS Connection Status Check feature. This test suite validates the complete flow of the DNS connection check feature: 1. DNS query to dnscheck authoritative server @@ -28,7 +28,7 @@ @pytest.mark.skip(reason="I did not manage to fully setup the test environment") class TestDnsConnectionStatus: - """Integration tests for DNS connection status check feature.""" + """Backend E2E tests for DNS connection status check feature.""" def setup_class(self): """Setup the test class.""" diff --git a/tests/dns_tests/test_cross_phase_filtering.py b/tests/dns_tests/test_cross_phase_filtering.py index 9fb1b5f6..9cb6fa6a 100644 --- a/tests/dns_tests/test_cross_phase_filtering.py +++ b/tests/dns_tests/test_cross_phase_filtering.py @@ -1,4 +1,4 @@ -"""Cross-phase DNS filtering integration tests. +"""Cross-phase DNS filtering backend E2E tests. Tests interactions between domain-phase (pre-resolve) and IP-phase (post-resolve) filters, covering scenarios from the behaviour table diff --git a/tests/dns_tests/test_custom_rules_precedence.py b/tests/dns_tests/test_custom_rules_precedence.py index 97088962..19978466 100644 --- a/tests/dns_tests/test_custom_rules_precedence.py +++ b/tests/dns_tests/test_custom_rules_precedence.py @@ -21,7 +21,7 @@ class TestCustomRulesPrecedence: """ - End-to-end integration tests verifying that custom rules take precedence + Backend E2E tests verifying that custom rules take precedence over blocklist blocking and default_rule settings. The DNS proxy evaluates filtering tiers in priority order: diff --git a/tests/dns_tests/test_profile_export_import_contract.py b/tests/dns_tests/test_profile_export_import_contract.py index d170a19e..52cd7054 100644 --- a/tests/dns_tests/test_profile_export_import_contract.py +++ b/tests/dns_tests/test_profile_export_import_contract.py @@ -1,4 +1,4 @@ -"""HTTP-contract integration tests for profile export/import endpoints. +"""HTTP-contract backend E2E tests for profile export/import endpoints. Covers Sections E, I, V, M, S of docs/specs/account-export-import-behaviour.md. These tests assert only HTTP-level behaviour (status codes, headers, response diff --git a/tests/dns_tests/test_signup_reset.py b/tests/dns_tests/test_signup_reset.py index f010a78f..6258c35f 100644 --- a/tests/dns_tests/test_signup_reset.py +++ b/tests/dns_tests/test_signup_reset.py @@ -1,4 +1,4 @@ -"""End-to-end integration tests for the signup-reset (account retirement) flow. +"""Backend E2E tests for the signup-reset (account retirement) flow. specRef: docs/specs/signup-reset-behaviour.md (RT3, RT5-RT8, R-E9, and the no-false-positive invariant) diff --git a/tests/docs/REDIS_SETUP.md b/tests/docs/REDIS_SETUP.md index 8301559f..bb4e1013 100644 --- a/tests/docs/REDIS_SETUP.md +++ b/tests/docs/REDIS_SETUP.md @@ -1,6 +1,6 @@ # Redis Sentinel Test Topology (Multi-User ACL) -Current integration test topology provides a minimal high-availability Redis deployment with explicit ACL users for clearer separation of application vs. replication/failover concerns. +Current backend E2E test topology provides a minimal high-availability Redis deployment with explicit ACL users for clearer separation of application vs. replication/failover concerns. ## Services - `cache`: Primary Redis (master) on port 6379 (`tests/redis/master.conf`) diff --git a/tests/libs/export_import_helpers.py b/tests/libs/export_import_helpers.py index 8baa97b9..00d4129c 100644 --- a/tests/libs/export_import_helpers.py +++ b/tests/libs/export_import_helpers.py @@ -1,4 +1,4 @@ -"""Helpers for profile export/import integration tests. +"""Helpers for profile export/import backend E2E tests. The generated Python API client uses strict pydantic models that reject many of the invalid inputs we need to test (unknown scope values, schemaVersion=2, diff --git a/tests/libs/profile_helpers.py b/tests/libs/profile_helpers.py index dffb90fc..62213130 100644 --- a/tests/libs/profile_helpers.py +++ b/tests/libs/profile_helpers.py @@ -1,4 +1,4 @@ -"""Shared helpers for integration tests that manage profiles, custom rules, services, and blocklists.""" +"""Shared helpers for backend E2E tests that manage profiles, custom rules, services, and blocklists.""" import uuid From be2c0c5ab5832b1941be5c7aa198807dd6ea2932 Mon Sep 17 00:00:00 2001 From: Maciek Date: Mon, 20 Jul 2026 13:17:08 +0200 Subject: [PATCH 04/67] test(e2e): add stack readiness gate, account teardown, state isolation, and centralized test settings Signed-off-by: Maciek --- certs/README.md | 1 - tests/conftest.py | 98 ++++++++++++++----- tests/dns_tests/infra/test_redis_failover.py | 2 +- tests/dns_tests/test_basic.py | 4 + tests/dns_tests/test_blocklists.py | 15 ++- tests/dns_tests/test_custom_rules.py | 17 +++- .../dns_tests/test_custom_rules_precedence.py | 4 +- tests/dns_tests/test_dnssec.py | 2 +- tests/dns_tests/test_ip_custom_rules.py | 21 ++-- tests/dns_tests/test_services.py | 8 ++ tests/dns_tests/test_subdomain_blocking.py | 4 +- tests/libs/dns_lib.py | 11 ++- tests/libs/settings.py | 11 ++- 13 files changed, 148 insertions(+), 50 deletions(-) diff --git a/certs/README.md b/certs/README.md index 989736fa..8984bd68 100644 --- a/certs/README.md +++ b/certs/README.md @@ -2,7 +2,6 @@ This directory contains certificates necessary for local development and testing. - 1. `private_key.pem` and `certificate.pem` are used in API unit tests and backend E2E tests (mobileconfig generation). 2. `moddns.dev+4.pem` and `moddns.dev+4-key.pem` are the TLS server cert/key (SANs: `moddns.dev`, `*.moddns.dev`, `localhost`, `127.0.0.1`, `::1`) used for local development and in backend E2E tests. The proxy serves them for DoH/DoT/DoQ on `moddns.dev`. 3. `moddns_dev_development_CA.crt` is the root CA that signed the cert above. It is trusted by the backend E2E test client (both locally via `tests/Dockerfile` and in the GitHub workflow) so `https://moddns.dev` validates. diff --git a/tests/conftest.py b/tests/conftest.py index 6f73b2e6..1057dcdf 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,4 @@ +import asyncio import os import pytest from datetime import datetime @@ -7,9 +8,10 @@ import string import uuid from datetime import timedelta, timezone -import os as _os import redis +from dns.rdatatype import A + from retry import retry from testcontainers.compose import DockerCompose @@ -26,7 +28,11 @@ from moddns.models.requests_rotate_pa_session_req import RequestsRotatePASessionReq from helpers import generate_complex_password +from libs.dns_lib import DNSLib, is_resolved from libs.settings import get_settings +from moddns.models.requests_account_deletion_request import ( + RequestsAccountDeletionRequest, +) # Shared deterministic blocklist test constants TEST_BLOCKLIST_ID = "hagezi_threat_intelligence_feeds_full" @@ -41,7 +47,8 @@ def ensure_test_blocklisted(): """Insert a deterministic test domain into the target blocklist for the duration of a test. The subdomain is intentionally not added; proxy logic should still block it when subdomain rule applies. """ - r = redis.Redis(host="localhost", port=6379, db=0) + cfg = get_settings() + r = redis.Redis(host=cfg.REDIS_HOST, port=cfg.REDIS_PORT, db=0) key = f"blocklist:{TEST_BLOCKLIST_ID}" r.sadd(key, TEST_DOMAIN) try: @@ -58,7 +65,8 @@ def ensure_domain_blocklisted(): then request this fixture. The domain is removed on teardown. """ _inserted = [] - r = redis.Redis(host="localhost", port=6379, db=0) + cfg = get_settings() + r = redis.Redis(host=cfg.REDIS_HOST, port=cfg.REDIS_PORT, db=0) key = f"blocklist:{TEST_BLOCKLIST_ID}" def _insert(domain: str): @@ -76,27 +84,37 @@ def _insert(domain: str): def create_account_and_login(): """ Pytest fixture to create a new account, log in, and return the account object with session cookie. - Cleans up by deleting the account after the test class is completed. + Cleans up by deleting the account (and all its profiles) after the test class is completed. """ - account, cookie = create_acc_and_login_func() + account, cookie, password = create_acc_and_login_func() yield account, cookie + delete_account(cookie, password, account_id=account.id) + + +def delete_account(cookie: str, password: str, *, account_id: str = "?") -> None: + """Best-effort account deletion via the deletion-code + password-reauth flow. - # TODO: Cleanup: delete the account after the test - # this has to be done together with scope change - # try: - # config = get_settings() - # api_conf = api_config.Configuration(host=config.DNS_API_ADDR) - # with client.ApiClient(api_conf) as api_client: - # account_api = api.AccountApi(api_client) - # account_api.api_client.default_headers["Cookie"] = cookie - # TODO: get deletion code before - # resp = account_api.api_v1_accounts_current_delete_with_http_info() - # assert ( - # resp.status_code == 204 - # ), f"Account deletion failed with status code: {resp.status_code}" - # except Exception as e: - # # Log the error but don't fail the test due to cleanup issues - # print(f"Warning: Failed to delete test account {account.id}: {str(e)}") + Deleting the account removes all its profiles and cached state, so test + runs don't accumulate data in Mongo/Redis. Failures are logged, not raised — + cleanup problems must not fail an otherwise green test. + """ + try: + config = get_settings() + api_conf = api_config.Configuration(host=config.DNS_API_ADDR) + with client.ApiClient(api_conf) as api_client: + account_api = api.AccountApi(api_client) + account_api.api_client.default_headers["Cookie"] = cookie + code_resp = account_api.api_v1_accounts_current_deletion_code_post() + resp = account_api.api_v1_accounts_current_delete_with_http_info( + body=RequestsAccountDeletionRequest( + deletion_code=code_resp.code, current_password=password + ) + ) + assert resp.status_code in (200, 204), ( + f"Account deletion failed with status code: {resp.status_code}" + ) + except Exception as e: + print(f"Warning: Failed to delete test account {account_id}: {e}") def create_temp_subscription(validity_days: int = 30) -> tuple[str, str]: @@ -125,7 +143,7 @@ def create_temp_subscription(validity_days: int = 30) -> tuple[str, str]: token_hash = base64.b64encode(hashlib.sha256(token.encode()).digest()).decode() # 1. Create preauth entry in mock preauth service - mock_preauth_url = _os.getenv("MOCK_PREAUTH_URL", "http://localhost:8080") + mock_preauth_url = config.MOCK_PREAUTH_URL http_requests.post( f"{mock_preauth_url}/entry", json={ @@ -169,12 +187,15 @@ def create_temp_subscription(validity_days: int = 30) -> tuple[str, str]: def create_acc_and_login_func(): - """Create a new account, log in, fetch current account and return (account, cookie). + """Create a new account, log in, fetch current account and return (account, cookie, password). Flow: 1. create temp subscription cache key 2. register account (201 expected) 3. login to obtain session cookie 4. GET /accounts/current to retrieve full account object + + The plaintext password is returned so callers can perform reauth flows + (e.g. account deletion in fixture teardown). """ config = get_settings() api_conf = api_config.Configuration(host=config.DNS_API_ADDR) @@ -214,17 +235,21 @@ def create_acc_and_login_func(): account_api.api_client.default_headers["Cookie"] = cookie account = account_api.api_v1_accounts_current_get() assert len(account.profiles) == 1 - return account, cookie + return account, cookie, password @pytest.fixture(scope="session", autouse=True) -def ensure_blocklists_configured(): +def ensure_blocklists_configured(start_compose): """ - Autouse fixture that runs once per test session to ensure blocklists are configured. + Autouse fixture that runs once per test session to ensure blocklists are + configured and the DNS stack is ready to serve queries. Fails the test run early if no blocklists are found. Uses retry with exponential backoff to handle temporary unavailability. + + Depends on ``start_compose`` explicitly so the containers are guaranteed + to be up before the first API call, regardless of autouse ordering. """ - acc, cookie = create_acc_and_login_func() + acc, cookie, password = create_acc_and_login_func() config = get_settings() api_conf = api_config.Configuration(host=config.DNS_API_ADDR) @@ -253,6 +278,25 @@ def check_blocklists(): check_blocklists() + # DNS-stack readiness gate. The proxy image is FROM scratch (no shell), so + # it cannot declare a compose healthcheck and testcontainers' wait=True + # only gates the API. One successfully resolved query through the full + # chain (proxy TLS → replica Redis profile lookup → recursor, using the + # testhosts-pinned test.com) proves the stack is ready before any test runs. + dns_lib = DNSLib(config.DOH_ENDPOINT) + resp = asyncio.run( + dns_lib.wait_until( + acc.profiles[0], "test.com", A, is_resolved, timeout=60.0, interval=1.0 + ) + ) + assert is_resolved(resp), ( + "DNS stack not ready: proxy did not resolve pinned domain test.com within 60s" + ) + + yield + + delete_account(cookie, password, account_id=acc.id) + @pytest.fixture(scope="session") # autouse=True def start_compose(): diff --git a/tests/dns_tests/infra/test_redis_failover.py b/tests/dns_tests/infra/test_redis_failover.py index 9b244b04..cc05dbe5 100644 --- a/tests/dns_tests/infra/test_redis_failover.py +++ b/tests/dns_tests/infra/test_redis_failover.py @@ -40,7 +40,7 @@ def setup_class(self): self.dns_lib = DNSLib(self.config.DOH_ENDPOINT) self.docker_client = docker.from_env() # Create a test account once for the whole class. - account, _ = create_acc_and_login_func() + account, _, _ = create_acc_and_login_func() assert len(account.profiles) == 1 self.profile_id = account.profiles[0] diff --git a/tests/dns_tests/test_basic.py b/tests/dns_tests/test_basic.py index 743f8ce3..3714f6e9 100644 --- a/tests/dns_tests/test_basic.py +++ b/tests/dns_tests/test_basic.py @@ -35,6 +35,10 @@ async def test_profile_id_not_provided_or_non_existing(self, profile_id: str): await self.dns_lib.send_doh_request(profile_id, "example.com", "A") @pytest.mark.asyncio + @pytest.mark.xfail( + strict=False, + reason="depends on live external DNS (facebook.com via real recursion)", + ) async def test_regular_account(self): """ Create account and use its profile_id to resolve some DNS request. diff --git a/tests/dns_tests/test_blocklists.py b/tests/dns_tests/test_blocklists.py index 8e159628..5f852847 100644 --- a/tests/dns_tests/test_blocklists.py +++ b/tests/dns_tests/test_blocklists.py @@ -30,7 +30,9 @@ def setup_class(self): self.config = get_settings() self.api_config = api_config.Configuration(host=self.config.DNS_API_ADDR) self.dns_lib = DNSLib(self.config.DOH_ENDPOINT) - self.redis_client = redis.Redis(host="localhost", port=6379, db=0) + self.redis_client = redis.Redis( + host=self.config.REDIS_HOST, port=self.config.REDIS_PORT, db=0 + ) def test_threat_intelligence_feeds_blocklist( self, create_account_and_login, ensure_test_blocklisted @@ -120,7 +122,16 @@ async def test_blocklist_disable_unblocks_domain( account, cookie = create_account_and_login with client.ApiClient(self.api_config) as api_client: profiles_instance = api.ProfileApi(api_client) - profile_id = account.profiles[0] + # Fresh profile: this test disables the blocklist and must not + # mutate the shared class profile other tests assert against. + profiles_instance.api_client.default_headers["Cookie"] = cookie + create_resp = profiles_instance.api_v1_profiles_post_with_http_info( + body=ApiCreateProfileBody(name="bl_disable_test") + ) + assert ( + create_resp.status_code == 201 + ), f"Failed to create profile with status code: {create_resp.status_code}" + profile_id = create_resp.data.profile_id resp = await self.dns_lib.wait_until(profile_id, TEST_DOMAIN, A, is_blocked) ip_addr = resp.answer[0].to_text().split(" ")[-1] diff --git a/tests/dns_tests/test_custom_rules.py b/tests/dns_tests/test_custom_rules.py index 001fb44d..1efd1f20 100644 --- a/tests/dns_tests/test_custom_rules.py +++ b/tests/dns_tests/test_custom_rules.py @@ -1,3 +1,4 @@ +import uuid from ipaddress import ip_address, IPv6Address import pytest @@ -10,7 +11,7 @@ import moddns.api_client as client import moddns.api as api import moddns.configuration as api_config -from moddns import RequestsCreateProfileCustomRuleBody +from moddns import ApiCreateProfileBody, RequestsCreateProfileCustomRuleBody class TestCustomRules: @@ -110,12 +111,22 @@ async def test_blocking_custom_rule_answer( account, cookie = create_account_and_login with client.ApiClient(self.api_config) as api_client: profiles_instance = api.ProfileApi(api_client) + profiles_instance.api_client.default_headers["Cookie"] = cookie + + # Fresh profile per parametrization: rules must not accumulate on + # the shared class profile across the 13 params. Name must be + # unique — the API rejects duplicate profile names per account. + create_resp = profiles_instance.api_v1_profiles_post_with_http_info( + body=ApiCreateProfileBody(name=f"custom_rule_{uuid.uuid4().hex[:8]}") + ) + assert ( + create_resp.status_code == 201 + ), f"Failed to create profile with status code: {create_resp.status_code}" + profile_id = create_resp.data.profile_id - profile_id = account.profiles[0] custom_rule_body = RequestsCreateProfileCustomRuleBody( action="block", value=test_domain ) - profiles_instance.api_client.default_headers["Cookie"] = cookie ur_resp = ( profiles_instance.api_v1_profiles_id_custom_rules_post_with_http_info( id=profile_id, body=custom_rule_body diff --git a/tests/dns_tests/test_custom_rules_precedence.py b/tests/dns_tests/test_custom_rules_precedence.py index 19978466..dac11a4b 100644 --- a/tests/dns_tests/test_custom_rules_precedence.py +++ b/tests/dns_tests/test_custom_rules_precedence.py @@ -35,7 +35,9 @@ def setup_class(self): self.config = get_settings() self.api_config = api_config.Configuration(host=self.config.DNS_API_ADDR) self.dns_lib = DNSLib(self.config.DOH_ENDPOINT) - self.redis_client = redis.Redis(host="localhost", port=6379, db=0) + self.redis_client = redis.Redis( + host=self.config.REDIS_HOST, port=self.config.REDIS_PORT, db=0 + ) def _create_profile(self, profiles_instance, name): """Helper to create a new profile and return its ID.""" diff --git a/tests/dns_tests/test_dnssec.py b/tests/dns_tests/test_dnssec.py index 08dff276..922c6d89 100644 --- a/tests/dns_tests/test_dnssec.py +++ b/tests/dns_tests/test_dnssec.py @@ -133,7 +133,7 @@ async def test_answer_no_dnssec(self, test_domain, expected_results): """ Create account, disable DNSSEC validation, send query to DNSSEC-configured domain and make sure the DNS response does not contain DNSSEC validation results (DO bit is not sent). """ - account, cookie = create_acc_and_login_func() + account, cookie, _ = create_acc_and_login_func() profile_id = account.profiles[0] with client.ApiClient(self.api_config) as api_client: diff --git a/tests/dns_tests/test_ip_custom_rules.py b/tests/dns_tests/test_ip_custom_rules.py index 422d59f8..4994ebc0 100644 --- a/tests/dns_tests/test_ip_custom_rules.py +++ b/tests/dns_tests/test_ip_custom_rules.py @@ -34,6 +34,9 @@ TEST_IPV6_DOMAIN = "ipv6-test.com" # RFC 5737 TEST-NET address — guaranteed to not appear in any real DNS response. NONEXISTENT_IPV4 = "192.0.2.1" +# Pinned to 8.8.8.8 in config/testhosts.txt — resolves deterministically and +# shares no IP with TEST_IPV4_DOMAIN, so "unrelated domain" tests need no live DNS. +UNRELATED_PINNED_DOMAIN = "svctest-google.com" class TestIPCustomRules: @@ -135,13 +138,13 @@ async def test_block_nonmatching_ip_does_not_block( # NOTE: negative assertion — cannot poll; may read pre-mutation state (see DNSLib.wait_until docstring) resp = await self.dns_lib.send_doh_request( - profile_id, "google.com", A + profile_id, TEST_IPV4_DOMAIN, A ) - assert resp.answer, "Expected an answer for google.com" + assert resp.answer, f"Expected an answer for {TEST_IPV4_DOMAIN}" ip_addr = resp.answer[0].to_text().split(" ")[-1] assert ip_addr != "0.0.0.0", ( f"Non-matching IP block rule for {NONEXISTENT_IPV4} should not " - f"block google.com; got {ip_addr}" + f"block {TEST_IPV4_DOMAIN}; got {ip_addr}" ) assert ip_address(ip_addr), f"Expected a valid IP, got {ip_addr}" @@ -153,8 +156,8 @@ async def test_block_nonmatching_ip_does_not_block( async def test_ip_block_does_not_affect_unrelated_domain( self, create_account_and_login ): - """Blocking an IP that test.com resolves to must not block google.com - (which resolves to a different IP).""" + """Blocking an IP that test.com resolves to must not block an + unrelated pinned domain that resolves to a different IP.""" account, cookie = create_account_and_login with client.ApiClient(self.api_config) as api_client: p = api.ProfileApi(api_client) @@ -165,13 +168,13 @@ async def test_ip_block_does_not_affect_unrelated_domain( # NOTE: negative assertion — cannot poll; may read pre-mutation state (see DNSLib.wait_until docstring) resp = await self.dns_lib.send_doh_request( - profile_id, "google.com", A + profile_id, UNRELATED_PINNED_DOMAIN, A ) - assert resp.answer, "Expected an answer for google.com" + assert resp.answer, f"Expected an answer for {UNRELATED_PINNED_DOMAIN}" ip_addr = resp.answer[0].to_text().split(" ")[-1] assert ip_addr != "0.0.0.0", ( - f"IP block rule for {TEST_IPV4} should not block google.com; " - f"got {ip_addr}" + f"IP block rule for {TEST_IPV4} should not block " + f"{UNRELATED_PINNED_DOMAIN}; got {ip_addr}" ) # ------------------------------------------------------------------ diff --git a/tests/dns_tests/test_services.py b/tests/dns_tests/test_services.py index 1756787d..5224f45e 100644 --- a/tests/dns_tests/test_services.py +++ b/tests/dns_tests/test_services.py @@ -447,6 +447,10 @@ def setup_class(self): self.dns_lib = DNSLib(self.config.DOH_ENDPOINT) @pytest.mark.asyncio + @pytest.mark.xfail( + strict=False, + reason="depends on live external DNS (google.com HTTPS records)", + ) async def test_services_block_https_query_no_ip_hints( self, create_account_and_login ): @@ -485,6 +489,10 @@ async def test_services_block_https_query_no_ip_hints( ) @pytest.mark.asyncio + @pytest.mark.xfail( + strict=False, + reason="depends on live external DNS (google.com HTTPS records)", + ) async def test_services_no_block_real_domain_https_query( self, create_account_and_login ): diff --git a/tests/dns_tests/test_subdomain_blocking.py b/tests/dns_tests/test_subdomain_blocking.py index a23af272..a8f6b5cd 100644 --- a/tests/dns_tests/test_subdomain_blocking.py +++ b/tests/dns_tests/test_subdomain_blocking.py @@ -56,7 +56,9 @@ def setup_class(self): self.config = get_settings() self.api_config = api_config.Configuration(host=self.config.DNS_API_ADDR) self.dns_lib = DNSLib(self.config.DOH_ENDPOINT) - self.redis_client = redis.Redis(host="localhost", port=6379, db=0) + self.redis_client = redis.Redis( + host=self.config.REDIS_HOST, port=self.config.REDIS_PORT, db=0 + ) # ------------------------------------------------------------------ # Helpers diff --git a/tests/libs/dns_lib.py b/tests/libs/dns_lib.py index dd379a33..bd1d1670 100644 --- a/tests/libs/dns_lib.py +++ b/tests/libs/dns_lib.py @@ -92,7 +92,16 @@ async def wait_until( """ deadline = time.monotonic() + timeout while True: - resp = await self.send_doh_request(profile_id, domain, record_type) + try: + resp = await self.send_doh_request(profile_id, domain, record_type) + except (ShortHeader, httpx.ConnectError, httpx.ReadError, OSError): + # The proxy drops connections for unknown profiles, so a freshly + # created profile can cause ShortHeader until it propagates to + # the replica. Treat as "not ready yet"; re-raise on deadline. + if time.monotonic() >= deadline: + raise + await asyncio.sleep(interval) + continue try: if predicate(resp): return resp diff --git a/tests/libs/settings.py b/tests/libs/settings.py index 5a6231b5..6f3c5a42 100644 --- a/tests/libs/settings.py +++ b/tests/libs/settings.py @@ -1,13 +1,18 @@ -import pytest - from functools import lru_cache from pydantic_settings import BaseSettings class Settings(BaseSettings): - """Config class holds the configuration for the tests.""" + """Config class holds the configuration for the tests. + + Every field is overridable via an identically-named environment variable. + Defaults match the port mappings in ``tests/docker-compose.yml``. + """ DNS_API_ADDR: str = "http://localhost:3000" DOH_ENDPOINT: str = "https://moddns.dev/dns-query/" + REDIS_HOST: str = "localhost" + REDIS_PORT: int = 6379 + MOCK_PREAUTH_URL: str = "http://localhost:8080" @lru_cache() From 970ef3e72389ea1569a3c610b79b8987781a7831 Mon Sep 17 00:00:00 2001 From: Maciek Date: Mon, 20 Jul 2026 14:30:32 +0200 Subject: [PATCH 05/67] test(e2e): introduce ProfileSession facade, unify constants and provisioning, dedupe assertions Signed-off-by: Maciek --- tests/conftest.py | 205 +---- tests/dns_tests/infra/test_redis_failover.py | 14 +- tests/dns_tests/test_basic.py | 49 +- tests/dns_tests/test_blocklists.py | 204 ++--- tests/dns_tests/test_cross_phase_filtering.py | 282 +++---- tests/dns_tests/test_custom_rules.py | 124 +-- .../dns_tests/test_custom_rules_precedence.py | 732 +++++------------- tests/dns_tests/test_dnssec.py | 114 +-- tests/dns_tests/test_ip_custom_rules.py | 212 ++--- tests/dns_tests/test_multiple_users.py | 97 +-- .../test_profile_export_import_behaviour.py | 6 +- tests/dns_tests/test_services.py | 681 ++++++---------- tests/dns_tests/test_signup_reset.py | 87 +-- tests/dns_tests/test_subdomain_blocking.py | 215 ++--- tests/libs/accounts.py | 182 +++++ tests/libs/constants.py | 22 + tests/libs/dns_lib.py | 21 + tests/libs/export_import_helpers.py | 44 +- tests/libs/profile_helpers.py | 5 +- tests/libs/session.py | 203 +++++ 20 files changed, 1267 insertions(+), 2232 deletions(-) create mode 100644 tests/libs/accounts.py create mode 100644 tests/libs/constants.py create mode 100644 tests/libs/session.py diff --git a/tests/conftest.py b/tests/conftest.py index 1057dcdf..fd3cd4ab 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,10 +4,7 @@ from datetime import datetime from pathlib import Path import shutil -import random -import string -import uuid -from datetime import timedelta, timezone +from typing import Iterator import redis from dns.rdatatype import A @@ -15,31 +12,20 @@ from retry import retry from testcontainers.compose import DockerCompose -import hashlib -import base64 -import requests as http_requests - import moddns.api_client as client import moddns.api as api import moddns.configuration as api_config -from moddns import RequestsLoginBody -from moddns.api.pa_session_api import PASessionApi -from moddns.models.requests_pa_session_req import RequestsPASessionReq -from moddns.models.requests_rotate_pa_session_req import RequestsRotatePASessionReq -from helpers import generate_complex_password +# Re-exported so existing `from conftest import …` sites keep working. +from libs.accounts import ( # noqa: F401 + create_account, + create_temp_subscription, + delete_account, +) +from libs.constants import BLOCKLISTED_DOMAIN, TEST_BLOCKLIST_ID from libs.dns_lib import DNSLib, is_resolved +from libs.session import ProfileSession from libs.settings import get_settings -from moddns.models.requests_account_deletion_request import ( - RequestsAccountDeletionRequest, -) - -# Shared deterministic blocklist test constants -TEST_BLOCKLIST_ID = "hagezi_threat_intelligence_feeds_full" -TEST_DOMAIN = "example.com" # parent only inserted, existing domain so it's resolvable -TEST_SUBDOMAIN = ( - f"sub.{TEST_DOMAIN}" # not inserted; used to validate inherited blocking -) @pytest.fixture @@ -50,11 +36,11 @@ def ensure_test_blocklisted(): cfg = get_settings() r = redis.Redis(host=cfg.REDIS_HOST, port=cfg.REDIS_PORT, db=0) key = f"blocklist:{TEST_BLOCKLIST_ID}" - r.sadd(key, TEST_DOMAIN) + r.sadd(key, BLOCKLISTED_DOMAIN) try: yield finally: - r.srem(key, TEST_DOMAIN) + r.srem(key, BLOCKLISTED_DOMAIN) @pytest.fixture @@ -79,163 +65,46 @@ def _insert(domain: str): r.srem(key, d) -# TODO: class scope can be troublesome, investigate usage and change if necessary +@pytest.fixture(scope="class") +def user() -> Iterator[ProfileSession]: + """Class-scoped logged-in test user (ProfileSession facade). + + Tests needing isolation create per-test profiles via ``user.new_profile()`` + — the account itself is shared across the class for speed and deleted on + teardown. + """ + session = ProfileSession.create() + yield session + session.cleanup() + + +# Deprecated: migrate to the `user` fixture. Kept while old-style tests remain. @pytest.fixture(scope="class") def create_account_and_login(): """ Pytest fixture to create a new account, log in, and return the account object with session cookie. Cleans up by deleting the account (and all its profiles) after the test class is completed. """ - account, cookie, password = create_acc_and_login_func() + account, cookie, password, _ = create_account() yield account, cookie delete_account(cookie, password, account_id=account.id) -def delete_account(cookie: str, password: str, *, account_id: str = "?") -> None: - """Best-effort account deletion via the deletion-code + password-reauth flow. - - Deleting the account removes all its profiles and cached state, so test - runs don't accumulate data in Mongo/Redis. Failures are logged, not raised — - cleanup problems must not fail an otherwise green test. - """ - try: - config = get_settings() - api_conf = api_config.Configuration(host=config.DNS_API_ADDR) - with client.ApiClient(api_conf) as api_client: - account_api = api.AccountApi(api_client) - account_api.api_client.default_headers["Cookie"] = cookie - code_resp = account_api.api_v1_accounts_current_deletion_code_post() - resp = account_api.api_v1_accounts_current_delete_with_http_info( - body=RequestsAccountDeletionRequest( - deletion_code=code_resp.code, current_password=password - ) - ) - assert resp.status_code in (200, 204), ( - f"Account deletion failed with status code: {resp.status_code}" - ) - except Exception as e: - print(f"Warning: Failed to delete test account {account_id}: {e}") - - -def create_temp_subscription(validity_days: int = 30) -> tuple[str, str]: - """Provision a pre-auth session (PASession) for the ZLA signup flow. - - Flow: - 1. Generate a random token and compute its SHA256 hash - 2. Create a preauth entry in the mock preauth service - 3. Call POST /api/v1/pasession/add with PSK to cache the PASession - 4. Call PUT /api/v1/pasession/rotate to get a rotated session cookie - 5. Return (subscription_id, pa_session_cookie) - """ - config = get_settings() - - subscription_id = str(uuid.uuid4()) - session_id = str(uuid.uuid4()) - preauth_id = str(uuid.uuid4()) - token = str(uuid.uuid4()) # random token - - active_until_dt = datetime.utcnow().replace(tzinfo=timezone.utc) + timedelta( - days=validity_days - ) - active_until = active_until_dt.isoformat().replace("+00:00", "Z") - - # Compute token hash (SHA256, base64-encoded) matching what the API validates - token_hash = base64.b64encode(hashlib.sha256(token.encode()).digest()).decode() - - # 1. Create preauth entry in mock preauth service - mock_preauth_url = config.MOCK_PREAUTH_URL - http_requests.post( - f"{mock_preauth_url}/entry", - json={ - "id": preauth_id, - "token_hash": token_hash, - "is_active": True, - "active_until": active_until, - "tier": "Tier 2", - }, - ).raise_for_status() - - # 2. Add PASession via API (PSK-protected endpoint) - api_conf = api_config.Configuration(host=config.DNS_API_ADDR) - psk = "" # empty PSK works if no PSK is set in API .env - - with client.ApiClient(api_conf) as api_client: - pa_api = PASessionApi(api_client) - pa_api.api_client.default_headers["Authorization"] = f"Bearer {psk}" - body = RequestsPASessionReq(id=session_id, preauth_id=preauth_id, token=token) - resp = pa_api.api_v1_pasession_add_post(body=body) - assert ( - resp.get("message") == "pre-auth session added" - ), f"Unexpected PASession add response: {resp}" - - # 3. Rotate PASession to get cookie - with client.ApiClient(api_conf) as api_client: - pa_api = PASessionApi(api_client) - rotate_body = RequestsRotatePASessionReq(sessionid=session_id) - rotate_resp = pa_api.api_v1_pasession_rotate_put_with_http_info( - body=rotate_body - ) - assert rotate_resp.status_code == 200, ( - f"PASession rotation failed: {rotate_resp.status_code}" - ) - pa_cookie = rotate_resp.headers.get("Set-Cookie", "") - assert "pa_session=" in pa_cookie, ( - f"No pa_session cookie in rotation response: {pa_cookie}" - ) - - return subscription_id, pa_cookie +@pytest.fixture(scope="session") +def redis_client(): + """Session-scoped Redis client for fixtures/tests that seed blocklist sets.""" + cfg = get_settings() + return redis.Redis(host=cfg.REDIS_HOST, port=cfg.REDIS_PORT, db=0) def create_acc_and_login_func(): - """Create a new account, log in, fetch current account and return (account, cookie, password). - Flow: - 1. create temp subscription cache key - 2. register account (201 expected) - 3. login to obtain session cookie - 4. GET /accounts/current to retrieve full account object - - The plaintext password is returned so callers can perform reauth flows - (e.g. account deletion in fixture teardown). - """ - config = get_settings() - api_conf = api_config.Configuration(host=config.DNS_API_ADDR) - with client.ApiClient(api_conf) as api_client: - account_api = api.AccountApi(api_client) - auth_api = api.AuthenticationApi(api_client) - - # Create a new account with a random email - email = ( - f"test{''.join(random.choice(string.digits) for _ in range(5))}@ivpn.net" - ) - password = generate_complex_password() + """Deprecated wrapper over libs.accounts.create_account. - # Prepare PASession for ZLA signup flow - subscription_id, pa_cookie = create_temp_subscription() - - # Set pa_session cookie for registration - account_api.api_client.default_headers["Cookie"] = pa_cookie - reg_resp = account_api.api_v1_accounts_post_with_http_info( - body={"email": email, "password": password, "subid": subscription_id} - ) - assert ( - reg_resp.status_code == 201 - ), f"Registration failed with status code: {reg_resp.status_code}" - # registration success is 201; full account not returned anymore - # Log in to the account - login_response = auth_api.api_v1_login_post_with_http_info( - body=RequestsLoginBody(email=email, password=password) - ) - assert ( - login_response.status_code == 200 - ), f"Login failed with status code: {login_response.status_code}" - cookie = login_response.headers.get("Set-Cookie") - assert cookie, "No session cookie returned after login" - - # Fetch current account data using cookie - account_api.api_client.default_headers["Cookie"] = cookie - account = account_api.api_v1_accounts_current_get() - assert len(account.profiles) == 1 - return account, cookie, password + Returns (account, cookie, password). New code should use the `user` + fixture (ProfileSession) or libs.accounts.create_account directly. + """ + account, cookie, password, _ = create_account() + return account, cookie, password @pytest.fixture(scope="session", autouse=True) diff --git a/tests/dns_tests/infra/test_redis_failover.py b/tests/dns_tests/infra/test_redis_failover.py index cc05dbe5..6347651e 100644 --- a/tests/dns_tests/infra/test_redis_failover.py +++ b/tests/dns_tests/infra/test_redis_failover.py @@ -13,11 +13,10 @@ import docker import pytest +from libs.accounts import create_account, delete_account from libs.dns_lib import DNSLib from libs.settings import get_settings -from conftest import create_acc_and_login_func - REPLICA_CONTAINER = "redis-replica-dns" # Health check: 3 failures × 3 s interval = ~9 s. Add generous margin. FAILOVER_WAIT = 15 @@ -40,12 +39,19 @@ def setup_class(self): self.dns_lib = DNSLib(self.config.DOH_ENDPOINT) self.docker_client = docker.from_env() # Create a test account once for the whole class. - account, _, _ = create_acc_and_login_func() + account, cookie, password, _ = create_account() assert len(account.profiles) == 1 self.profile_id = account.profiles[0] + self._cookie = cookie + self._password = password + self._account_id = account.id def teardown_class(self): - self.docker_client.close() + # Best-effort account cleanup, but always release the docker client. + try: + delete_account(self._cookie, self._password, account_id=self._account_id) + finally: + self.docker_client.close() def _get_replica(self): return self.docker_client.containers.get(REPLICA_CONTAINER) diff --git a/tests/dns_tests/test_basic.py b/tests/dns_tests/test_basic.py index 3714f6e9..fd00ff88 100644 --- a/tests/dns_tests/test_basic.py +++ b/tests/dns_tests/test_basic.py @@ -2,28 +2,18 @@ import pytest from libs.dns_lib import DNSLib +from libs.session import ProfileSession from libs.settings import get_settings from dns.message import ShortHeader from dns.rdataclass import IN from dns.rdatatype import A -import random -import string -from helpers import generate_complex_password -import moddns.api_client as client -import moddns.api as api -import moddns.configuration as api_config -from moddns import RequestsLoginBody -from conftest import create_temp_subscription +# Account-less DoH client for the missing/non-existent profile case, which must +# be exercised without a registered account. +_dns = DNSLib(get_settings().DOH_ENDPOINT) class TestBasic: - def setup_class(self): - """Setup the test class.""" - self.config = get_settings() - self.api_config = api_config.Configuration(host=self.config.DNS_API_ADDR) - self.dns_lib = DNSLib(self.config.DOH_ENDPOINT) - @pytest.mark.asyncio @pytest.mark.parametrize("profile_id", ["", "123"]) async def test_profile_id_not_provided_or_non_existing(self, profile_id: str): @@ -32,7 +22,7 @@ async def test_profile_id_not_provided_or_non_existing(self, profile_id: str): exception (connection is dropped, user does not get any response). """ with pytest.raises(ShortHeader): - await self.dns_lib.send_doh_request(profile_id, "example.com", "A") + await _dns.send_doh_request(profile_id, "example.com", "A") @pytest.mark.asyncio @pytest.mark.xfail( @@ -43,30 +33,9 @@ async def test_regular_account(self): """ Create account and use its profile_id to resolve some DNS request. """ - with client.ApiClient(self.api_config) as api_client: - api_instance = api.AccountApi(api_client) - - password = generate_complex_password() - subscription_id, pa_cookie = create_temp_subscription() - email = f"test{''.join(random.choice(string.digits) for i in range(5))}@ivpn.net" - - api_instance.api_client.default_headers["Cookie"] = pa_cookie - reg_resp = api_instance.api_v1_accounts_post( - body={"email": email, "password": password, "subid": subscription_id} - ) - # Login to obtain cookie - auth_api = api.AuthenticationApi(api_client) - login_resp = auth_api.api_v1_login_post_with_http_info( - body=RequestsLoginBody(email=email, password=password) - ) - assert login_resp.status_code == 200 - cookie = login_resp.headers.get("Set-Cookie") - assert cookie - api_instance.api_client.default_headers["Cookie"] = cookie - account = api_instance.api_v1_accounts_current_get() - assert len(account.profiles) == 1 - profile_id = account.profiles[0] - resp = await self.dns_lib.send_doh_request(profile_id, "facebook.com", "A") + session = ProfileSession.create() + try: + resp = await session.resolve(session.default_profile_id, "facebook.com", A) assert ( len(resp.answer) == 1 ) # 1 answer since DNSSEC is not configured on facebook.com @@ -74,3 +43,5 @@ async def test_regular_account(self): assert resp.answer[0].rdclass == IN ipv4_addr = resp.answer[0].to_text().split(" ")[-1] assert ip_address(ipv4_addr) != ip_address("0.0.0.0") + finally: + session.cleanup() diff --git a/tests/dns_tests/test_blocklists.py b/tests/dns_tests/test_blocklists.py index 5f852847..69346d5e 100644 --- a/tests/dns_tests/test_blocklists.py +++ b/tests/dns_tests/test_blocklists.py @@ -1,23 +1,13 @@ -from ipaddress import ip_address - import pytest -from libs.dns_lib import DNSLib, is_blocked, is_resolved -from libs.settings import get_settings from dns.rdatatype import A -import redis -import moddns.api_client as client -import moddns.api as api -import moddns.configuration as api_config -from moddns import ( - RequestsProfileUpdates, - ModelProfileUpdate, - ApiCreateProfileBody, - ApiBlocklistsUpdates, +from libs.constants import ( + BLOCKLISTED_DOMAIN, + BLOCKLISTED_SUBDOMAIN, + RESOLVABLE_TEST_DOMAIN, + TEST_BLOCKLIST_ID, ) - -# Import shared test constants & fixture (fixture auto-discovered by pytest, constants used directly) -from conftest import TEST_BLOCKLIST_ID, TEST_DOMAIN, TEST_SUBDOMAIN # noqa: F401 +from libs.dns_lib import assert_blocked, assert_not_blocked, is_blocked, is_resolved class TestBlocklistFilters: @@ -25,177 +15,91 @@ class TestBlocklistFilters: Test cases for DNS blocklist functionality. """ - def setup_class(self): - """Setup the test class.""" - self.config = get_settings() - self.api_config = api_config.Configuration(host=self.config.DNS_API_ADDR) - self.dns_lib = DNSLib(self.config.DOH_ENDPOINT) - self.redis_client = redis.Redis( - host=self.config.REDIS_HOST, port=self.config.REDIS_PORT, db=0 - ) - def test_threat_intelligence_feeds_blocklist( - self, create_account_and_login, ensure_test_blocklisted + self, user, ensure_test_blocklisted, redis_client ): """ Test that the Threat Intelligence Feeds blocklist is enabled by default. """ blocklist_set = f"blocklist:{TEST_BLOCKLIST_ID}" - assert self.redis_client.sismember( - blocklist_set, TEST_DOMAIN - ), f'"{TEST_DOMAIN}" is not present in Redis set {blocklist_set}' - - account, cookie = create_account_and_login - with client.ApiClient(self.api_config) as api_client: - profiles_instance = api.ProfileApi(api_client) - profile_id = account.profiles[0] - - profiles_instance.api_client.default_headers["Cookie"] = cookie - resp = profiles_instance.api_v1_profiles_id_get_with_http_info( - id=profile_id - ) - assert ( - resp.status_code == 200 - ), f"Failed to get profile ID {profile_id} with status code: {resp.status_code}" - assert ( - len(resp.data.settings.privacy.blocklists) == 1 - ), "Threat Intelligence Feeds blocklist is not enabled for profile" - assert ( - resp.data.settings.privacy.blocklists[0] == TEST_BLOCKLIST_ID - ), "Threat Intelligence Feeds blocklist is not enabled for profile" + assert redis_client.sismember( + blocklist_set, BLOCKLISTED_DOMAIN + ), f'"{BLOCKLISTED_DOMAIN}" is not present in Redis set {blocklist_set}' + + profile = user.get_profile(user.default_profile_id) + assert ( + len(profile.settings.privacy.blocklists) == 1 + ), "Threat Intelligence Feeds blocklist is not enabled for profile" + assert ( + profile.settings.privacy.blocklists[0] == TEST_BLOCKLIST_ID + ), "Threat Intelligence Feeds blocklist is not enabled for profile" @pytest.mark.asyncio @pytest.mark.parametrize( "domain,expected_blocked", [ - (TEST_DOMAIN, True), - ("example.com", False), + (BLOCKLISTED_DOMAIN, True), + (RESOLVABLE_TEST_DOMAIN, False), ], ) async def test_blocklist_blocking( self, - create_account_and_login, + user, domain, expected_blocked, ensure_test_blocklisted, ): """Test that domains in the blocklist are blocked and others are not.""" - account, cookie = create_account_and_login - with client.ApiClient(self.api_config) as api_client: - profiles_instance = api.ProfileApi(api_client) - profile_id = account.profiles[0] - - profiles_instance.api_client.default_headers["Cookie"] = cookie - resp = profiles_instance.api_v1_profiles_id_get_with_http_info( - id=profile_id - ) - assert ( - resp.status_code == 200 - ), f"Failed to get profile ID {profile_id} with status code: {resp.status_code}" - assert ( - len(resp.data.settings.privacy.blocklists) == 1 - ), "Threat Intelligence Feeds blocklist is not enabled for profile" - assert ( - resp.data.settings.privacy.blocklists[0] == TEST_BLOCKLIST_ID - ), "Threat Intelligence Feeds blocklist is not enabled for profile" + profile_id = user.default_profile_id + profile = user.get_profile(profile_id) + assert ( + len(profile.settings.privacy.blocklists) == 1 + ), "Threat Intelligence Feeds blocklist is not enabled for profile" + assert ( + profile.settings.privacy.blocklists[0] == TEST_BLOCKLIST_ID + ), "Threat Intelligence Feeds blocklist is not enabled for profile" if expected_blocked: - resp = await self.dns_lib.wait_until(profile_id, domain, A, is_blocked) + resp = await user.wait_for(profile_id, domain, A, is_blocked) + assert_blocked(resp, domain) else: # NOTE: negative assertion — cannot poll; may read pre-mutation state (see DNSLib.wait_until docstring) - resp = await self.dns_lib.send_doh_request(profile_id, domain, A) - ip_addr = resp.answer[0].to_text().split(" ")[-1] - if expected_blocked: - assert ( - ip_addr == "0.0.0.0" - ), f"Blocklisted domain {domain} did not return 0.0.0.0" - else: - assert ip_address( - ip_addr - ), f"Non-blocklisted domain {domain} did not return a valid IP" + resp = await user.resolve(profile_id, domain, A) + assert_not_blocked(resp, domain) @pytest.mark.asyncio async def test_blocklist_disable_unblocks_domain( - self, create_account_and_login, ensure_test_blocklisted + self, user, ensure_test_blocklisted ): """Test that disabling the blocklist unblocks a previously blocked domain.""" - account, cookie = create_account_and_login - with client.ApiClient(self.api_config) as api_client: - profiles_instance = api.ProfileApi(api_client) - # Fresh profile: this test disables the blocklist and must not - # mutate the shared class profile other tests assert against. - profiles_instance.api_client.default_headers["Cookie"] = cookie - create_resp = profiles_instance.api_v1_profiles_post_with_http_info( - body=ApiCreateProfileBody(name="bl_disable_test") - ) - assert ( - create_resp.status_code == 201 - ), f"Failed to create profile with status code: {create_resp.status_code}" - profile_id = create_resp.data.profile_id + # Fresh profile: this test disables the blocklist and must not + # mutate the shared class profile other tests assert against. + profile_id = user.new_profile("bl_disable") - resp = await self.dns_lib.wait_until(profile_id, TEST_DOMAIN, A, is_blocked) - ip_addr = resp.answer[0].to_text().split(" ")[-1] - assert ( - ip_addr == "0.0.0.0" - ), f"Blocklisted domain {TEST_DOMAIN} did not return 0.0.0.0" + resp = await user.wait_for(profile_id, BLOCKLISTED_DOMAIN, A, is_blocked) + assert_blocked(resp, BLOCKLISTED_DOMAIN) - profiles_instance.api_client.default_headers["Cookie"] = cookie - disable_body = ApiBlocklistsUpdates(blocklist_ids=[TEST_BLOCKLIST_ID]) - disable_resp = ( - profiles_instance.api_v1_profiles_id_blocklists_delete_with_http_info( - id=profile_id, blocklist_ids=disable_body - ) - ) - assert ( - disable_resp.status_code == 200 - ), f"Failed to disable blocklist with status code: {disable_resp.status_code}" + user.disable_blocklists(profile_id, [TEST_BLOCKLIST_ID]) - get_resp = profiles_instance.api_v1_profiles_id_get_with_http_info( - id=profile_id - ) - assert ( - get_resp.status_code == 200 - ), f"Failed to get profile with status code: {get_resp.status_code}" - assert ( - len(get_resp.data.settings.privacy.blocklists) == 0 - ), "Blocklist still enabled after disabling" + profile = user.get_profile(profile_id) + assert ( + len(profile.settings.privacy.blocklists) == 0 + ), "Blocklist still enabled after disabling" - resp2 = await self.dns_lib.wait_until(profile_id, TEST_DOMAIN, A, is_resolved) - ip_addr2 = resp2.answer[0].to_text().split(" ")[-1] - assert ( - ip_address(ip_addr2) and ip_addr2 != "0.0.0.0" - ), f"Domain {TEST_DOMAIN} still blocked after disabling blocklist" + resp2 = await user.wait_for(profile_id, BLOCKLISTED_DOMAIN, A, is_resolved) + assert_not_blocked(resp2, BLOCKLISTED_DOMAIN) @pytest.mark.asyncio async def test_blocklist_subdomain_behavior( - self, create_account_and_login, ensure_test_blocklisted + self, user, ensure_test_blocklisted ): """Test blocklist default subdomain blocking behavior.""" - _, cookie = create_account_and_login - with client.ApiClient(self.api_config) as api_client: - profiles_instance = api.ProfileApi(api_client) - profiles_instance.api_client.default_headers["Cookie"] = cookie - body = ApiCreateProfileBody(name="test_profile") - resp = profiles_instance.api_v1_profiles_post_with_http_info(body=body) - assert ( - resp.status_code == 201 - ), f"Failed to create profile with status code: {resp.status_code}" - profile_id = resp.data.profile_id + profile_id = user.new_profile("test_profile") - # Parent domain should be blocked - resp_parent = await self.dns_lib.wait_until( - profile_id, TEST_DOMAIN, A, is_blocked - ) - ip_parent = resp_parent.answer[0].to_text().split(" ")[-1] - assert ( - ip_parent == "0.0.0.0" - ), f"Blocklisted parent domain {TEST_DOMAIN} did not return 0.0.0.0" + # Parent domain should be blocked + resp_parent = await user.wait_for(profile_id, BLOCKLISTED_DOMAIN, A, is_blocked) + assert_blocked(resp_parent, BLOCKLISTED_DOMAIN) - # Subdomain should be blocked when subdomain blocking rule is active by default (added explicitly as entry) - resp_sub = await self.dns_lib.send_doh_request( - profile_id, TEST_SUBDOMAIN, A - ) - ip_sub = resp_sub.answer[0].to_text().split(" ")[-1] - assert ( - ip_sub == "0.0.0.0" - ), f"Blocklisted subdomain {TEST_SUBDOMAIN} did not return 0.0.0.0" + # Subdomain should be blocked when subdomain blocking rule is active by default (added explicitly as entry) + resp_sub = await user.resolve(profile_id, BLOCKLISTED_SUBDOMAIN, A) + assert_blocked(resp_sub, BLOCKLISTED_SUBDOMAIN) diff --git a/tests/dns_tests/test_cross_phase_filtering.py b/tests/dns_tests/test_cross_phase_filtering.py index 9cb6fa6a..75429868 100644 --- a/tests/dns_tests/test_cross_phase_filtering.py +++ b/tests/dns_tests/test_cross_phase_filtering.py @@ -10,29 +10,22 @@ """ import pytest -from libs.dns_lib import DNSLib, is_blocked -from libs.settings import get_settings +from libs.dns_lib import is_blocked +from libs.constants import RESOLVABLE_TEST_DOMAIN, RESOLVABLE_TEST_IP from libs.profile_helpers import ( - ProfileHelpers, extract_ip, services_available, SVC_GOOGLE_DOMAIN, SVC_GOOGLE_IP, SVC_GOOGLE_ID, - TEST_DOMAIN, - TEST_IP, ) from dns.rdatatype import A -import moddns.api_client as client -import moddns.api as api -import moddns.configuration as api_config - # =================================================================== # Unified cross-phase aggregation — domain allow overrides IP blocks # =================================================================== -class TestCrossPhaseAggregation(ProfileHelpers): +class TestCrossPhaseAggregation: """Domain-phase custom Allow (T200) overrides IP-phase blocks through unified cross-phase aggregation. @@ -40,239 +33,166 @@ class TestCrossPhaseAggregation(ProfileHelpers): following the global aggregation rule: any Allow present wins. """ - def setup_class(self): - self.config = get_settings() - self.api_config = api_config.Configuration(host=self.config.DNS_API_ADDR) - self.dns_lib = DNSLib(self.config.DOH_ENDPOINT) - @pytest.mark.asyncio - async def test_domain_allow_overrides_services_block( - self, create_account_and_login - ): + async def test_domain_allow_overrides_services_block(self, user): """Domain custom allow + services block -> Processed. Domain Allow (T200) overrides services block (T100) through unified cross-phase aggregation. Behaviour table #8.""" - account, cookie = create_account_and_login - with client.ApiClient(self.api_config) as api_client: - p = api.ProfileApi(api_client) - p.api_client.default_headers["Cookie"] = cookie - - if not await services_available(self.dns_lib, p, cookie): + with user.profiles_api() as p: + if not await services_available(user.dns, p, user.cookie): pytest.skip("Services/ASN blocking not available") - profile_id = self._create_profile(p, "cross_phase_8") - self._create_custom_rule( - p, profile_id, "allow", SVC_GOOGLE_DOMAIN - ) - self._block_service(p, profile_id, [SVC_GOOGLE_ID]) + profile_id = user.new_profile("cross_phase_8") + user.add_rule(profile_id, "allow", SVC_GOOGLE_DOMAIN) + user.block_services(profile_id, [SVC_GOOGLE_ID]) - # NOTE: negative assertion — cannot poll; may read pre-mutation state (see DNSLib.wait_until docstring) - resp = await self.dns_lib.send_doh_request( - profile_id, SVC_GOOGLE_DOMAIN, A - ) - ip_str = extract_ip(resp) - assert ip_str != "0.0.0.0", ( - f"#8: Domain allow for {SVC_GOOGLE_DOMAIN} should override " - f"services block; got {ip_str}" - ) + # NOTE: negative assertion — cannot poll; may read pre-mutation state (see DNSLib.wait_until docstring) + resp = await user.resolve(profile_id, SVC_GOOGLE_DOMAIN, A) + ip_str = extract_ip(resp) + assert ip_str != "0.0.0.0", ( + f"#8: Domain allow for {SVC_GOOGLE_DOMAIN} should override " + f"services block; got {ip_str}" + ) @pytest.mark.asyncio - async def test_domain_allow_overrides_ip_block( - self, create_account_and_login - ): + async def test_domain_allow_overrides_ip_block(self, user): """Domain custom allow + IP custom block -> Processed. Domain Allow (T200) overrides IP custom block (T200) — Allow always wins. Behaviour table #9.""" - account, cookie = create_account_and_login - with client.ApiClient(self.api_config) as api_client: - p = api.ProfileApi(api_client) - p.api_client.default_headers["Cookie"] = cookie - profile_id = self._create_profile(p, "cross_phase_9") + profile_id = user.new_profile("cross_phase_9") - self._create_custom_rule(p, profile_id, "allow", TEST_DOMAIN) - self._create_custom_rule(p, profile_id, "block", TEST_IP) + user.add_rule(profile_id, "allow", RESOLVABLE_TEST_DOMAIN) + user.add_rule(profile_id, "block", RESOLVABLE_TEST_IP) - # NOTE: negative assertion — cannot poll; may read pre-mutation state (see DNSLib.wait_until docstring) - resp = await self.dns_lib.send_doh_request(profile_id, TEST_DOMAIN, A) - ip_str = extract_ip(resp) - assert ip_str != "0.0.0.0", ( - f"#9: Domain allow should override IP block; got {ip_str}" - ) + # NOTE: negative assertion — cannot poll; may read pre-mutation state (see DNSLib.wait_until docstring) + resp = await user.resolve(profile_id, RESOLVABLE_TEST_DOMAIN, A) + ip_str = extract_ip(resp) + assert ip_str != "0.0.0.0", ( + f"#9: Domain allow should override IP block; got {ip_str}" + ) @pytest.mark.asyncio async def test_domain_allow_overrides_blocklist_and_ip_block( - self, create_account_and_login, ensure_domain_blocklisted + self, user, ensure_domain_blocklisted ): """BL block + domain CR allow + IP CR block -> Processed. Domain Allow (T200) overrides both blocklist (T100) and IP custom block (T200). Behaviour table #15.""" - account, cookie = create_account_and_login - ensure_domain_blocklisted(TEST_DOMAIN) - with client.ApiClient(self.api_config) as api_client: - p = api.ProfileApi(api_client) - p.api_client.default_headers["Cookie"] = cookie - profile_id = self._create_profile(p, "cross_phase_15") - # Default blocklist (TEST_BLOCKLIST_ID) is already enabled on new profiles. - self._create_custom_rule(p, profile_id, "allow", TEST_DOMAIN) - self._create_custom_rule(p, profile_id, "block", TEST_IP) - - # NOTE: negative assertion — cannot poll; may read pre-mutation state (see DNSLib.wait_until docstring) - resp = await self.dns_lib.send_doh_request(profile_id, TEST_DOMAIN, A) - ip_str = extract_ip(resp) - assert ip_str != "0.0.0.0", ( - f"#15: Domain allow should override BL block + IP block; " - f"got {ip_str}" - ) + ensure_domain_blocklisted(RESOLVABLE_TEST_DOMAIN) + profile_id = user.new_profile("cross_phase_15") + # Default blocklist (TEST_BLOCKLIST_ID) is already enabled on new profiles. + user.add_rule(profile_id, "allow", RESOLVABLE_TEST_DOMAIN) + user.add_rule(profile_id, "block", RESOLVABLE_TEST_IP) + + # NOTE: negative assertion — cannot poll; may read pre-mutation state (see DNSLib.wait_until docstring) + resp = await user.resolve(profile_id, RESOLVABLE_TEST_DOMAIN, A) + ip_str = extract_ip(resp) + assert ip_str != "0.0.0.0", ( + f"#15: Domain allow should override BL block + IP block; " + f"got {ip_str}" + ) @pytest.mark.asyncio async def test_domain_allow_overrides_blocklist_and_services_block( - self, create_account_and_login, ensure_domain_blocklisted + self, user, ensure_domain_blocklisted ): """BL block + domain CR allow + services block -> Processed. Domain Allow (T200) overrides both blocklist (T100) and services block (T100). Behaviour table #14.""" - account, cookie = create_account_and_login ensure_domain_blocklisted(SVC_GOOGLE_DOMAIN) - with client.ApiClient(self.api_config) as api_client: - p = api.ProfileApi(api_client) - p.api_client.default_headers["Cookie"] = cookie - - if not await services_available(self.dns_lib, p, cookie): + with user.profiles_api() as p: + if not await services_available(user.dns, p, user.cookie): pytest.skip("Services/ASN blocking not available") - profile_id = self._create_profile(p, "cross_phase_14") - # Default blocklist (TEST_BLOCKLIST_ID) is already enabled on new profiles. - self._create_custom_rule( - p, profile_id, "allow", SVC_GOOGLE_DOMAIN - ) - self._block_service(p, profile_id, [SVC_GOOGLE_ID]) + profile_id = user.new_profile("cross_phase_14") + # Default blocklist (TEST_BLOCKLIST_ID) is already enabled on new profiles. + user.add_rule(profile_id, "allow", SVC_GOOGLE_DOMAIN) + user.block_services(profile_id, [SVC_GOOGLE_ID]) - # NOTE: negative assertion — cannot poll; may read pre-mutation state (see DNSLib.wait_until docstring) - resp = await self.dns_lib.send_doh_request( - profile_id, SVC_GOOGLE_DOMAIN, A - ) - ip_str = extract_ip(resp) - assert ip_str != "0.0.0.0", ( - f"#14: Domain allow should override BL block + services block; " - f"got {ip_str}" - ) + # NOTE: negative assertion — cannot poll; may read pre-mutation state (see DNSLib.wait_until docstring) + resp = await user.resolve(profile_id, SVC_GOOGLE_DOMAIN, A) + ip_str = extract_ip(resp) + assert ip_str != "0.0.0.0", ( + f"#14: Domain allow should override BL block + services block; " + f"got {ip_str}" + ) @pytest.mark.asyncio - async def test_ip_allow_overrides_services_with_domain_allow( - self, create_account_and_login - ): + async def test_ip_allow_overrides_services_with_domain_allow(self, user): """Domain allow + services block + IP allow -> Processed. Both domain and IP allow, services blocked. Table #12.""" - account, cookie = create_account_and_login - with client.ApiClient(self.api_config) as api_client: - p = api.ProfileApi(api_client) - p.api_client.default_headers["Cookie"] = cookie - - if not await services_available(self.dns_lib, p, cookie): + with user.profiles_api() as p: + if not await services_available(user.dns, p, user.cookie): pytest.skip("Services/ASN blocking not available") - profile_id = self._create_profile(p, "ip_allow_svc_12") - self._create_custom_rule( - p, profile_id, "allow", SVC_GOOGLE_DOMAIN - ) - self._block_service(p, profile_id, [SVC_GOOGLE_ID]) - self._create_custom_rule(p, profile_id, "allow", SVC_GOOGLE_IP) + profile_id = user.new_profile("ip_allow_svc_12") + user.add_rule(profile_id, "allow", SVC_GOOGLE_DOMAIN) + user.block_services(profile_id, [SVC_GOOGLE_ID]) + user.add_rule(profile_id, "allow", SVC_GOOGLE_IP) - # NOTE: negative assertion — cannot poll; may read pre-mutation state (see DNSLib.wait_until docstring) - resp = await self.dns_lib.send_doh_request( - profile_id, SVC_GOOGLE_DOMAIN, A - ) - ip_str = extract_ip(resp) - assert ip_str != "0.0.0.0", ( - f"#12: Domain allow + IP allow should override services block; " - f"got {ip_str}" - ) + # NOTE: negative assertion — cannot poll; may read pre-mutation state (see DNSLib.wait_until docstring) + resp = await user.resolve(profile_id, SVC_GOOGLE_DOMAIN, A) + ip_str = extract_ip(resp) + assert ip_str != "0.0.0.0", ( + f"#12: Domain allow + IP allow should override services block; " + f"got {ip_str}" + ) # =================================================================== # Domain block is terminal — IP phase is skipped entirely # =================================================================== -class TestDomainBlockTerminal(ProfileHelpers): +class TestDomainBlockTerminal: """When the domain phase blocks, the IP phase is skipped entirely. Configured IP allow rules are inert.""" - def setup_class(self): - self.config = get_settings() - self.api_config = api_config.Configuration(host=self.config.DNS_API_ADDR) - self.dns_lib = DNSLib(self.config.DOH_ENDPOINT) - @pytest.mark.asyncio - async def test_domain_block_ignores_ip_allow(self, create_account_and_login): + async def test_domain_block_ignores_ip_allow(self, user): """Domain CR block + IP CR allow -> Blocked. IP allow can't fire because domain block prevents upstream resolution (no response IPs to match). Table #24.""" - account, cookie = create_account_and_login - with client.ApiClient(self.api_config) as api_client: - p = api.ProfileApi(api_client) - p.api_client.default_headers["Cookie"] = cookie - profile_id = self._create_profile(p, "terminal_24") + profile_id = user.new_profile("terminal_24") - self._create_custom_rule(p, profile_id, "block", TEST_DOMAIN) - self._create_custom_rule(p, profile_id, "allow", TEST_IP) + user.add_rule(profile_id, "block", RESOLVABLE_TEST_DOMAIN) + user.add_rule(profile_id, "allow", RESOLVABLE_TEST_IP) - resp = await self.dns_lib.wait_until(profile_id, TEST_DOMAIN, A, is_blocked) - ip_str = extract_ip(resp) - assert ip_str == "0.0.0.0", ( - f"#24: Domain block must be terminal -- IP allow should be " - f"inert; got {ip_str}" - ) + resp = await user.wait_for(profile_id, RESOLVABLE_TEST_DOMAIN, A, is_blocked) + ip_str = extract_ip(resp) + assert ip_str == "0.0.0.0", ( + f"#24: Domain block must be terminal -- IP allow should be " + f"inert; got {ip_str}" + ) @pytest.mark.asyncio async def test_blocklist_block_ignores_ip_allow( - self, create_account_and_login, ensure_domain_blocklisted + self, user, ensure_domain_blocklisted ): """BL block (no domain CR allow to override) + IP CR allow -> Blocked. Table #19 variant with IP allow configured.""" - account, cookie = create_account_and_login - ensure_domain_blocklisted(TEST_DOMAIN) - with client.ApiClient(self.api_config) as api_client: - p = api.ProfileApi(api_client) - p.api_client.default_headers["Cookie"] = cookie - profile_id = self._create_profile(p, "terminal_bl_19") - # Default blocklist (TEST_BLOCKLIST_ID) is already enabled on new profiles. - self._create_custom_rule(p, profile_id, "allow", TEST_IP) - - resp = await self.dns_lib.wait_until(profile_id, TEST_DOMAIN, A, is_blocked) - ip_str = extract_ip(resp) - assert ip_str == "0.0.0.0", ( - f"#19 variant: Blocklist block must be terminal -- IP allow " - f"should be inert; got {ip_str}" - ) + ensure_domain_blocklisted(RESOLVABLE_TEST_DOMAIN) + profile_id = user.new_profile("terminal_bl_19") + # Default blocklist (TEST_BLOCKLIST_ID) is already enabled on new profiles. + user.add_rule(profile_id, "allow", RESOLVABLE_TEST_IP) + + resp = await user.wait_for(profile_id, RESOLVABLE_TEST_DOMAIN, A, is_blocked) + ip_str = extract_ip(resp) + assert ip_str == "0.0.0.0", ( + f"#19 variant: Blocklist block must be terminal -- IP allow " + f"should be inert; got {ip_str}" + ) @pytest.mark.asyncio - async def test_default_block_ignores_ip_allow(self, create_account_and_login): + async def test_default_block_ignores_ip_allow(self, user): """default_rule=block + IP CR allow -> Blocked. Default rule blocks at domain phase, IP allow never evaluated.""" - account, cookie = create_account_and_login - with client.ApiClient(self.api_config) as api_client: - p = api.ProfileApi(api_client) - p.api_client.default_headers["Cookie"] = cookie - profile_id = self._create_profile(p, "terminal_default") - - from moddns import RequestsProfileUpdates, ModelProfileUpdate + profile_id = user.new_profile("terminal_default") - p.api_v1_profiles_id_patch_with_http_info( - id=profile_id, - body=RequestsProfileUpdates( - updates=[ - ModelProfileUpdate( - operation="replace", - path="/settings/privacy/default_rule", - value={"value": "block"}, - ) - ] - ), - ) - self._create_custom_rule(p, profile_id, "allow", TEST_IP) + user.patch_setting(profile_id, "/settings/privacy/default_rule", "block") + user.add_rule(profile_id, "allow", RESOLVABLE_TEST_IP) - resp = await self.dns_lib.wait_until(profile_id, TEST_DOMAIN, A, is_blocked) - ip_str = extract_ip(resp) - assert ip_str == "0.0.0.0", ( - f"Default block must be terminal -- IP allow should be inert; " - f"got {ip_str}" - ) + resp = await user.wait_for(profile_id, RESOLVABLE_TEST_DOMAIN, A, is_blocked) + ip_str = extract_ip(resp) + assert ip_str == "0.0.0.0", ( + f"Default block must be terminal -- IP allow should be inert; " + f"got {ip_str}" + ) diff --git a/tests/dns_tests/test_custom_rules.py b/tests/dns_tests/test_custom_rules.py index 1efd1f20..aabc5668 100644 --- a/tests/dns_tests/test_custom_rules.py +++ b/tests/dns_tests/test_custom_rules.py @@ -1,26 +1,13 @@ -import uuid from ipaddress import ip_address, IPv6Address import pytest -from libs.dns_lib import DNSLib, is_blocked -from libs.settings import get_settings +from libs.dns_lib import is_blocked from dns.rdataclass import IN from dns.rdatatype import A, AAAA from dns.flags import RD, QR -import moddns.api_client as client -import moddns.api as api -import moddns.configuration as api_config -from moddns import ApiCreateProfileBody, RequestsCreateProfileCustomRuleBody - class TestCustomRules: - def setup_class(self): - """Setup the test class.""" - self.config = get_settings() - self.api_config = api_config.Configuration(host=self.config.DNS_API_ADDR) - self.dns_lib = DNSLib(self.config.DOH_ENDPOINT) - @pytest.mark.asyncio @pytest.mark.parametrize( "test_domain,queries", @@ -102,79 +89,48 @@ def setup_class(self): ), # block IPv6, expect :: as blocked response ], ) - async def test_blocking_custom_rule_answer( - self, create_account_and_login, test_domain, queries - ): + async def test_blocking_custom_rule_answer(self, user, test_domain, queries): """ - Create account, configure blocking custom rule for a domain/IP, then send queries and ensure DNS response contains expected IP address. + Configure a blocking custom rule for a domain/IP, then send queries and ensure DNS response contains expected IP address. """ - account, cookie = create_account_and_login - with client.ApiClient(self.api_config) as api_client: - profiles_instance = api.ProfileApi(api_client) - profiles_instance.api_client.default_headers["Cookie"] = cookie - - # Fresh profile per parametrization: rules must not accumulate on - # the shared class profile across the 13 params. Name must be - # unique — the API rejects duplicate profile names per account. - create_resp = profiles_instance.api_v1_profiles_post_with_http_info( - body=ApiCreateProfileBody(name=f"custom_rule_{uuid.uuid4().hex[:8]}") - ) - assert ( - create_resp.status_code == 201 - ), f"Failed to create profile with status code: {create_resp.status_code}" - profile_id = create_resp.data.profile_id + profile_id = user.new_profile("custom_rule") + user.add_rule(profile_id, "block", test_domain) - custom_rule_body = RequestsCreateProfileCustomRuleBody( - action="block", value=test_domain - ) - ur_resp = ( - profiles_instance.api_v1_profiles_id_custom_rules_post_with_http_info( - id=profile_id, body=custom_rule_body - ) - ) - assert ( - ur_resp.status_code == 201 - ), f"Custom rule creation failed for {test_domain} with status code: {ur_resp.status_code}" + waited = False + for query, expected_value in queries.items(): + # Determine if we should send an A or AAAA query + try: + ip_ver = ip_address(expected_value) + except ValueError: + ip_ver = None - waited = False - for query, expected_value in queries.items(): - # Determine if we should send an A or AAAA query - try: - ip_ver = ip_address(expected_value) - except ValueError: - ip_ver = None + if isinstance(ip_ver, IPv6Address): + record_type = AAAA + else: + record_type = A - if isinstance(ip_ver, IPv6Address): - record_type = AAAA - else: - record_type = A - - # Send DNS query. The first query whose block outcome depends on - # the rule just created polls for replication to catch up. - if expected_value in ("0.0.0.0", "::") and not waited: - resp = await self.dns_lib.wait_until( - profile_id, query, record_type, is_blocked - ) - waited = True - else: - resp = await self.dns_lib.send_doh_request( - profile_id, query, record_type - ) - # Blocked expectations: ensure an answer and it matches the block IP - if expected_value in ("0.0.0.0", "::"): - assert resp.answer, f"Expected a blocked answer for {query}" - if record_type == A: - assert resp.answer[0].rdtype == A - else: - assert resp.answer[0].rdtype == AAAA - assert resp.answer[0].rdclass == IN - assert resp.flags & QR, "QR flag is not set in the response" - assert resp.flags & RD, "RD flag is not set in the response" - ip_addr = resp.answer[0].to_text().split(" ")[-1] - assert ip_address(ip_addr) == ip_address( - expected_value - ), f"Blocked domain {test_domain} did not return {expected_value}" + # Send DNS query. The first query whose block outcome depends on + # the rule just created polls for replication to catch up. + if expected_value in ("0.0.0.0", "::") and not waited: + resp = await user.wait_for(profile_id, query, record_type, is_blocked) + waited = True + else: + resp = await user.resolve(profile_id, query, record_type) + # Blocked expectations: ensure an answer and it matches the block IP + if expected_value in ("0.0.0.0", "::"): + assert resp.answer, f"Expected a blocked answer for {query}" + if record_type == A: + assert resp.answer[0].rdtype == A else: - # Non-blocked expectations: allow any resolver behavior (could be NXDOMAIN or blocklists), - # so no strict assertions here. - continue + assert resp.answer[0].rdtype == AAAA + assert resp.answer[0].rdclass == IN + assert resp.flags & QR, "QR flag is not set in the response" + assert resp.flags & RD, "RD flag is not set in the response" + ip_addr = resp.answer[0].to_text().split(" ")[-1] + assert ip_address(ip_addr) == ip_address( + expected_value + ), f"Blocked domain {test_domain} did not return {expected_value}" + else: + # Non-blocked expectations: allow any resolver behavior (could be NXDOMAIN or blocklists), + # so no strict assertions here. + continue diff --git a/tests/dns_tests/test_custom_rules_precedence.py b/tests/dns_tests/test_custom_rules_precedence.py index dac11a4b..22e43bc6 100644 --- a/tests/dns_tests/test_custom_rules_precedence.py +++ b/tests/dns_tests/test_custom_rules_precedence.py @@ -1,22 +1,16 @@ -from ipaddress import ip_address - import pytest -from libs.dns_lib import DNSLib, is_blocked, is_resolved -from libs.settings import get_settings from dns.rdatatype import A -import redis - -import moddns.api_client as client -import moddns.api as api -import moddns.configuration as api_config -from moddns import ( - RequestsProfileUpdates, - ModelProfileUpdate, - RequestsCreateProfileCustomRuleBody, - ApiCreateProfileBody, -) -from conftest import TEST_BLOCKLIST_ID, TEST_DOMAIN, TEST_SUBDOMAIN +from libs.constants import ( + BLOCKLISTED_DOMAIN, + BLOCKLISTED_SUBDOMAIN, +) +from libs.dns_lib import ( + assert_blocked, + assert_not_blocked, + is_blocked, + is_resolved, +) class TestCustomRulesPrecedence: @@ -30,82 +24,9 @@ class TestCustomRulesPrecedence: Each test creates an isolated profile to avoid cross-test interference. """ - def setup_class(self): - """Setup the test class.""" - self.config = get_settings() - self.api_config = api_config.Configuration(host=self.config.DNS_API_ADDR) - self.dns_lib = DNSLib(self.config.DOH_ENDPOINT) - self.redis_client = redis.Redis( - host=self.config.REDIS_HOST, port=self.config.REDIS_PORT, db=0 - ) - - def _create_profile(self, profiles_instance, name): - """Helper to create a new profile and return its ID.""" - body = ApiCreateProfileBody(name=name) - resp = profiles_instance.api_v1_profiles_post_with_http_info(body=body) - assert ( - resp.status_code == 201 - ), f"Failed to create profile with status code: {resp.status_code}" - return resp.data.profile_id - - def _create_custom_rule(self, profiles_instance, profile_id, action, value): - """Helper to create a custom rule on a profile.""" - custom_rule_body = RequestsCreateProfileCustomRuleBody( - action=action, value=value - ) - resp = profiles_instance.api_v1_profiles_id_custom_rules_post_with_http_info( - id=profile_id, body=custom_rule_body - ) - assert ( - resp.status_code == 201 - ), f"Custom rule creation failed for {value} with status code: {resp.status_code}" - return resp - - def _set_default_rule(self, profiles_instance, profile_id, rule_value): - """Helper to set the default_rule on a profile via PATCH.""" - update_request = RequestsProfileUpdates( - updates=[ - ModelProfileUpdate( - operation="replace", - path="/settings/privacy/default_rule", - value={"value": rule_value}, - ) - ] - ) - resp = profiles_instance.api_v1_profiles_id_patch_with_http_info( - profile_id, body=update_request - ) - assert ( - resp.status_code == 200 - ), f"Profile default_rule update failed with status code: {resp.status_code}" - return resp - - def _set_custom_rules_subdomains_rule(self, profiles_instance, profile_id, value): - """Helper to set the custom_rules_subdomains_rule setting on a profile via PATCH. - - Args: - value: "include" (auto-prepend *. to plain FQDNs) or "exact" (store as-is). - """ - update_request = RequestsProfileUpdates( - updates=[ - ModelProfileUpdate( - operation="replace", - path="/settings/privacy/custom_rules_subdomains_rule", - value={"value": value}, - ) - ] - ) - resp = profiles_instance.api_v1_profiles_id_patch_with_http_info( - profile_id, body=update_request - ) - assert ( - resp.status_code == 200 - ), f"Profile custom_rules_subdomains_rule update failed with status code: {resp.status_code}" - return resp - @pytest.mark.asyncio async def test_custom_allow_overrides_blocklist_block( - self, create_account_and_login, ensure_test_blocklisted + self, user, ensure_test_blocklisted ): """Verify that a custom 'allow' rule overrides a blocklist 'block' for the same domain. @@ -117,42 +38,24 @@ async def test_custom_allow_overrides_blocklist_block( - The DNS query for example.com returns a valid IP (not 0.0.0.0) because CustomRules tier (200) takes precedence over Blocklists tier (100). """ - account, cookie = create_account_and_login - with client.ApiClient(self.api_config) as api_client: - profiles_instance = api.ProfileApi(api_client) - profiles_instance.api_client.default_headers["Cookie"] = cookie + profile_id = user.new_profile("test_allow_overrides_blocklist") - profile_id = self._create_profile( - profiles_instance, "test_allow_overrides_blocklist" - ) + # Confirm the domain is blocked by the blocklist before adding the custom rule + resp_blocked = await user.wait_for( + profile_id, BLOCKLISTED_DOMAIN, A, is_blocked + ) + assert_blocked(resp_blocked, BLOCKLISTED_DOMAIN) - # Confirm the domain is blocked by the blocklist before adding the custom rule - resp_blocked = await self.dns_lib.wait_until( - profile_id, TEST_DOMAIN, A, is_blocked - ) - ip_blocked = resp_blocked.answer[0].to_text().split(" ")[-1] - assert ( - ip_blocked == "0.0.0.0" - ), f"Expected {TEST_DOMAIN} to be blocked by blocklist, got {ip_blocked}" - - # Create custom allow rule for the blocklisted domain - self._create_custom_rule( - profiles_instance, profile_id, "allow", TEST_DOMAIN - ) + # Create custom allow rule for the blocklisted domain + user.add_rule(profile_id, "allow", BLOCKLISTED_DOMAIN) - # Query again -- custom allow should override blocklist block - resp = await self.dns_lib.wait_until(profile_id, TEST_DOMAIN, A, is_resolved) - assert resp.answer, f"Expected an answer for {TEST_DOMAIN}" - ip_addr = resp.answer[0].to_text().split(" ")[-1] - assert ip_addr != "0.0.0.0", ( - f"Custom allow rule did not override blocklist block for {TEST_DOMAIN}; " - f"got {ip_addr}" - ) - assert ip_address(ip_addr), f"Expected a valid IP, got {ip_addr}" + # Query again -- custom allow should override blocklist block + resp = await user.wait_for(profile_id, BLOCKLISTED_DOMAIN, A, is_resolved) + assert_not_blocked(resp, BLOCKLISTED_DOMAIN) @pytest.mark.asyncio async def test_custom_allow_overrides_subdomain_blocklist_block( - self, create_account_and_login, ensure_test_blocklisted + self, user, ensure_test_blocklisted ): """Verify that a custom 'allow' rule for a subdomain overrides inherited blocklist blocking. @@ -165,44 +68,32 @@ async def test_custom_allow_overrides_subdomain_blocklist_block( - The DNS query for sub.example.com returns a valid IP (not 0.0.0.0) because the exact custom allow rule overrides the inherited blocklist match. """ - account, cookie = create_account_and_login - with client.ApiClient(self.api_config) as api_client: - profiles_instance = api.ProfileApi(api_client) - profiles_instance.api_client.default_headers["Cookie"] = cookie + profile_id = user.new_profile("test_allow_overrides_subdomain_blocklist") - profile_id = self._create_profile( - profiles_instance, "test_allow_overrides_subdomain_blocklist" - ) + # Confirm subdomain is blocked by inherited blocklist rule + resp_blocked = await user.wait_for( + profile_id, BLOCKLISTED_SUBDOMAIN, A, is_blocked + ) + assert_blocked(resp_blocked, BLOCKLISTED_SUBDOMAIN) - # Confirm subdomain is blocked by inherited blocklist rule - resp_blocked = await self.dns_lib.wait_until( - profile_id, TEST_SUBDOMAIN, A, is_blocked - ) - ip_blocked = resp_blocked.answer[0].to_text().split(" ")[-1] - assert ( - ip_blocked == "0.0.0.0" - ), f"Expected {TEST_SUBDOMAIN} to be blocked by blocklist, got {ip_blocked}" - - # Create custom allow rule for the exact subdomain - self._create_custom_rule( - profiles_instance, profile_id, "allow", TEST_SUBDOMAIN - ) + # Create custom allow rule for the exact subdomain + user.add_rule(profile_id, "allow", BLOCKLISTED_SUBDOMAIN) - # Query again -- custom allow should override subdomain blocklist match. - # Note: sub.example.com may not exist in DNS (NXDOMAIN / empty answer), - # which is fine -- we only verify it's not actively blocked (0.0.0.0). - # NOTE: negative assertion — cannot poll; may read pre-mutation state (see DNSLib.wait_until docstring) - resp = await self.dns_lib.send_doh_request(profile_id, TEST_SUBDOMAIN, A) - if resp.answer: - ip_addr = resp.answer[0].to_text().split(" ")[-1] - assert ip_addr != "0.0.0.0", ( - f"Custom allow rule did not override subdomain blocklist block for " - f"{TEST_SUBDOMAIN}; got {ip_addr}" - ) + # Query again -- custom allow should override subdomain blocklist match. + # Note: sub.example.com may not exist in DNS (NXDOMAIN / empty answer), + # which is fine -- we only verify it's not actively blocked (0.0.0.0). + # NOTE: negative assertion — cannot poll; may read pre-mutation state (see DNSLib.wait_until docstring) + resp = await user.resolve(profile_id, BLOCKLISTED_SUBDOMAIN, A) + if resp.answer: + ip_addr = resp.answer[0].to_text().split(" ")[-1] + assert ip_addr != "0.0.0.0", ( + f"Custom allow rule did not override subdomain blocklist block for " + f"{BLOCKLISTED_SUBDOMAIN}; got {ip_addr}" + ) @pytest.mark.asyncio async def test_custom_wildcard_allow_overrides_blocklist( - self, create_account_and_login, ensure_test_blocklisted + self, user, ensure_test_blocklisted ): """Verify that a wildcard custom 'allow' rule overrides blocklist blocking for subdomains. @@ -214,45 +105,31 @@ async def test_custom_wildcard_allow_overrides_blocklist( - The DNS query for sub.example.com returns a valid IP (not 0.0.0.0) because the wildcard custom allow rule matches and overrides the blocklist. """ - account, cookie = create_account_and_login - with client.ApiClient(self.api_config) as api_client: - profiles_instance = api.ProfileApi(api_client) - profiles_instance.api_client.default_headers["Cookie"] = cookie + profile_id = user.new_profile("test_wildcard_allow_overrides_blocklist") - profile_id = self._create_profile( - profiles_instance, "test_wildcard_allow_overrides_blocklist" - ) + # Confirm subdomain is blocked before adding wildcard allow + resp_blocked = await user.wait_for( + profile_id, BLOCKLISTED_SUBDOMAIN, A, is_blocked + ) + assert_blocked(resp_blocked, BLOCKLISTED_SUBDOMAIN) - # Confirm subdomain is blocked before adding wildcard allow - resp_blocked = await self.dns_lib.wait_until( - profile_id, TEST_SUBDOMAIN, A, is_blocked - ) - ip_blocked = resp_blocked.answer[0].to_text().split(" ")[-1] - assert ( - ip_blocked == "0.0.0.0" - ), f"Expected {TEST_SUBDOMAIN} to be blocked by blocklist, got {ip_blocked}" - - # Create wildcard custom allow rule - self._create_custom_rule( - profiles_instance, profile_id, "allow", f"*.{TEST_DOMAIN}" - ) + # Create wildcard custom allow rule + user.add_rule(profile_id, "allow", f"*.{BLOCKLISTED_DOMAIN}") - # Query subdomain -- wildcard allow should override blocklist. - # Note: sub.example.com may not exist in DNS (NXDOMAIN / empty answer), - # which is fine -- we only verify it's not actively blocked (0.0.0.0). - # NOTE: negative assertion — cannot poll; may read pre-mutation state (see DNSLib.wait_until docstring) - resp = await self.dns_lib.send_doh_request(profile_id, TEST_SUBDOMAIN, A) - if resp.answer: - ip_addr = resp.answer[0].to_text().split(" ")[-1] - assert ip_addr != "0.0.0.0", ( - f"Wildcard custom allow rule did not override blocklist block for " - f"{TEST_SUBDOMAIN}; got {ip_addr}" - ) + # Query subdomain -- wildcard allow should override blocklist. + # Note: sub.example.com may not exist in DNS (NXDOMAIN / empty answer), + # which is fine -- we only verify it's not actively blocked (0.0.0.0). + # NOTE: negative assertion — cannot poll; may read pre-mutation state (see DNSLib.wait_until docstring) + resp = await user.resolve(profile_id, BLOCKLISTED_SUBDOMAIN, A) + if resp.answer: + ip_addr = resp.answer[0].to_text().split(" ")[-1] + assert ip_addr != "0.0.0.0", ( + f"Wildcard custom allow rule did not override blocklist block for " + f"{BLOCKLISTED_SUBDOMAIN}; got {ip_addr}" + ) @pytest.mark.asyncio - async def test_custom_block_on_non_blocklisted_domain( - self, create_account_and_login - ): + async def test_custom_block_on_non_blocklisted_domain(self, user): """Verify that a custom 'block' rule blocks a domain that is not in any blocklist. Setup: @@ -263,31 +140,16 @@ async def test_custom_block_on_non_blocklisted_domain( - The DNS query for facebook.com returns 0.0.0.0 (blocked by custom rule), independent of any blocklist configuration. """ - account, cookie = create_account_and_login - with client.ApiClient(self.api_config) as api_client: - profiles_instance = api.ProfileApi(api_client) - profiles_instance.api_client.default_headers["Cookie"] = cookie + profile_id = user.new_profile("test_custom_block_non_blocklisted") - profile_id = self._create_profile( - profiles_instance, "test_custom_block_non_blocklisted" - ) + # Create custom block rule for a domain not in any blocklist + user.add_rule(profile_id, "block", "facebook.com") - # Create custom block rule for a domain not in any blocklist - self._create_custom_rule( - profiles_instance, profile_id, "block", "facebook.com" - ) - - resp = await self.dns_lib.wait_until( - profile_id, "facebook.com", A, is_blocked - ) - assert resp.answer, "Expected a blocked answer for facebook.com" - ip_addr = resp.answer[0].to_text().split(" ")[-1] - assert ( - ip_addr == "0.0.0.0" - ), f"Custom block rule did not block facebook.com; got {ip_addr}" + resp = await user.wait_for(profile_id, "facebook.com", A, is_blocked) + assert_blocked(resp, "facebook.com") @pytest.mark.asyncio - async def test_default_block_rule_blocks_all(self, create_account_and_login): + async def test_default_block_rule_blocks_all(self, user): """Verify that setting default_rule to 'block' blocks all domains. Setup: @@ -297,31 +159,16 @@ async def test_default_block_rule_blocks_all(self, create_account_and_login): - Any DNS query (e.g., google.com) returns 0.0.0.0 because the default rule blocks everything. """ - account, cookie = create_account_and_login - with client.ApiClient(self.api_config) as api_client: - profiles_instance = api.ProfileApi(api_client) - profiles_instance.api_client.default_headers["Cookie"] = cookie + profile_id = user.new_profile("test_default_block_all") - profile_id = self._create_profile( - profiles_instance, "test_default_block_all" - ) - - # Set default_rule to block - self._set_default_rule(profiles_instance, profile_id, "block") + # Set default_rule to block + user.patch_setting(profile_id, "/settings/privacy/default_rule", "block") - resp = await self.dns_lib.wait_until( - profile_id, "google.com", A, is_blocked - ) - assert resp.answer, "Expected a blocked answer for google.com" - ip_addr = resp.answer[0].to_text().split(" ")[-1] - assert ( - ip_addr == "0.0.0.0" - ), f"Default block rule did not block google.com; got {ip_addr}" + resp = await user.wait_for(profile_id, "google.com", A, is_blocked) + assert_blocked(resp, "google.com") @pytest.mark.asyncio - async def test_custom_allow_overrides_default_block( - self, create_account_and_login - ): + async def test_custom_allow_overrides_default_block(self, user): """Verify that a custom 'allow' rule overrides a default_rule of 'block'. Setup: @@ -332,47 +179,25 @@ async def test_custom_allow_overrides_default_block( - The DNS query for facebook.com returns a valid IP (not 0.0.0.0) because the custom allow rule (tier 200) overrides the default block rule (tier 0). """ - account, cookie = create_account_and_login - with client.ApiClient(self.api_config) as api_client: - profiles_instance = api.ProfileApi(api_client) - profiles_instance.api_client.default_headers["Cookie"] = cookie + profile_id = user.new_profile("test_allow_overrides_default_block") - profile_id = self._create_profile( - profiles_instance, "test_allow_overrides_default_block" - ) + # Set default_rule to block + user.patch_setting(profile_id, "/settings/privacy/default_rule", "block") - # Set default_rule to block - self._set_default_rule(profiles_instance, profile_id, "block") + # Confirm facebook.com is blocked by default rule + resp_blocked = await user.wait_for(profile_id, "facebook.com", A, is_blocked) + assert_blocked(resp_blocked, "facebook.com") - # Confirm facebook.com is blocked by default rule - resp_blocked = await self.dns_lib.wait_until( - profile_id, "facebook.com", A, is_blocked - ) - ip_blocked = resp_blocked.answer[0].to_text().split(" ")[-1] - assert ( - ip_blocked == "0.0.0.0" - ), f"Expected facebook.com to be blocked by default rule, got {ip_blocked}" - - # Create custom allow rule for facebook.com - self._create_custom_rule( - profiles_instance, profile_id, "allow", "facebook.com" - ) + # Create custom allow rule for facebook.com + user.add_rule(profile_id, "allow", "facebook.com") - # Query again -- custom allow should override default block - resp = await self.dns_lib.wait_until( - profile_id, "facebook.com", A, is_resolved - ) - assert resp.answer, "Expected an answer for facebook.com" - ip_addr = resp.answer[0].to_text().split(" ")[-1] - assert ip_addr != "0.0.0.0", ( - f"Custom allow rule did not override default block for facebook.com; " - f"got {ip_addr}" - ) - assert ip_address(ip_addr), f"Expected a valid IP, got {ip_addr}" + # Query again -- custom allow should override default block + resp = await user.wait_for(profile_id, "facebook.com", A, is_resolved) + assert_not_blocked(resp, "facebook.com") @pytest.mark.asyncio async def test_blocklist_block_with_default_block( - self, create_account_and_login, ensure_test_blocklisted + self, user, ensure_test_blocklisted ): """Verify blocking when both blocklist and default_rule agree on blocking. @@ -385,52 +210,29 @@ async def test_blocklist_block_with_default_block( - The DNS query for a non-blocklisted domain (e.g., google.com) also returns 0.0.0.0 (blocked by default rule even though not in any blocklist). """ - account, cookie = create_account_and_login - with client.ApiClient(self.api_config) as api_client: - profiles_instance = api.ProfileApi(api_client) - profiles_instance.api_client.default_headers["Cookie"] = cookie + profile_id = user.new_profile("test_blocklist_and_default_block") - profile_id = self._create_profile( - profiles_instance, "test_blocklist_and_default_block" - ) + # Set default_rule to block + user.patch_setting(profile_id, "/settings/privacy/default_rule", "block") - # Set default_rule to block - self._set_default_rule(profiles_instance, profile_id, "block") + # Blocklisted domain should be blocked (both blocklist and default rule) + resp_blocklisted = await user.wait_for( + profile_id, BLOCKLISTED_DOMAIN, A, is_blocked + ) + assert_blocked(resp_blocklisted, BLOCKLISTED_DOMAIN) - # Blocklisted domain should be blocked (both blocklist and default rule) - resp_blocklisted = await self.dns_lib.wait_until( - profile_id, TEST_DOMAIN, A, is_blocked - ) - assert ( - resp_blocklisted.answer - ), f"Expected a blocked answer for blocklisted {TEST_DOMAIN}" - ip_blocklisted = resp_blocklisted.answer[0].to_text().split(" ")[-1] - assert ( - ip_blocklisted == "0.0.0.0" - ), f"Expected {TEST_DOMAIN} to be blocked, got {ip_blocklisted}" - - # Non-blocklisted domain should also be blocked (by default rule) - resp_non_blocklisted = await self.dns_lib.wait_until( - profile_id, "google.com", A, is_blocked - ) - assert ( - resp_non_blocklisted.answer - ), "Expected a blocked answer for google.com (default block rule)" - ip_non_blocklisted = ( - resp_non_blocklisted.answer[0].to_text().split(" ")[-1] - ) - assert ( - ip_non_blocklisted == "0.0.0.0" - ), f"Expected google.com to be blocked by default rule, got {ip_non_blocklisted}" + # Non-blocklisted domain should also be blocked (by default rule) + resp_non_blocklisted = await user.wait_for( + profile_id, "google.com", A, is_blocked + ) + assert_blocked(resp_non_blocklisted, "google.com") # ------------------------------------------------------------------ # Custom rule subdomain matching tests # ------------------------------------------------------------------ @pytest.mark.asyncio - async def test_exact_custom_block_does_not_block_www_subdomain( - self, create_account_and_login - ): + async def test_exact_custom_block_does_not_block_www_subdomain(self, user): """Verify that an exact custom block rule does NOT block www.. When custom_rules_subdomains_rule is set to "exact", a rule for @@ -440,129 +242,63 @@ async def test_exact_custom_block_does_not_block_www_subdomain( Wildcards (*.facebook.com or .facebook.com) are required to also cover subdomains when using exact mode. """ - account, cookie = create_account_and_login - with client.ApiClient(self.api_config) as api_client: - profiles_instance = api.ProfileApi(api_client) - profiles_instance.api_client.default_headers["Cookie"] = cookie + profile_id = user.new_profile("test_exact_block_no_www") - profile_id = self._create_profile( - profiles_instance, "test_exact_block_no_www" - ) + # Set custom_rules_subdomains_rule to "exact" so plain domains are not auto-expanded + user.patch_setting( + profile_id, "/settings/privacy/custom_rules_subdomains_rule", "exact" + ) - # Set custom_rules_subdomains_rule to "exact" so plain domains are not auto-expanded - self._set_custom_rules_subdomains_rule(profiles_instance, profile_id, "exact") + # Create exact block rule for facebook.com + user.add_rule(profile_id, "block", "facebook.com") - # Create exact block rule for facebook.com - self._create_custom_rule( - profiles_instance, profile_id, "block", "facebook.com" - ) + # facebook.com itself should be blocked + resp_exact = await user.wait_for(profile_id, "facebook.com", A, is_blocked) + assert_blocked(resp_exact, "facebook.com") - # facebook.com itself should be blocked - resp_exact = await self.dns_lib.wait_until( - profile_id, "facebook.com", A, is_blocked - ) - assert resp_exact.answer, "Expected a blocked answer for facebook.com" - ip_exact = resp_exact.answer[0].to_text().split(" ")[-1] - assert ( - ip_exact == "0.0.0.0" - ), f"Exact custom block rule did not block facebook.com; got {ip_exact}" - - # www.facebook.com should NOT be blocked (exact match only) - resp_www = await self.dns_lib.send_doh_request( - profile_id, "www.facebook.com", A - ) - assert resp_www.answer, "Expected an answer for www.facebook.com" - ip_www = resp_www.answer[0].to_text().split(" ")[-1] - assert ip_www != "0.0.0.0", ( - f"Exact custom block rule for facebook.com should NOT block " - f"www.facebook.com; got {ip_www}" - ) + # www.facebook.com should NOT be blocked (exact match only) + resp_www = await user.resolve(profile_id, "www.facebook.com", A) + assert_not_blocked(resp_www, "www.facebook.com") @pytest.mark.asyncio - async def test_wildcard_custom_block_blocks_www_subdomain( - self, create_account_and_login - ): + async def test_wildcard_custom_block_blocks_www_subdomain(self, user): """Verify that a wildcard custom block rule *.facebook.com blocks www.facebook.com. Unlike exact rules, the "*.facebook.com" pattern matches the root domain AND all subdomains (www.facebook.com, ads.facebook.com, etc.). """ - account, cookie = create_account_and_login - with client.ApiClient(self.api_config) as api_client: - profiles_instance = api.ProfileApi(api_client) - profiles_instance.api_client.default_headers["Cookie"] = cookie + profile_id = user.new_profile("test_wildcard_block_www") - profile_id = self._create_profile( - profiles_instance, "test_wildcard_block_www" - ) + # Create wildcard block rule + user.add_rule(profile_id, "block", "*.facebook.com") - # Create wildcard block rule - self._create_custom_rule( - profiles_instance, profile_id, "block", "*.facebook.com" - ) + # facebook.com itself should be blocked + resp_root = await user.wait_for(profile_id, "facebook.com", A, is_blocked) + assert_blocked(resp_root, "facebook.com") - # facebook.com itself should be blocked - resp_root = await self.dns_lib.wait_until( - profile_id, "facebook.com", A, is_blocked - ) - assert resp_root.answer, "Expected a blocked answer for facebook.com" - ip_root = resp_root.answer[0].to_text().split(" ")[-1] - assert ( - ip_root == "0.0.0.0" - ), f"Wildcard block rule did not block facebook.com; got {ip_root}" - - # www.facebook.com should also be blocked - resp_www = await self.dns_lib.send_doh_request( - profile_id, "www.facebook.com", A - ) - assert resp_www.answer, "Expected a blocked answer for www.facebook.com" - ip_www = resp_www.answer[0].to_text().split(" ")[-1] - assert ( - ip_www == "0.0.0.0" - ), f"Wildcard block rule did not block www.facebook.com; got {ip_www}" + # www.facebook.com should also be blocked + resp_www = await user.resolve(profile_id, "www.facebook.com", A) + assert_blocked(resp_www, "www.facebook.com") @pytest.mark.asyncio - async def test_dot_prefix_custom_block_blocks_www_subdomain( - self, create_account_and_login - ): + async def test_dot_prefix_custom_block_blocks_www_subdomain(self, user): """Verify that the dot-prefix syntax .facebook.com blocks www.facebook.com. The ".facebook.com" syntax is equivalent to "*.facebook.com" -- it blocks the root domain and all subdomains. """ - account, cookie = create_account_and_login - with client.ApiClient(self.api_config) as api_client: - profiles_instance = api.ProfileApi(api_client) - profiles_instance.api_client.default_headers["Cookie"] = cookie + profile_id = user.new_profile("test_dot_prefix_block_www") - profile_id = self._create_profile( - profiles_instance, "test_dot_prefix_block_www" - ) + # Create dot-prefix block rule + user.add_rule(profile_id, "block", ".facebook.com") - # Create dot-prefix block rule - self._create_custom_rule( - profiles_instance, profile_id, "block", ".facebook.com" - ) + # facebook.com itself should be blocked + resp_root = await user.wait_for(profile_id, "facebook.com", A, is_blocked) + assert_blocked(resp_root, "facebook.com") - # facebook.com itself should be blocked - resp_root = await self.dns_lib.wait_until( - profile_id, "facebook.com", A, is_blocked - ) - assert resp_root.answer, "Expected a blocked answer for facebook.com" - ip_root = resp_root.answer[0].to_text().split(" ")[-1] - assert ( - ip_root == "0.0.0.0" - ), f"Dot-prefix block rule did not block facebook.com; got {ip_root}" - - # www.facebook.com should also be blocked - resp_www = await self.dns_lib.send_doh_request( - profile_id, "www.facebook.com", A - ) - assert resp_www.answer, "Expected a blocked answer for www.facebook.com" - ip_www = resp_www.answer[0].to_text().split(" ")[-1] - assert ( - ip_www == "0.0.0.0" - ), f"Dot-prefix block rule did not block www.facebook.com; got {ip_www}" + # www.facebook.com should also be blocked + resp_www = await user.resolve(profile_id, "www.facebook.com", A) + assert_blocked(resp_www, "www.facebook.com") @pytest.mark.asyncio @pytest.mark.parametrize( @@ -585,7 +321,7 @@ async def test_dot_prefix_custom_block_blocks_www_subdomain( ], ) async def test_custom_block_subdomain_matching_matrix( - self, create_account_and_login, pattern, subdomain, expect_blocked + self, user, pattern, subdomain, expect_blocked ): """Parametrized matrix: which custom rule patterns block which subdomains. @@ -595,139 +331,75 @@ async def test_custom_block_subdomain_matching_matrix( wildcard ("*.facebook.com") and dot-prefix (".facebook.com") block the root domain and all subdomains. """ - account, cookie = create_account_and_login - with client.ApiClient(self.api_config) as api_client: - profiles_instance = api.ProfileApi(api_client) - profiles_instance.api_client.default_headers["Cookie"] = cookie + profile_id = user.new_profile(f"test_matrix_{pattern}_{subdomain}") - profile_id = self._create_profile( - profiles_instance, f"test_matrix_{pattern}_{subdomain}" - ) + # Use "exact" mode so pattern matching is tested without auto-prepend + user.patch_setting( + profile_id, "/settings/privacy/custom_rules_subdomains_rule", "exact" + ) - # Use "exact" mode so pattern matching is tested without auto-prepend - self._set_custom_rules_subdomains_rule(profiles_instance, profile_id, "exact") + user.add_rule(profile_id, "block", pattern) - self._create_custom_rule( - profiles_instance, profile_id, "block", pattern - ) + if expect_blocked: + resp = await user.wait_for(profile_id, subdomain, A, is_blocked) + else: + # NOTE: negative assertion — cannot poll; may read pre-mutation state (see DNSLib.wait_until docstring) + resp = await user.resolve(profile_id, subdomain, A) - if expect_blocked: - resp = await self.dns_lib.wait_until( - profile_id, subdomain, A, is_blocked - ) - else: - # NOTE: negative assertion — cannot poll; may read pre-mutation state (see DNSLib.wait_until docstring) - resp = await self.dns_lib.send_doh_request(profile_id, subdomain, A) - - if expect_blocked: - assert resp.answer, f"Expected a blocked answer for {subdomain}" - ip_addr = resp.answer[0].to_text().split(" ")[-1] - assert ip_addr == "0.0.0.0", ( - f"Pattern '{pattern}' should block {subdomain}; got {ip_addr}" - ) - else: - assert resp.answer, f"Expected an answer for {subdomain}" - ip_addr = resp.answer[0].to_text().split(" ")[-1] - assert ip_addr != "0.0.0.0", ( - f"Pattern '{pattern}' should NOT block {subdomain}; got {ip_addr}" - ) + if expect_blocked: + assert_blocked(resp, subdomain) + else: + assert_not_blocked(resp, subdomain) # ------------------------------------------------------------------ # custom_rules_subdomains_rule setting tests # ------------------------------------------------------------------ @pytest.mark.asyncio - async def test_include_mode_auto_prepends_wildcard( - self, create_account_and_login - ): + async def test_include_mode_auto_prepends_wildcard(self, user): """Verify that "include" mode (default) auto-expands plain domains to block subdomains. When custom_rules_subdomains_rule is "include", adding "facebook.com" should store "*.facebook.com" and therefore block www.facebook.com. """ - account, cookie = create_account_and_login - with client.ApiClient(self.api_config) as api_client: - profiles_instance = api.ProfileApi(api_client) - profiles_instance.api_client.default_headers["Cookie"] = cookie + profile_id = user.new_profile("test_include_mode_auto_prepend") - profile_id = self._create_profile( - profiles_instance, "test_include_mode_auto_prepend" - ) + # Default is "include" -- no need to explicitly set it + user.add_rule(profile_id, "block", "facebook.com") - # Default is "include" -- no need to explicitly set it - self._create_custom_rule( - profiles_instance, profile_id, "block", "facebook.com" - ) + # facebook.com itself should be blocked + resp_root = await user.wait_for(profile_id, "facebook.com", A, is_blocked) + assert_blocked(resp_root, "facebook.com") - # facebook.com itself should be blocked - resp_root = await self.dns_lib.wait_until( - profile_id, "facebook.com", A, is_blocked - ) - assert resp_root.answer, "Expected a blocked answer for facebook.com" - ip_root = resp_root.answer[0].to_text().split(" ")[-1] - assert ( - ip_root == "0.0.0.0" - ), f"Include mode did not block facebook.com; got {ip_root}" - - # www.facebook.com should also be blocked (auto-prepend made it *.facebook.com) - resp_www = await self.dns_lib.send_doh_request( - profile_id, "www.facebook.com", A - ) - assert resp_www.answer, "Expected a blocked answer for www.facebook.com" - ip_www = resp_www.answer[0].to_text().split(" ")[-1] - assert ip_www == "0.0.0.0", ( - f"Include mode should block www.facebook.com via auto-prepended " - f"wildcard; got {ip_www}" - ) + # www.facebook.com should also be blocked (auto-prepend made it *.facebook.com) + resp_www = await user.resolve(profile_id, "www.facebook.com", A) + assert_blocked(resp_www, "www.facebook.com") @pytest.mark.asyncio - async def test_exact_mode_does_not_block_subdomain( - self, create_account_and_login - ): + async def test_exact_mode_does_not_block_subdomain(self, user): """Verify that "exact" mode stores plain domains as-is without wildcard expansion. When custom_rules_subdomains_rule is "exact", adding "facebook.com" should only block the exact domain, not www.facebook.com. """ - account, cookie = create_account_and_login - with client.ApiClient(self.api_config) as api_client: - profiles_instance = api.ProfileApi(api_client) - profiles_instance.api_client.default_headers["Cookie"] = cookie + profile_id = user.new_profile("test_exact_mode_no_subdomain") - profile_id = self._create_profile( - profiles_instance, "test_exact_mode_no_subdomain" - ) + user.patch_setting( + profile_id, "/settings/privacy/custom_rules_subdomains_rule", "exact" + ) - self._set_custom_rules_subdomains_rule(profiles_instance, profile_id, "exact") + user.add_rule(profile_id, "block", "facebook.com") - self._create_custom_rule( - profiles_instance, profile_id, "block", "facebook.com" - ) + # facebook.com itself should be blocked + resp_root = await user.wait_for(profile_id, "facebook.com", A, is_blocked) + assert_blocked(resp_root, "facebook.com") - # facebook.com itself should be blocked - resp_root = await self.dns_lib.wait_until( - profile_id, "facebook.com", A, is_blocked - ) - assert resp_root.answer, "Expected a blocked answer for facebook.com" - ip_root = resp_root.answer[0].to_text().split(" ")[-1] - assert ( - ip_root == "0.0.0.0" - ), f"Exact mode did not block facebook.com; got {ip_root}" - - # www.facebook.com should NOT be blocked (exact match only) - resp_www = await self.dns_lib.send_doh_request( - profile_id, "www.facebook.com", A - ) - assert resp_www.answer, "Expected an answer for www.facebook.com" - ip_www = resp_www.answer[0].to_text().split(" ")[-1] - assert ip_www != "0.0.0.0", ( - f"Exact mode should NOT block www.facebook.com; got {ip_www}" - ) + # www.facebook.com should NOT be blocked (exact match only) + resp_www = await user.resolve(profile_id, "www.facebook.com", A) + assert_not_blocked(resp_www, "www.facebook.com") @pytest.mark.asyncio - async def test_custom_rules_subdomains_rule_setting_patch( - self, create_account_and_login - ): + async def test_custom_rules_subdomains_rule_setting_patch(self, user): """Verify that the custom_rules_subdomains_rule setting can be toggled via PATCH API. Steps: @@ -738,40 +410,28 @@ async def test_custom_rules_subdomains_rule_setting_patch( 5. PATCH back to "include" 6. Verify the setting is "include" via GET """ - account, cookie = create_account_and_login - with client.ApiClient(self.api_config) as api_client: - profiles_instance = api.ProfileApi(api_client) - profiles_instance.api_client.default_headers["Cookie"] = cookie + profile_id = user.new_profile("test_setting_patch") - profile_id = self._create_profile( - profiles_instance, "test_setting_patch" - ) + # Step 1: Verify default is "include" + profile = user.get_profile(profile_id) + assert ( + profile.settings.privacy.custom_rules_subdomains_rule == "include" + ), "Default custom_rules_subdomains_rule should be 'include'" - # Step 1: Verify default is "include" - resp = profiles_instance.api_v1_profiles_id_get_with_http_info( - id=profile_id - ) - assert resp.status_code == 200 - assert ( - resp.data.settings.privacy.custom_rules_subdomains_rule == "include" - ), "Default custom_rules_subdomains_rule should be 'include'" - - # Step 2: PATCH to "exact" - self._set_custom_rules_subdomains_rule(profiles_instance, profile_id, "exact") - resp = profiles_instance.api_v1_profiles_id_get_with_http_info( - id=profile_id - ) - assert resp.status_code == 200 - assert ( - resp.data.settings.privacy.custom_rules_subdomains_rule == "exact" - ), "custom_rules_subdomains_rule should be 'exact' after PATCH" - - # Step 3: PATCH back to "include" - self._set_custom_rules_subdomains_rule(profiles_instance, profile_id, "include") - resp = profiles_instance.api_v1_profiles_id_get_with_http_info( - id=profile_id - ) - assert resp.status_code == 200 - assert ( - resp.data.settings.privacy.custom_rules_subdomains_rule == "include" - ), "custom_rules_subdomains_rule should be 'include' after toggling back" + # Step 2: PATCH to "exact" + user.patch_setting( + profile_id, "/settings/privacy/custom_rules_subdomains_rule", "exact" + ) + profile = user.get_profile(profile_id) + assert ( + profile.settings.privacy.custom_rules_subdomains_rule == "exact" + ), "custom_rules_subdomains_rule should be 'exact' after PATCH" + + # Step 3: PATCH back to "include" + user.patch_setting( + profile_id, "/settings/privacy/custom_rules_subdomains_rule", "include" + ) + profile = user.get_profile(profile_id) + assert ( + profile.settings.privacy.custom_rules_subdomains_rule == "include" + ), "custom_rules_subdomains_rule should be 'include' after toggling back" diff --git a/tests/dns_tests/test_dnssec.py b/tests/dns_tests/test_dnssec.py index 922c6d89..434fe13c 100644 --- a/tests/dns_tests/test_dnssec.py +++ b/tests/dns_tests/test_dnssec.py @@ -1,50 +1,34 @@ from ipaddress import ip_address import pytest -from libs.dns_lib import DNSLib, is_resolved -from libs.settings import get_settings +from libs.dns_lib import is_resolved +from libs.session import ProfileSession from dns.rdataclass import IN from dns.rdatatype import A, RRSIG from dns.flags import AD, CD, DO from dns.rcode import NOERROR, SERVFAIL -from conftest import create_acc_and_login_func -import moddns.api_client as client -import moddns.api as api -import moddns.configuration as api_config -from moddns import RequestsProfileUpdates, ModelProfileUpdate - class TestDNSSEC: - def setup_class(self): - """Setup the test class.""" - self.config = get_settings() - self.api_config = api_config.Configuration(host=self.config.DNS_API_ADDR) - self.dns_lib = DNSLib(self.config.DOH_ENDPOINT) - @pytest.mark.asyncio - async def test_valid_dnssec_answer(self, create_account_and_login): + async def test_valid_dnssec_answer(self, user): """ Create account, then: 1. Send query to properly DNSSEC-configured domain and make sure the DNS response does not contain DNSSEC validation results (DO bit is not send, therefore end device won't get RRSIG query entries). 2. Enable DO bit sending, then send query to properly DNSSEC-configured domain and make sure the DNS response does contain DNSSEC validation results (DO bit is sent, therefore end device will get RRSIG query entries). """ - account, cookie = create_account_and_login - profile_id = account.profiles[0] + profile_id = user.default_profile_id - with client.ApiClient(self.api_config) as api_client: - profiles_instance = api.ProfileApi(api_client) - profiles_instance.api_client.default_headers["Cookie"] = cookie - profile = profiles_instance.api_v1_profiles_id_get(profile_id) - assert ( - profile.settings.security.dnssec.enabled - ), "DNSSEC validation should be enabled by default for new profiles" - # Make sure DO bit is disabled by default for new profiles - assert ( - not profile.settings.security.dnssec.send_do_bit - ), "DO bit is enabled by default for new profiles but should be disabled" + profile = user.get_profile(profile_id) + assert ( + profile.settings.security.dnssec.enabled + ), "DNSSEC validation should be enabled by default for new profiles" + # Make sure DO bit is disabled by default for new profiles + assert ( + not profile.settings.security.dnssec.send_do_bit + ), "DO bit is enabled by default for new profiles but should be disabled" - resp = await self.dns_lib.wait_until(profile_id, "example.com", "A", is_resolved) + resp = await user.wait_for(profile_id, "example.com", "A", is_resolved) assert ( len(resp.answer) == 1 ) # 1 answers since DNSSEC is configured on example.com @@ -54,30 +38,9 @@ async def test_valid_dnssec_answer(self, create_account_and_login): ipv4_addr = resp.answer[0].to_text().split(" ")[-1] assert ip_address(ipv4_addr) != ip_address("0.0.0.0") - with client.ApiClient(self.api_config) as api_client: - profiles_instance = api.ProfileApi(api_client) - - # Create request body to disable DNSSEC - update_request = RequestsProfileUpdates( - updates=[ - ModelProfileUpdate( - operation="replace", - path="/settings/security/dnssec/send_do_bit", - value={ - "value": True - }, # Dict[string, Any] is a openapi-cli-gen limitation - 'interface{}' Go type is transformed to Dict[string, Any] in the generated code - ) - ] - ) - profiles_instance.api_client.default_headers["Cookie"] = cookie - resp = profiles_instance.api_v1_profiles_id_patch_with_http_info( - account.profiles[0], body=update_request - ) - assert ( - resp.status_code == 200 - ), f"Profile DNSSEC settings update failed with status code: {resp.status_code} and payload {resp.data}" + user.patch_setting(profile_id, "/settings/security/dnssec/send_do_bit", True) - resp = await self.dns_lib.wait_until( + resp = await user.wait_for( profile_id, "example.com", "A", lambda r: len(r.answer) == 2 ) assert ( @@ -96,15 +59,14 @@ async def test_valid_dnssec_answer(self, create_account_and_login): assert ip_address(ipv4_addr) != ip_address("0.0.0.0") @pytest.mark.asyncio - async def test_invalid_dnssec_answer(self, create_account_and_login): + async def test_invalid_dnssec_answer(self, user): """ Create account, send query to improperly DNSSEC-configured domain and make sure the DNS response contains DNSSEC validation results. """ - account, _ = create_account_and_login - assert len(account.profiles) == 1 + assert len(user.account.profiles) == 1 - profile_id = account.profiles[0] - resp = await self.dns_lib.wait_until( + profile_id = user.default_profile_id + resp = await user.wait_for( profile_id, "dnssec-failed.org", "A", lambda r: r.rcode() == SERVFAIL ) assert ( @@ -133,13 +95,11 @@ async def test_answer_no_dnssec(self, test_domain, expected_results): """ Create account, disable DNSSEC validation, send query to DNSSEC-configured domain and make sure the DNS response does not contain DNSSEC validation results (DO bit is not sent). """ - account, cookie, _ = create_acc_and_login_func() - profile_id = account.profiles[0] + session = ProfileSession.create() + try: + profile_id = session.default_profile_id - with client.ApiClient(self.api_config) as api_client: - profiles_instance = api.ProfileApi(api_client) - profiles_instance.api_client.default_headers["Cookie"] = cookie - profile = profiles_instance.api_v1_profiles_id_get(profile_id) + profile = session.get_profile(profile_id) assert ( profile.settings.security.dnssec.enabled ), "DNSSEC validation should be enabled by default for new profiles" @@ -148,29 +108,11 @@ async def test_answer_no_dnssec(self, test_domain, expected_results): not profile.settings.security.dnssec.send_do_bit ), "DO bit is enabled by default for new profiles but should be disabled" - with client.ApiClient(self.api_config) as api_client: - profiles_instance = api.ProfileApi(api_client) - - # Create request body to disable DNSSEC - update_request = RequestsProfileUpdates( - updates=[ - ModelProfileUpdate( - operation="replace", - path="/settings/security/dnssec/enabled", - value={ - "value": False - }, # Dict[string, Any] is a openapi-cli-gen limitation - 'interface{}' Go type is transformed to Dict[string, Any] in the generated code - ) - ] - ) - profiles_instance.api_client.default_headers["Cookie"] = cookie - resp = profiles_instance.api_v1_profiles_id_patch_with_http_info( - profile_id, body=update_request + session.patch_setting( + profile_id, "/settings/security/dnssec/enabled", False ) - assert ( - resp.status_code == 200 - ), f"Profile DNSSEC settings update failed with status code: {resp.status_code} and payload {resp.data}" - resp = await self.dns_lib.wait_until( + + resp = await session.wait_for( profile_id, test_domain, "A", lambda r: r.flags & CD ) assert len(resp.answer) == expected_results["resp_length"] @@ -186,3 +128,5 @@ async def test_answer_no_dnssec(self, test_domain, expected_results): ), "AD (Authenticated Data) flag is set in the response but should not be" ipv4_addr = resp.answer[0].to_text().split(" ")[-1] assert ip_address(ipv4_addr) != ip_address("0.0.0.0") + finally: + session.cleanup() diff --git a/tests/dns_tests/test_ip_custom_rules.py b/tests/dns_tests/test_ip_custom_rules.py index 4994ebc0..83a7121f 100644 --- a/tests/dns_tests/test_ip_custom_rules.py +++ b/tests/dns_tests/test_ip_custom_rules.py @@ -12,230 +12,118 @@ and are assumed stable for the CI environment. """ -from ipaddress import ip_address - import pytest -from libs.dns_lib import DNSLib, is_blocked -from libs.settings import get_settings from dns.rdatatype import A, AAAA -import moddns.api_client as client -import moddns.api as api -import moddns.configuration as api_config -from moddns import ( - RequestsCreateProfileCustomRuleBody, - ApiCreateProfileBody, -) - -# Known IPs that the test domains resolve to via sdns. -TEST_IPV4 = "104.18.74.230" -TEST_IPV4_DOMAIN = "test.com" +from libs.constants import RESOLVABLE_TEST_DOMAIN, RESOLVABLE_TEST_IP +from libs.dns_lib import assert_blocked, assert_not_blocked, is_blocked + +# Known IPv6 target the test domain resolves to via sdns. TEST_IPV6 = "2001:41d0:701:1100::29c8" TEST_IPV6_DOMAIN = "ipv6-test.com" # RFC 5737 TEST-NET address — guaranteed to not appear in any real DNS response. NONEXISTENT_IPV4 = "192.0.2.1" # Pinned to 8.8.8.8 in config/testhosts.txt — resolves deterministically and -# shares no IP with TEST_IPV4_DOMAIN, so "unrelated domain" tests need no live DNS. +# shares no IP with RESOLVABLE_TEST_DOMAIN, so "unrelated domain" tests need no +# live DNS. UNRELATED_PINNED_DOMAIN = "svctest-google.com" class TestIPCustomRules: """Dedicated test suite for IP-based custom rule filtering.""" - def setup_class(self): - self.config = get_settings() - self.api_config = api_config.Configuration(host=self.config.DNS_API_ADDR) - self.dns_lib = DNSLib(self.config.DOH_ENDPOINT) - - def _create_profile(self, profiles_instance, name): - body = ApiCreateProfileBody(name=name) - resp = profiles_instance.api_v1_profiles_post_with_http_info(body=body) - assert resp.status_code == 201, ( - f"Profile creation failed with status code: {resp.status_code}" - ) - return resp.data.profile_id - - def _create_custom_rule(self, profiles_instance, profile_id, action, value): - body = RequestsCreateProfileCustomRuleBody(action=action, value=value) - resp = profiles_instance.api_v1_profiles_id_custom_rules_post_with_http_info( - id=profile_id, body=body - ) - assert resp.status_code == 201, ( - f"Custom rule creation failed for {value} with status code: {resp.status_code}" - ) - return resp - # ------------------------------------------------------------------ # IPv4 block # ------------------------------------------------------------------ @pytest.mark.asyncio - async def test_block_matching_ipv4(self, create_account_and_login): + async def test_block_matching_ipv4(self, user): """An IP block rule for an IPv4 that appears in the A response should cause the proxy to return 0.0.0.0.""" - account, cookie = create_account_and_login - with client.ApiClient(self.api_config) as api_client: - p = api.ProfileApi(api_client) - p.api_client.default_headers["Cookie"] = cookie - profile_id = self._create_profile(p, "ip_block_ipv4") - - self._create_custom_rule(p, profile_id, "block", TEST_IPV4) - - resp = await self.dns_lib.wait_until( - profile_id, TEST_IPV4_DOMAIN, A, is_blocked - ) - assert resp.answer, f"Expected a blocked answer for {TEST_IPV4_DOMAIN}" - ip_addr = resp.answer[0].to_text().split(" ")[-1] - assert ip_addr == "0.0.0.0", ( - f"IP block rule for {TEST_IPV4} did not block {TEST_IPV4_DOMAIN}; " - f"got {ip_addr}" - ) + profile_id = user.new_profile("ip_block_ipv4") + user.add_rule(profile_id, "block", RESOLVABLE_TEST_IP) + + resp = await user.wait_for(profile_id, RESOLVABLE_TEST_DOMAIN, A, is_blocked) + assert_blocked(resp, RESOLVABLE_TEST_DOMAIN) # ------------------------------------------------------------------ # IPv6 block # ------------------------------------------------------------------ @pytest.mark.asyncio - async def test_block_matching_ipv6(self, create_account_and_login): + async def test_block_matching_ipv6(self, user): """An IP block rule for an IPv6 that appears in the AAAA response should cause the proxy to return ::.""" - account, cookie = create_account_and_login - with client.ApiClient(self.api_config) as api_client: - p = api.ProfileApi(api_client) - p.api_client.default_headers["Cookie"] = cookie - profile_id = self._create_profile(p, "ip_block_ipv6") - - self._create_custom_rule(p, profile_id, "block", TEST_IPV6) - - resp = await self.dns_lib.wait_until( - profile_id, TEST_IPV6_DOMAIN, AAAA, is_blocked - ) - assert resp.answer, f"Expected a blocked answer for {TEST_IPV6_DOMAIN}" - ip_addr = resp.answer[0].to_text().split(" ")[-1] - assert ip_address(ip_addr) == ip_address("::"), ( - f"IP block rule for {TEST_IPV6} did not block {TEST_IPV6_DOMAIN}; " - f"got {ip_addr}" - ) + profile_id = user.new_profile("ip_block_ipv6") + user.add_rule(profile_id, "block", TEST_IPV6) + + resp = await user.wait_for(profile_id, TEST_IPV6_DOMAIN, AAAA, is_blocked) + assert_blocked(resp, TEST_IPV6_DOMAIN) # ------------------------------------------------------------------ # Non-matching IP block (should NOT block) # ------------------------------------------------------------------ @pytest.mark.asyncio - async def test_block_nonmatching_ip_does_not_block( - self, create_account_and_login - ): + async def test_block_nonmatching_ip_does_not_block(self, user): """An IP block rule for an address that does NOT appear in the DNS response must not interfere with normal resolution.""" - account, cookie = create_account_and_login - with client.ApiClient(self.api_config) as api_client: - p = api.ProfileApi(api_client) - p.api_client.default_headers["Cookie"] = cookie - profile_id = self._create_profile(p, "ip_block_nonmatch") - - # Block an IP from TEST-NET that no real domain resolves to. - self._create_custom_rule(p, profile_id, "block", NONEXISTENT_IPV4) - - # NOTE: negative assertion — cannot poll; may read pre-mutation state (see DNSLib.wait_until docstring) - resp = await self.dns_lib.send_doh_request( - profile_id, TEST_IPV4_DOMAIN, A - ) - assert resp.answer, f"Expected an answer for {TEST_IPV4_DOMAIN}" - ip_addr = resp.answer[0].to_text().split(" ")[-1] - assert ip_addr != "0.0.0.0", ( - f"Non-matching IP block rule for {NONEXISTENT_IPV4} should not " - f"block {TEST_IPV4_DOMAIN}; got {ip_addr}" - ) - assert ip_address(ip_addr), f"Expected a valid IP, got {ip_addr}" + profile_id = user.new_profile("ip_block_nonmatch") + # Block an IP from TEST-NET that no real domain resolves to. + user.add_rule(profile_id, "block", NONEXISTENT_IPV4) + + # NOTE: negative assertion — cannot poll; may read pre-mutation state (see DNSLib.wait_until docstring) + resp = await user.resolve(profile_id, RESOLVABLE_TEST_DOMAIN, A) + assert_not_blocked(resp, RESOLVABLE_TEST_DOMAIN) # ------------------------------------------------------------------ # IP block does not affect unrelated domains # ------------------------------------------------------------------ @pytest.mark.asyncio - async def test_ip_block_does_not_affect_unrelated_domain( - self, create_account_and_login - ): + async def test_ip_block_does_not_affect_unrelated_domain(self, user): """Blocking an IP that test.com resolves to must not block an unrelated pinned domain that resolves to a different IP.""" - account, cookie = create_account_and_login - with client.ApiClient(self.api_config) as api_client: - p = api.ProfileApi(api_client) - p.api_client.default_headers["Cookie"] = cookie - profile_id = self._create_profile(p, "ip_block_unrelated") - - self._create_custom_rule(p, profile_id, "block", TEST_IPV4) - - # NOTE: negative assertion — cannot poll; may read pre-mutation state (see DNSLib.wait_until docstring) - resp = await self.dns_lib.send_doh_request( - profile_id, UNRELATED_PINNED_DOMAIN, A - ) - assert resp.answer, f"Expected an answer for {UNRELATED_PINNED_DOMAIN}" - ip_addr = resp.answer[0].to_text().split(" ")[-1] - assert ip_addr != "0.0.0.0", ( - f"IP block rule for {TEST_IPV4} should not block " - f"{UNRELATED_PINNED_DOMAIN}; got {ip_addr}" - ) + profile_id = user.new_profile("ip_block_unrelated") + user.add_rule(profile_id, "block", RESOLVABLE_TEST_IP) + + # NOTE: negative assertion — cannot poll; may read pre-mutation state (see DNSLib.wait_until docstring) + resp = await user.resolve(profile_id, UNRELATED_PINNED_DOMAIN, A) + assert_not_blocked(resp, UNRELATED_PINNED_DOMAIN) # ------------------------------------------------------------------ # IPv4 allow (should not block) # ------------------------------------------------------------------ @pytest.mark.asyncio - async def test_allow_matching_ipv4(self, create_account_and_login): + async def test_allow_matching_ipv4(self, user): """An IP allow rule for an IPv4 that appears in the A response should let the domain resolve normally (not 0.0.0.0).""" - account, cookie = create_account_and_login - with client.ApiClient(self.api_config) as api_client: - p = api.ProfileApi(api_client) - p.api_client.default_headers["Cookie"] = cookie - profile_id = self._create_profile(p, "ip_allow_ipv4") - - self._create_custom_rule(p, profile_id, "allow", TEST_IPV4) - - # NOTE: negative assertion — cannot poll; may read pre-mutation state (see DNSLib.wait_until docstring) - resp = await self.dns_lib.send_doh_request( - profile_id, TEST_IPV4_DOMAIN, A - ) - assert resp.answer, f"Expected an answer for {TEST_IPV4_DOMAIN}" - ip_addr = resp.answer[0].to_text().split(" ")[-1] - assert ip_addr != "0.0.0.0", ( - f"IP allow rule for {TEST_IPV4} should not block {TEST_IPV4_DOMAIN}; " - f"got {ip_addr}" - ) + profile_id = user.new_profile("ip_allow_ipv4") + user.add_rule(profile_id, "allow", RESOLVABLE_TEST_IP) + + # NOTE: negative assertion — cannot poll; may read pre-mutation state (see DNSLib.wait_until docstring) + resp = await user.resolve(profile_id, RESOLVABLE_TEST_DOMAIN, A) + assert_not_blocked(resp, RESOLVABLE_TEST_DOMAIN) # ------------------------------------------------------------------ # Domain allow + IP block — allow wins (unified cross-phase aggregation) # ------------------------------------------------------------------ @pytest.mark.asyncio - async def test_domain_allow_overrides_ip_block( - self, create_account_and_login - ): + async def test_domain_allow_overrides_ip_block(self, user): """When a domain allow rule and an IP block rule both match, the domain allow wins through unified cross-phase aggregation. Domain Allow (T200) overrides IP custom block (T200) — any Allow present wins. Behaviour table #9. """ - account, cookie = create_account_and_login - with client.ApiClient(self.api_config) as api_client: - p = api.ProfileApi(api_client) - p.api_client.default_headers["Cookie"] = cookie - profile_id = self._create_profile(p, "domain_allow_ip_block") - - # Allow the domain explicitly. - self._create_custom_rule(p, profile_id, "allow", TEST_IPV4_DOMAIN) - # Block the IP it resolves to. - self._create_custom_rule(p, profile_id, "block", TEST_IPV4) - - # NOTE: negative assertion — cannot poll; may read pre-mutation state (see DNSLib.wait_until docstring) - resp = await self.dns_lib.send_doh_request( - profile_id, TEST_IPV4_DOMAIN, A - ) - assert resp.answer, f"Expected an answer for {TEST_IPV4_DOMAIN}" - ip_addr = resp.answer[0].to_text().split(" ")[-1] - assert ip_addr != "0.0.0.0", ( - f"Domain allow should override IP block for {TEST_IPV4_DOMAIN}; " - f"got {ip_addr}" - ) + profile_id = user.new_profile("domain_allow_ip_block") + # Allow the domain explicitly. + user.add_rule(profile_id, "allow", RESOLVABLE_TEST_DOMAIN) + # Block the IP it resolves to. + user.add_rule(profile_id, "block", RESOLVABLE_TEST_IP) + + # NOTE: negative assertion — cannot poll; may read pre-mutation state (see DNSLib.wait_until docstring) + resp = await user.resolve(profile_id, RESOLVABLE_TEST_DOMAIN, A) + assert_not_blocked(resp, RESOLVABLE_TEST_DOMAIN) diff --git a/tests/dns_tests/test_multiple_users.py b/tests/dns_tests/test_multiple_users.py index 71e518cd..84c2b742 100644 --- a/tests/dns_tests/test_multiple_users.py +++ b/tests/dns_tests/test_multiple_users.py @@ -1,32 +1,18 @@ import asyncio from ipaddress import ip_address from collections import namedtuple -import random -import string + import pytest from dns.rdataclass import IN from dns.rdatatype import A -from libs.dns_lib import DNSLib -from libs.settings import get_settings -from helpers import generate_complex_password -from moddns import RequestsLoginBody -import moddns.api_client as client -import moddns.api as api -import moddns.configuration as api_config -from conftest import create_temp_subscription +from libs.session import ProfileSession DNSRequest = namedtuple("DNSRequest", ["domain", "ipv4_answers"]) class TestMultipleUsers: - def setup_class(self): - """Setup the test class.""" - self.config = get_settings() - self.api_config = api_config.Configuration(host=self.config.DNS_API_ADDR) - self.dns_lib = DNSLib(self.config.DOH_ENDPOINT) - @pytest.mark.asyncio @pytest.mark.xfail( strict=False, @@ -37,67 +23,46 @@ async def test_multiple_temporary_accounts_sending_doh_requests(self): """ Create 4 temporary accounts to resolve some DNS requests asynchronously (make sure the answers are properly assigned to requests). """ - with client.ApiClient(self.api_config) as api_client: - api_instance = api.AccountApi(api_client) - - # Create multiple accounts with subscription markers - profiles: list[str] = [] - for idx in range(4): - subscription_id, pa_cookie = create_temp_subscription() - email = f"test{''.join(random.choice(string.digits) for i in range(5))}@ivpn.net" - password = generate_complex_password() - - # Register account (201 expected, no account object returned) - api_instance.api_client.default_headers["Cookie"] = pa_cookie - api_instance.api_v1_accounts_post( - body={ - "email": email, - "password": password, - "subid": subscription_id, - } - ) - - # Login to obtain session cookie - auth_api = api.AuthenticationApi(api_client) - login_resp = auth_api.api_v1_login_post_with_http_info( - body=RequestsLoginBody(email=email, password=password) - ) - assert login_resp.status_code == 200 - cookie = login_resp.headers.get("Set-Cookie") - assert cookie - api_instance.api_client.default_headers["Cookie"] = cookie - - # Fetch current account to obtain profile ID - account = api_instance.api_v1_accounts_current_get() - assert len(account.profiles) == 1 - profiles.append(account.profiles[0]) - - expected_results = { - profiles[0]: DNSRequest("news.ycombinator.com", ["209.216.230.207"]), - profiles[1]: DNSRequest("wp.pl", ["212.77.98.9"]), - profiles[2]: DNSRequest( - "edition.cnn.com", - ["151.101.131.5", "151.101.195.5", "151.101.3.5", "151.101.67.5"], + sessions = [ProfileSession.create() for _ in range(4)] + try: + requests = [ + (sessions[0], DNSRequest("news.ycombinator.com", ["209.216.230.207"])), + (sessions[1], DNSRequest("wp.pl", ["212.77.98.9"])), + ( + sessions[2], + DNSRequest( + "edition.cnn.com", + [ + "151.101.131.5", + "151.101.195.5", + "151.101.3.5", + "151.101.67.5", + ], + ), ), - profiles[3]: DNSRequest( - "linkedin.com", - ["13.107.42.14", "150.171.22.12", "130.211.32.14"], + ( + sessions[3], + DNSRequest( + "linkedin.com", + ["13.107.42.14", "150.171.22.12", "130.211.32.14"], + ), ), - } + ] results = await asyncio.gather( *[ - self.dns_lib.send_doh_request(profile_id, dns_request.domain, "A") - for profile_id, dns_request in expected_results.items() + session.resolve(session.default_profile_id, dns_request.domain, A) + for session, dns_request in requests ] ) - for resp, (profile_id, dns_request) in zip( - results, expected_results.items() - ): + for resp, (session, dns_request) in zip(results, requests): assert len(resp.answer) == 1 assert resp.answer[0].rdtype == A assert resp.answer[0].rdclass == IN ipv4_addr = resp.answer[0].to_text().split(" ")[-1] assert ip_address(ipv4_addr) != ip_address("0.0.0.0") assert ipv4_addr in dns_request.ipv4_answers + finally: + for session in sessions: + session.cleanup() diff --git a/tests/dns_tests/test_profile_export_import_behaviour.py b/tests/dns_tests/test_profile_export_import_behaviour.py index 5fd947e5..98302b05 100644 --- a/tests/dns_tests/test_profile_export_import_behaviour.py +++ b/tests/dns_tests/test_profile_export_import_behaviour.py @@ -27,7 +27,7 @@ make_rules, raw_export, ) -from conftest import TEST_DOMAIN +from libs.constants import BLOCKLISTED_DOMAIN from dns.rdatatype import A import moddns.api as api @@ -111,10 +111,10 @@ async def test_export_then_import_preserves_dns_filtering( new_profile_id = body["createdProfileIds"][0] assert isinstance(new_profile_id, str) and new_profile_id - resp = await self.dns_lib.send_doh_request(new_profile_id, TEST_DOMAIN, A) + resp = await self.dns_lib.send_doh_request(new_profile_id, BLOCKLISTED_DOMAIN, A) ip_addr = resp.answer[0].to_text().split(" ")[-1] assert ip_addr == "0.0.0.0", ( - f"Imported profile did not apply blocklist; {TEST_DOMAIN} -> {ip_addr}" + f"Imported profile did not apply blocklist; {BLOCKLISTED_DOMAIN} -> {ip_addr}" ) resp = await self.dns_lib.send_doh_request( diff --git a/tests/dns_tests/test_services.py b/tests/dns_tests/test_services.py index 5224f45e..06f59e3b 100644 --- a/tests/dns_tests/test_services.py +++ b/tests/dns_tests/test_services.py @@ -15,11 +15,9 @@ """ import pytest -from libs.dns_lib import DNSLib, is_blocked, is_resolved -from libs.settings import get_settings +from libs.dns_lib import is_blocked, is_resolved, assert_blocked, assert_not_blocked +from libs.constants import RESOLVABLE_TEST_DOMAIN from libs.profile_helpers import ( - ProfileHelpers, - extract_ip, services_available, SVC_GOOGLE_DOMAIN, SVC_GOOGLE_IP, @@ -31,408 +29,199 @@ SVC_MICROSOFT_ID, REAL_GOOGLE_DOMAIN, REAL_HTTPS_HINTS_DOMAIN, - TEST_DOMAIN, ) from dns.rdatatype import A, HTTPS import dns.rcode -import moddns.api_client as client -import moddns.api as api -import moddns.configuration as api_config - # =================================================================== # Services blocking (ASN-based, via catalog) +# +# Covers both the canonical service ID and its catalog *alias*: the +# alias (``google-legacy``) exercises the zero-downtime service-ID +# rename mechanism — the proxy's FindByID resolves an alias to the +# underlying service, so blocking the alias must yield exactly the same +# ASN blocking as the canonical ID. This is what keeps blocking from +# failing open while profiles are migrated off an old ID. # =================================================================== -class TestServicesBlocking(ProfileHelpers): +class TestServicesBlocking: """End-to-end tests for ASN-based services blocking.""" - def setup_class(self): - self.config = get_settings() - self.api_config = api_config.Configuration(host=self.config.DNS_API_ADDR) - self.dns_lib = DNSLib(self.config.DOH_ENDPOINT) - @pytest.mark.asyncio - async def test_services_block_by_asn(self, create_account_and_login): - """Blocking the 'google' service should cause svctest-google.com - (which resolves to 8.8.8.8, AS15169) to return 0.0.0.0. - Behaviour table #2.""" - account, cookie = create_account_and_login - with client.ApiClient(self.api_config) as api_client: - p = api.ProfileApi(api_client) - p.api_client.default_headers["Cookie"] = cookie - - if not await services_available(self.dns_lib, p, cookie): + @pytest.mark.parametrize( + "service_id, domain", + [ + pytest.param(SVC_GOOGLE_ID, SVC_GOOGLE_DOMAIN, id="google"), + pytest.param(SVC_GOOGLE_ALIAS_ID, SVC_GOOGLE_DOMAIN, id="alias"), + pytest.param( + SVC_APPLE_ID, + SVC_APPLE_DOMAIN, + marks=pytest.mark.xfail( + strict=False, + reason="Depends on apple.com resolving to Apple ASN (external DNS)", + ), + id="apple", + ), + pytest.param( + SVC_MICROSOFT_ID, + SVC_MICROSOFT_DOMAIN, + marks=pytest.mark.xfail( + strict=False, + reason="Depends on microsoft.com resolving to Microsoft ASN (external DNS)", + ), + id="microsoft", + ), + ], + ) + async def test_services_block_by_asn(self, user, service_id, domain): + """Blocking a service blocks every domain resolving into its ASN set. + Behaviour table #2. Each parametrized service resolves to an IP in the + service's ASN and must come back as the block sentinel (0.0.0.0): + + - google: svctest-google.com -> 8.8.8.8 (AS15169), pinned/deterministic. + - alias (google-legacy): a catalog *alias* of 'google'. The proxy's + FindByID resolves the alias to the underlying 'google' service, so + blocking the alias yields identical ASN blocking to the canonical ID. + - apple: apple.com -> AS714/AS6185 (live external DNS, xfail). + - microsoft: microsoft.com -> AS8068-AS8075 (live external DNS, xfail). + """ + with user.profiles_api() as p: + if not await services_available(user.dns, p, user.cookie): pytest.skip("Services/ASN blocking not available (GeoIP DB missing?)") - profile_id = self._create_profile(p, "svc_block") - self._block_service(p, profile_id, [SVC_GOOGLE_ID]) + profile_id = user.new_profile("svc_block") + user.block_services(profile_id, [service_id]) - resp = await self.dns_lib.wait_until(profile_id, SVC_GOOGLE_DOMAIN, A, is_blocked) - ip_str = extract_ip(resp) - assert ip_str == "0.0.0.0", ( - f"Services block for {SVC_GOOGLE_ID} did not block " - f"{SVC_GOOGLE_DOMAIN}; got {ip_str}" - ) + resp = await user.wait_for(profile_id, domain, A, is_blocked) + assert_blocked(resp, domain) @pytest.mark.asyncio - async def test_services_block_does_not_affect_other_asn( - self, create_account_and_login - ): - """Blocking 'google' service must NOT block test.com (Cloudflare AS13335). + @pytest.mark.parametrize( + "service_id", + [ + pytest.param(SVC_GOOGLE_ID, id="google"), + pytest.param(SVC_GOOGLE_ALIAS_ID, id="alias"), + ], + ) + async def test_services_block_does_not_affect_other_asn(self, user, service_id): + """Blocking the google service (by canonical ID or alias) must NOT + over-block: test.com (Cloudflare AS13335) stays resolvable — a + different ASN is unaffected. Behaviour table #1 (no rules matched in IP phase).""" - account, cookie = create_account_and_login - with client.ApiClient(self.api_config) as api_client: - p = api.ProfileApi(api_client) - p.api_client.default_headers["Cookie"] = cookie - - if not await services_available(self.dns_lib, p, cookie): - pytest.skip("Services/ASN blocking not available") - - profile_id = self._create_profile(p, "svc_other_asn") - self._block_service(p, profile_id, [SVC_GOOGLE_ID]) - - # NOTE: negative assertion — cannot poll; may read pre-mutation state (see DNSLib.wait_until docstring) - resp = await self.dns_lib.send_doh_request(profile_id, TEST_DOMAIN, A) - ip_str = extract_ip(resp) - assert ip_str != "0.0.0.0", ( - f"Blocking {SVC_GOOGLE_ID} should not affect {TEST_DOMAIN} " - f"(different ASN); got {ip_str}" - ) - - @pytest.mark.asyncio - async def test_services_unblock_restores_resolution(self, create_account_and_login): - """After unblocking a service, the domain should resolve normally again.""" - account, cookie = create_account_and_login - with client.ApiClient(self.api_config) as api_client: - p = api.ProfileApi(api_client) - p.api_client.default_headers["Cookie"] = cookie - - if not await services_available(self.dns_lib, p, cookie): - pytest.skip("Services/ASN blocking not available") - - profile_id = self._create_profile(p, "svc_unblock") - self._block_service(p, profile_id, [SVC_GOOGLE_ID]) - - # Verify blocked first. - resp = await self.dns_lib.wait_until(profile_id, SVC_GOOGLE_DOMAIN, A, is_blocked) - assert extract_ip(resp) == "0.0.0.0", "Expected blocked before unblock" - - # Unblock. - self._unblock_service(p, profile_id, [SVC_GOOGLE_ID]) - - resp = await self.dns_lib.wait_until(profile_id, SVC_GOOGLE_DOMAIN, A, is_resolved) - ip_str = extract_ip(resp) - assert ip_str != "0.0.0.0", ( - f"After unblocking {SVC_GOOGLE_ID}, {SVC_GOOGLE_DOMAIN} should " - f"resolve normally; got {ip_str}" - ) - - -# =================================================================== -# Services blocking via a catalog ALIAS (service-ID rename path) -# =================================================================== -class TestServicesAliasBlocking(ProfileHelpers): - """End-to-end verification that a catalog *alias* resolves to its service. - - Exercises the zero-downtime service-ID rename mechanism: the proxy's - FindByID resolves an alias (``google-legacy``) to the underlying service - (``google``), so a profile that blocks the alias must get exactly the same - ASN blocking as one that blocks the canonical ID. This is what keeps - blocking from failing open while profiles are migrated off an old ID. - - Uses the deterministic google path (svctest-google.com -> 8.8.8.8, AS15169) - and the ``aliases: [google-legacy]`` entry in the test services catalog. - """ - - def setup_class(self): - self.config = get_settings() - self.api_config = api_config.Configuration(host=self.config.DNS_API_ADDR) - self.dns_lib = DNSLib(self.config.DOH_ENDPOINT) - - @pytest.mark.asyncio - async def test_services_block_by_alias(self, create_account_and_login): - """Blocking the alias 'google-legacy' must block svctest-google.com - (AS15169) identically to blocking the canonical 'google' service.""" - account, cookie = create_account_and_login - with client.ApiClient(self.api_config) as api_client: - p = api.ProfileApi(api_client) - p.api_client.default_headers["Cookie"] = cookie - - if not await services_available(self.dns_lib, p, cookie): - pytest.skip("Services/ASN blocking not available (GeoIP DB missing?)") - - profile_id = self._create_profile(p, "svc_block_alias") - self._block_service(p, profile_id, [SVC_GOOGLE_ALIAS_ID]) - - resp = await self.dns_lib.wait_until(profile_id, SVC_GOOGLE_DOMAIN, A, is_blocked) - ip_str = extract_ip(resp) - assert ip_str == "0.0.0.0", ( - f"Alias block for {SVC_GOOGLE_ALIAS_ID} did not block " - f"{SVC_GOOGLE_DOMAIN}; alias must resolve to the " - f"{SVC_GOOGLE_ID} service. got {ip_str}" - ) - - @pytest.mark.asyncio - async def test_services_block_by_alias_does_not_affect_other_asn( - self, create_account_and_login - ): - """Blocking the alias must not over-block: test.com (AS13335) stays - resolvable, same as blocking the canonical service.""" - account, cookie = create_account_and_login - with client.ApiClient(self.api_config) as api_client: - p = api.ProfileApi(api_client) - p.api_client.default_headers["Cookie"] = cookie - - if not await services_available(self.dns_lib, p, cookie): + with user.profiles_api() as p: + if not await services_available(user.dns, p, user.cookie): pytest.skip("Services/ASN blocking not available") - profile_id = self._create_profile(p, "svc_alias_other_asn") - self._block_service(p, profile_id, [SVC_GOOGLE_ALIAS_ID]) + profile_id = user.new_profile("svc_other_asn") + user.block_services(profile_id, [service_id]) - # NOTE: negative assertion — cannot poll; may read pre-mutation state (see DNSLib.wait_until docstring) - resp = await self.dns_lib.send_doh_request(profile_id, TEST_DOMAIN, A) - ip_str = extract_ip(resp) - assert ip_str != "0.0.0.0", ( - f"Blocking alias {SVC_GOOGLE_ALIAS_ID} should not affect " - f"{TEST_DOMAIN} (different ASN); got {ip_str}" - ) + # NOTE: negative assertion — cannot poll; may read pre-mutation state (see DNSLib.wait_until docstring) + resp = await user.resolve(profile_id, RESOLVABLE_TEST_DOMAIN, A) + assert_not_blocked(resp, RESOLVABLE_TEST_DOMAIN) @pytest.mark.asyncio - async def test_services_unblock_by_alias_restores_resolution( - self, create_account_and_login - ): - """Unblocking the alias restores resolution — the alias round-trips - through enable/disable exactly like a canonical service ID.""" - account, cookie = create_account_and_login - with client.ApiClient(self.api_config) as api_client: - p = api.ProfileApi(api_client) - p.api_client.default_headers["Cookie"] = cookie - - if not await services_available(self.dns_lib, p, cookie): - pytest.skip("Services/ASN blocking not available") - - profile_id = self._create_profile(p, "svc_unblock_alias") - self._block_service(p, profile_id, [SVC_GOOGLE_ALIAS_ID]) - - resp = await self.dns_lib.wait_until(profile_id, SVC_GOOGLE_DOMAIN, A, is_blocked) - assert extract_ip(resp) == "0.0.0.0", "Expected blocked before unblock" - - self._unblock_service(p, profile_id, [SVC_GOOGLE_ALIAS_ID]) - - resp = await self.dns_lib.wait_until(profile_id, SVC_GOOGLE_DOMAIN, A, is_resolved) - ip_str = extract_ip(resp) - assert ip_str != "0.0.0.0", ( - f"After unblocking alias {SVC_GOOGLE_ALIAS_ID}, " - f"{SVC_GOOGLE_DOMAIN} should resolve normally; got {ip_str}" - ) - - -# =================================================================== -# Apple services blocking (real domain, AS714/AS6185) -# =================================================================== -class TestAppleServicesBlocking(ProfileHelpers): - """Verify ASN-based blocking for Apple services using real DNS. - - Uses apple.com (a real domain) which resolves to IPs in AS714. - Marked xfail(strict=False) because it depends on live external DNS. - """ - - def setup_class(self): - self.config = get_settings() - self.api_config = api_config.Configuration(host=self.config.DNS_API_ADDR) - self.dns_lib = DNSLib(self.config.DOH_ENDPOINT) - - @pytest.mark.asyncio - @pytest.mark.xfail( - reason="Depends on apple.com resolving to Apple ASN (external DNS)", - strict=False, + @pytest.mark.parametrize( + "service_id", + [ + pytest.param(SVC_GOOGLE_ID, id="google"), + pytest.param(SVC_GOOGLE_ALIAS_ID, id="alias"), + ], ) - async def test_apple_services_block_by_asn(self, create_account_and_login): - """Blocking the 'apple' service should cause apple.com - (AS714/AS6185) to return 0.0.0.0.""" - account, cookie = create_account_and_login - with client.ApiClient(self.api_config) as api_client: - p = api.ProfileApi(api_client) - p.api_client.default_headers["Cookie"] = cookie - - if not await services_available(self.dns_lib, p, cookie): - pytest.skip("Services/ASN blocking not available (GeoIP DB missing?)") - - profile_id = self._create_profile(p, "svc_block_apple") - self._block_service(p, profile_id, [SVC_APPLE_ID]) + async def test_services_unblock_restores_resolution(self, user, service_id): + """After unblocking the service (by canonical ID or alias), the domain + resolves normally again — the alias round-trips through enable/disable + exactly like a canonical service ID.""" + with user.profiles_api() as p: + if not await services_available(user.dns, p, user.cookie): + pytest.skip("Services/ASN blocking not available") - resp = await self.dns_lib.wait_until(profile_id, SVC_APPLE_DOMAIN, A, is_blocked) - ip_str = extract_ip(resp) - assert ip_str == "0.0.0.0", ( - f"Services block for {SVC_APPLE_ID} did not block " - f"{SVC_APPLE_DOMAIN}; got {ip_str}" - ) + profile_id = user.new_profile("svc_unblock") + user.block_services(profile_id, [service_id]) + # Verify blocked first. + resp = await user.wait_for(profile_id, SVC_GOOGLE_DOMAIN, A, is_blocked) + assert_blocked(resp, SVC_GOOGLE_DOMAIN) -# =================================================================== -# Microsoft services blocking (real domain, AS8068-AS8075) -# =================================================================== -class TestMicrosoftServicesBlocking(ProfileHelpers): - """Verify ASN-based blocking for Microsoft services using real DNS. + # Unblock. + user.unblock_services(profile_id, [service_id]) - Uses microsoft.com (a real domain) which resolves to IPs in AS8075. - Marked xfail(strict=False) because it depends on live external DNS. - """ - - def setup_class(self): - self.config = get_settings() - self.api_config = api_config.Configuration(host=self.config.DNS_API_ADDR) - self.dns_lib = DNSLib(self.config.DOH_ENDPOINT) - - @pytest.mark.asyncio - @pytest.mark.xfail( - reason="Depends on microsoft.com resolving to Microsoft ASN (external DNS)", - strict=False, - ) - async def test_microsoft_services_block_by_asn(self, create_account_and_login): - """Blocking the 'microsoft' service should cause microsoft.com - (AS8068-AS8075) to return 0.0.0.0.""" - account, cookie = create_account_and_login - with client.ApiClient(self.api_config) as api_client: - p = api.ProfileApi(api_client) - p.api_client.default_headers["Cookie"] = cookie - - if not await services_available(self.dns_lib, p, cookie): - pytest.skip("Services/ASN blocking not available (GeoIP DB missing?)") - - profile_id = self._create_profile(p, "svc_block_msft") - self._block_service(p, profile_id, [SVC_MICROSOFT_ID]) - - resp = await self.dns_lib.wait_until( - profile_id, SVC_MICROSOFT_DOMAIN, A, is_blocked - ) - ip_str = extract_ip(resp) - assert ip_str == "0.0.0.0", ( - f"Services block for {SVC_MICROSOFT_ID} did not block " - f"{SVC_MICROSOFT_DOMAIN}; got {ip_str}" - ) + resp = await user.wait_for(profile_id, SVC_GOOGLE_DOMAIN, A, is_resolved) + assert_not_blocked(resp, SVC_GOOGLE_DOMAIN) # =================================================================== # IP allow overrides services block (intra-IP-phase, T200 > T100) # =================================================================== -class TestIPAllowOverridesServices(ProfileHelpers): +class TestIPAllowOverridesServices: """IP custom allow (T200) should override services block (T100) within the IP phase.""" - def setup_class(self): - self.config = get_settings() - self.api_config = api_config.Configuration(host=self.config.DNS_API_ADDR) - self.dns_lib = DNSLib(self.config.DOH_ENDPOINT) - @pytest.mark.asyncio - async def test_ip_allow_overrides_services_block(self, create_account_and_login): + async def test_ip_allow_overrides_services_block(self, user): """Services block + IP allow for the resolved IP -> Processed. IP custom rule (T200) overrides services (T100). Table #6.""" - account, cookie = create_account_and_login - with client.ApiClient(self.api_config) as api_client: - p = api.ProfileApi(api_client) - p.api_client.default_headers["Cookie"] = cookie - - if not await services_available(self.dns_lib, p, cookie): + with user.profiles_api() as p: + if not await services_available(user.dns, p, user.cookie): pytest.skip("Services/ASN blocking not available") - profile_id = self._create_profile(p, "ip_allow_svc_6") - self._block_service(p, profile_id, [SVC_GOOGLE_ID]) - # Allow the specific IP that svctest-google.com resolves to. - self._create_custom_rule(p, profile_id, "allow", SVC_GOOGLE_IP) + profile_id = user.new_profile("ip_allow_svc_6") + user.block_services(profile_id, [SVC_GOOGLE_ID]) + # Allow the specific IP that svctest-google.com resolves to. + user.add_rule(profile_id, "allow", SVC_GOOGLE_IP) - # NOTE: negative assertion — cannot poll; may read pre-mutation state (see DNSLib.wait_until docstring) - resp = await self.dns_lib.send_doh_request(profile_id, SVC_GOOGLE_DOMAIN, A) - ip_str = extract_ip(resp) - assert ip_str != "0.0.0.0", ( - f"#6: IP allow for {SVC_GOOGLE_IP} should override services " - f"block; got {ip_str}" - ) + # NOTE: negative assertion — cannot poll; may read pre-mutation state (see DNSLib.wait_until docstring) + resp = await user.resolve(profile_id, SVC_GOOGLE_DOMAIN, A) + assert_not_blocked(resp, SVC_GOOGLE_DOMAIN) # =================================================================== # ASN custom rules (IP phase) # =================================================================== -class TestASNCustomRules(ProfileHelpers): +class TestASNCustomRules: """ASN-based custom rules created via the API and evaluated in the IP phase (post-resolve).""" - def setup_class(self): - self.config = get_settings() - self.api_config = api_config.Configuration(host=self.config.DNS_API_ADDR) - self.dns_lib = DNSLib(self.config.DOH_ENDPOINT) - @pytest.mark.asyncio - async def test_asn_custom_block(self, create_account_and_login): + async def test_asn_custom_block(self, user): """Block ASN 15169 (Google) -> svctest-google.com should return 0.0.0.0. Table #3 variant (IP CR block via ASN syntax).""" - account, cookie = create_account_and_login - with client.ApiClient(self.api_config) as api_client: - p = api.ProfileApi(api_client) - p.api_client.default_headers["Cookie"] = cookie - profile_id = self._create_profile(p, "asn_block") + profile_id = user.new_profile("asn_block") + user.add_rule(profile_id, "block", "AS15169") - self._create_custom_rule(p, profile_id, "block", "AS15169") - - resp = await self.dns_lib.wait_until(profile_id, SVC_GOOGLE_DOMAIN, A, is_blocked) - ip_str = extract_ip(resp) - assert ip_str == "0.0.0.0", ( - f"ASN block for AS15169 did not block {SVC_GOOGLE_DOMAIN}; " - f"got {ip_str}" - ) + resp = await user.wait_for(profile_id, SVC_GOOGLE_DOMAIN, A, is_blocked) + assert_blocked(resp, SVC_GOOGLE_DOMAIN) @pytest.mark.asyncio - async def test_asn_custom_block_does_not_affect_other_asn( - self, create_account_and_login - ): + async def test_asn_custom_block_does_not_affect_other_asn(self, user): """Block ASN 15169 should NOT block test.com (Cloudflare AS13335).""" - account, cookie = create_account_and_login - with client.ApiClient(self.api_config) as api_client: - p = api.ProfileApi(api_client) - p.api_client.default_headers["Cookie"] = cookie - profile_id = self._create_profile(p, "asn_block_other") - - self._create_custom_rule(p, profile_id, "block", "AS15169") - - # NOTE: negative assertion — cannot poll; may read pre-mutation state (see DNSLib.wait_until docstring) - resp = await self.dns_lib.send_doh_request(profile_id, TEST_DOMAIN, A) - ip_str = extract_ip(resp) - assert ip_str != "0.0.0.0", ( - f"ASN block for AS15169 should not affect {TEST_DOMAIN} " - f"(AS13335); got {ip_str}" - ) + profile_id = user.new_profile("asn_block_other") + user.add_rule(profile_id, "block", "AS15169") + + # NOTE: negative assertion — cannot poll; may read pre-mutation state (see DNSLib.wait_until docstring) + resp = await user.resolve(profile_id, RESOLVABLE_TEST_DOMAIN, A) + assert_not_blocked(resp, RESOLVABLE_TEST_DOMAIN) @pytest.mark.asyncio - async def test_asn_allow_overrides_services_block(self, create_account_and_login): + async def test_asn_allow_overrides_services_block(self, user): """Services block + ASN allow -> Processed. ASN custom allow (T200) overrides services block (T100). Table #6 variant.""" - account, cookie = create_account_and_login - with client.ApiClient(self.api_config) as api_client: - p = api.ProfileApi(api_client) - p.api_client.default_headers["Cookie"] = cookie - - if not await services_available(self.dns_lib, p, cookie): + with user.profiles_api() as p: + if not await services_available(user.dns, p, user.cookie): pytest.skip("Services/ASN blocking not available") - profile_id = self._create_profile(p, "asn_allow_svc") - self._block_service(p, profile_id, [SVC_GOOGLE_ID]) - self._create_custom_rule(p, profile_id, "allow", "AS15169") + profile_id = user.new_profile("asn_allow_svc") + user.block_services(profile_id, [SVC_GOOGLE_ID]) + user.add_rule(profile_id, "allow", "AS15169") - # NOTE: negative assertion — cannot poll; may read pre-mutation state (see DNSLib.wait_until docstring) - resp = await self.dns_lib.send_doh_request(profile_id, SVC_GOOGLE_DOMAIN, A) - ip_str = extract_ip(resp) - assert ip_str != "0.0.0.0", ( - f"ASN allow for AS15169 should override services block; " - f"got {ip_str}" - ) + # NOTE: negative assertion — cannot poll; may read pre-mutation state (see DNSLib.wait_until docstring) + resp = await user.resolve(profile_id, SVC_GOOGLE_DOMAIN, A) + assert_not_blocked(resp, SVC_GOOGLE_DOMAIN) # =================================================================== # HTTPS record blocking (real domain) # =================================================================== -class TestServicesHTTPSBlocking(ProfileHelpers): +class TestServicesHTTPSBlocking: """Verify that HTTPS (type 65) queries for blocked services don't leak information that would let browsers bypass A/AAAA blocking. @@ -441,90 +230,73 @@ class TestServicesHTTPSBlocking(ProfileHelpers): recursor to have internet access. """ - def setup_class(self): - self.config = get_settings() - self.api_config = api_config.Configuration(host=self.config.DNS_API_ADDR) - self.dns_lib = DNSLib(self.config.DOH_ENDPOINT) - @pytest.mark.asyncio @pytest.mark.xfail( strict=False, reason="depends on live external DNS (google.com HTTPS records)", ) - async def test_services_block_https_query_no_ip_hints( - self, create_account_and_login - ): + async def test_services_block_https_query_no_ip_hints(self, user): """When a service is blocked, HTTPS records must not contain ipv4hint or ipv6hint parameters that would leak IP addresses to browsers. The response is either NODATA (empty answer) when hints were present and matched, or contains only hint-free HTTPS records (e.g. alpn-only).""" - account, cookie = create_account_and_login - with client.ApiClient(self.api_config) as api_client: - p = api.ProfileApi(api_client) - p.api_client.default_headers["Cookie"] = cookie - - if not await services_available(self.dns_lib, p, cookie): + with user.profiles_api() as p: + if not await services_available(user.dns, p, user.cookie): pytest.skip("Services/ASN blocking not available") - profile_id = self._create_profile(p, "svc_https_hints") - self._block_service(p, profile_id, [SVC_GOOGLE_ID]) - - resp = await self.dns_lib.wait_until( - profile_id, REAL_GOOGLE_DOMAIN, HTTPS, lambda r: bool(r.answer) - ) - - # HTTPS records without IP hints (e.g. alpn-only) are safe - # to pass through. Verify none leak ipv4hint/ipv6hint. - for rrset in resp.answer: - for rdata in rrset: - rdata_text = rdata.to_text() - assert "ipv4hint" not in rdata_text, ( - f"HTTPS record for blocked service leaks ipv4hint: " - f"{rdata_text}" - ) - assert "ipv6hint" not in rdata_text, ( - f"HTTPS record for blocked service leaks ipv6hint: " - f"{rdata_text}" - ) + profile_id = user.new_profile("svc_https_hints") + user.block_services(profile_id, [SVC_GOOGLE_ID]) + + resp = await user.wait_for( + profile_id, REAL_GOOGLE_DOMAIN, HTTPS, lambda r: bool(r.answer) + ) + + # HTTPS records without IP hints (e.g. alpn-only) are safe + # to pass through. Verify none leak ipv4hint/ipv6hint. + for rrset in resp.answer: + for rdata in rrset: + rdata_text = rdata.to_text() + assert "ipv4hint" not in rdata_text, ( + f"HTTPS record for blocked service leaks ipv4hint: " + f"{rdata_text}" + ) + assert "ipv6hint" not in rdata_text, ( + f"HTTPS record for blocked service leaks ipv6hint: " + f"{rdata_text}" + ) @pytest.mark.asyncio @pytest.mark.xfail( strict=False, reason="depends on live external DNS (google.com HTTPS records)", ) - async def test_services_no_block_real_domain_https_query( - self, create_account_and_login - ): + async def test_services_no_block_real_domain_https_query(self, user): """When Google service is NOT blocked, HTTPS query should return answer records (proves the recursor returns HTTPS records and the blocking test above is meaningful).""" - account, cookie = create_account_and_login - with client.ApiClient(self.api_config) as api_client: - p = api.ProfileApi(api_client) - p.api_client.default_headers["Cookie"] = cookie - - if not await services_available(self.dns_lib, p, cookie): + with user.profiles_api() as p: + if not await services_available(user.dns, p, user.cookie): pytest.skip("Services/ASN blocking not available") - profile_id = self._create_profile(p, "svc_real_https_noblock") - # Do NOT block any service. + profile_id = user.new_profile("svc_real_https_noblock") + # Do NOT block any service. - resp = await self.dns_lib.wait_until( - profile_id, REAL_GOOGLE_DOMAIN, HTTPS, lambda r: bool(r.answer) - ) + resp = await user.wait_for( + profile_id, REAL_GOOGLE_DOMAIN, HTTPS, lambda r: bool(r.answer) + ) - assert resp.answer, ( - f"HTTPS query for {REAL_GOOGLE_DOMAIN} without blocking " - f"should return HTTPS records; got empty answer. " - f"Recursor may not have internet access." - ) + assert resp.answer, ( + f"HTTPS query for {REAL_GOOGLE_DOMAIN} without blocking " + f"should return HTTPS records; got empty answer. " + f"Recursor may not have internet access." + ) # =================================================================== # HTTPS record IP hints extraction (real domain with ipv4hint/ipv6hint) # =================================================================== -class TestHTTPSRecordIPHints(ProfileHelpers): +class TestHTTPSRecordIPHints: """Verify that the proxy inspects ipv4hint/ipv6hint inside HTTPS records when evaluating IP-phase filters (custom ASN rules). @@ -535,110 +307,89 @@ class TestHTTPSRecordIPHints(ProfileHelpers): a warning instead of a hard CI failure. """ - def setup_class(self): - self.config = get_settings() - self.api_config = api_config.Configuration(host=self.config.DNS_API_ADDR) - self.dns_lib = DNSLib(self.config.DOH_ENDPOINT) - @pytest.mark.asyncio @pytest.mark.xfail( reason="Depends on cloudflare.com serving HTTPS records with ipv4hint/ipv6hint (external DNS)", strict=False, ) - async def test_https_hints_precondition(self, create_account_and_login): + async def test_https_hints_precondition(self, user): """Precondition: cloudflare.com HTTPS record contains ipv4hint. If this fails, Cloudflare changed their HTTPS record format and the other tests in this class are not meaningful.""" - account, cookie = create_account_and_login - with client.ApiClient(self.api_config) as api_client: - p = api.ProfileApi(api_client) - p.api_client.default_headers["Cookie"] = cookie - - if not await services_available(self.dns_lib, p, cookie): + with user.profiles_api() as p: + if not await services_available(user.dns, p, user.cookie): pytest.skip("Services/ASN blocking not available") - profile_id = self._create_profile(p, "https_hints_pre") - - resp = await self.dns_lib.wait_until( - profile_id, REAL_HTTPS_HINTS_DOMAIN, HTTPS, lambda r: bool(r.answer) - ) - assert resp.answer, ( - f"HTTPS query for {REAL_HTTPS_HINTS_DOMAIN} returned empty answer" - ) - full_answer = " ".join( - rdata.to_text() for rrset in resp.answer for rdata in rrset - ) - assert "ipv4hint" in full_answer, ( - f"{REAL_HTTPS_HINTS_DOMAIN} HTTPS record has no ipv4hint; " - f"got: {full_answer}" - ) + profile_id = user.new_profile("https_hints_pre") + + resp = await user.wait_for( + profile_id, REAL_HTTPS_HINTS_DOMAIN, HTTPS, lambda r: bool(r.answer) + ) + assert resp.answer, ( + f"HTTPS query for {REAL_HTTPS_HINTS_DOMAIN} returned empty answer" + ) + full_answer = " ".join( + rdata.to_text() for rrset in resp.answer for rdata in rrset + ) + assert "ipv4hint" in full_answer, ( + f"{REAL_HTTPS_HINTS_DOMAIN} HTTPS record has no ipv4hint; " + f"got: {full_answer}" + ) @pytest.mark.asyncio @pytest.mark.xfail( reason="Depends on cloudflare.com serving HTTPS records with ipv4hint/ipv6hint (external DNS)", strict=False, ) - async def test_asn_block_catches_https_ipv4hint(self, create_account_and_login): + async def test_asn_block_catches_https_ipv4hint(self, user): """A custom ASN-block rule for AS13335 (Cloudflare) should block an HTTPS query whose ipv4hint IPs belong to that ASN. This verifies extractIPsFromSVCB feeds hint IPs into the ASN matcher in the IP-phase filter.""" - account, cookie = create_account_and_login - with client.ApiClient(self.api_config) as api_client: - p = api.ProfileApi(api_client) - p.api_client.default_headers["Cookie"] = cookie - - if not await services_available(self.dns_lib, p, cookie): + with user.profiles_api() as p: + if not await services_available(user.dns, p, user.cookie): pytest.skip("Services/ASN blocking not available") - profile_id = self._create_profile(p, "https_hints_asn") - self._create_custom_rule(p, profile_id, "block", "AS13335") - - resp = await self.dns_lib.wait_until( - profile_id, REAL_HTTPS_HINTS_DOMAIN, HTTPS, - lambda r: r.rcode() == dns.rcode.NOERROR and not r.answer, - ) - # When the proxy extracts ipv4hint IPs from the HTTPS record - # and matches them against the ASN custom rule, the query - # should be blocked. A blocked HTTPS query returns NODATA: - # RCODE=NOERROR with an empty answer section. - assert resp.rcode() == dns.rcode.NOERROR, ( - f"HTTPS query for {REAL_HTTPS_HINTS_DOMAIN} with AS13335 " - f"blocked should return NOERROR (NODATA); " - f"got rcode {dns.rcode.to_text(resp.rcode())}" - ) - assert not resp.answer, ( - f"HTTPS query for {REAL_HTTPS_HINTS_DOMAIN} with AS13335 " - f"blocked should return empty answer (NODATA); " - f"got: {resp.answer}" - ) + profile_id = user.new_profile("https_hints_asn") + user.add_rule(profile_id, "block", "AS13335") + + resp = await user.wait_for( + profile_id, REAL_HTTPS_HINTS_DOMAIN, HTTPS, + lambda r: r.rcode() == dns.rcode.NOERROR and not r.answer, + ) + # When the proxy extracts ipv4hint IPs from the HTTPS record + # and matches them against the ASN custom rule, the query + # should be blocked. A blocked HTTPS query returns NODATA: + # RCODE=NOERROR with an empty answer section. + assert resp.rcode() == dns.rcode.NOERROR, ( + f"HTTPS query for {REAL_HTTPS_HINTS_DOMAIN} with AS13335 " + f"blocked should return NOERROR (NODATA); " + f"got rcode {dns.rcode.to_text(resp.rcode())}" + ) + assert not resp.answer, ( + f"HTTPS query for {REAL_HTTPS_HINTS_DOMAIN} with AS13335 " + f"blocked should return empty answer (NODATA); " + f"got: {resp.answer}" + ) @pytest.mark.asyncio @pytest.mark.xfail( reason="Depends on cloudflare.com serving HTTPS records with ipv4hint/ipv6hint (external DNS)", strict=False, ) - async def test_asn_block_also_blocks_a_record(self, create_account_and_login): + async def test_asn_block_also_blocks_a_record(self, user): """Sanity check: the same AS13335 block rule also blocks the A query (standard post-resolve IP filtering).""" - account, cookie = create_account_and_login - with client.ApiClient(self.api_config) as api_client: - p = api.ProfileApi(api_client) - p.api_client.default_headers["Cookie"] = cookie - - if not await services_available(self.dns_lib, p, cookie): + with user.profiles_api() as p: + if not await services_available(user.dns, p, user.cookie): pytest.skip("Services/ASN blocking not available") - profile_id = self._create_profile(p, "https_hints_a") - self._create_custom_rule(p, profile_id, "block", "AS13335") - - resp = await self.dns_lib.wait_until( - profile_id, REAL_HTTPS_HINTS_DOMAIN, A, is_blocked - ) - ip_str = extract_ip(resp) - assert ip_str == "0.0.0.0", ( - f"A query for {REAL_HTTPS_HINTS_DOMAIN} with AS13335 blocked " - f"should return 0.0.0.0; got {ip_str}" - ) + profile_id = user.new_profile("https_hints_a") + user.add_rule(profile_id, "block", "AS13335") + + resp = await user.wait_for( + profile_id, REAL_HTTPS_HINTS_DOMAIN, A, is_blocked + ) + assert_blocked(resp, REAL_HTTPS_HINTS_DOMAIN) diff --git a/tests/dns_tests/test_signup_reset.py b/tests/dns_tests/test_signup_reset.py index 6258c35f..268b2b4c 100644 --- a/tests/dns_tests/test_signup_reset.py +++ b/tests/dns_tests/test_signup_reset.py @@ -13,28 +13,17 @@ response, so the tests poll the previous account's status until it flips. """ -import base64 -import hashlib -import os as _os -import random -import string import time import uuid -from datetime import datetime, timedelta, timezone import pytest -import requests as http_requests import moddns.api as api import moddns.api_client as client import moddns.configuration as api_config -from moddns import RequestsLoginBody -from moddns.api.pa_session_api import PASessionApi from moddns.exceptions import ApiException -from moddns.models.requests_pa_session_req import RequestsPASessionReq -from moddns.models.requests_rotate_pa_session_req import RequestsRotatePASessionReq -from helpers import generate_complex_password +from libs.accounts import create_account from libs.settings import get_settings RETIREMENT_TIMEOUT_S = 20 @@ -44,80 +33,14 @@ def _api_conf(): return api_config.Configuration(host=get_settings().DNS_API_ADDR) -def _random_email() -> str: - return f"reset{''.join(random.choice(string.digits) for _ in range(8))}@ivpn.net" - - -def _provision_pa_session(token: str, validity_days: int = 30, tier: str = "Tier 2"): - """Provision a PASession for a SPECIFIC ZLA token. - - Unlike conftest.create_temp_subscription (which randomises the token), this - lets two signups share the same token — and therefore the same token_hash, - the signal modDNS uses to detect a reset re-signup. - """ - subscription_id = str(uuid.uuid4()) - session_id = str(uuid.uuid4()) - preauth_id = str(uuid.uuid4()) - active_until = ( - datetime.utcnow().replace(tzinfo=timezone.utc) + timedelta(days=validity_days) - ).isoformat().replace("+00:00", "Z") - token_hash = base64.b64encode(hashlib.sha256(token.encode()).digest()).decode() - - mock_preauth_url = _os.getenv("MOCK_PREAUTH_URL", "http://localhost:8080") - http_requests.post( - f"{mock_preauth_url}/entry", - json={ - "id": preauth_id, - "token_hash": token_hash, - "is_active": True, - "active_until": active_until, - "tier": tier, - }, - ).raise_for_status() - - api_conf = _api_conf() - with client.ApiClient(api_conf) as api_client: - pa_api = PASessionApi(api_client) - pa_api.api_client.default_headers["Authorization"] = "Bearer " - pa_api.api_v1_pasession_add_post( - body=RequestsPASessionReq(id=session_id, preauth_id=preauth_id, token=token) - ) - with client.ApiClient(api_conf) as api_client: - pa_api = PASessionApi(api_client) - rotate = pa_api.api_v1_pasession_rotate_put_with_http_info( - body=RequestsRotatePASessionReq(sessionid=session_id) - ) - assert rotate.status_code == 200, f"PASession rotate failed: {rotate.status_code}" - pa_cookie = rotate.headers.get("Set-Cookie", "") - assert "pa_session=" in pa_cookie, f"no pa_session cookie: {pa_cookie}" - return subscription_id, pa_cookie - - def _signup_and_login(token: str) -> str: """Register a new account whose ZLA token is `token`, then log in. - Returns the session cookie. + Returns the session cookie. Uses ``libs.accounts.create_account`` with an + explicit token so two signups can share a token_hash — the signal modDNS + uses to detect a reset re-signup. """ - email = _random_email() - password = generate_complex_password() - subscription_id, pa_cookie = _provision_pa_session(token) - - api_conf = _api_conf() - with client.ApiClient(api_conf) as api_client: - account_api = api.AccountApi(api_client) - account_api.api_client.default_headers["Cookie"] = pa_cookie - reg = account_api.api_v1_accounts_post_with_http_info( - body={"email": email, "password": password, "subid": subscription_id} - ) - assert reg.status_code == 201, f"registration failed: {reg.status_code}" - - auth_api = api.AuthenticationApi(api_client) - login = auth_api.api_v1_login_post_with_http_info( - body=RequestsLoginBody(email=email, password=password) - ) - assert login.status_code == 200, f"login failed: {login.status_code}" - cookie = login.headers.get("Set-Cookie") - assert cookie, "no session cookie after login" + _, cookie, _, _ = create_account(token=token) return cookie diff --git a/tests/dns_tests/test_subdomain_blocking.py b/tests/dns_tests/test_subdomain_blocking.py index a8f6b5cd..8a714cab 100644 --- a/tests/dns_tests/test_subdomain_blocking.py +++ b/tests/dns_tests/test_subdomain_blocking.py @@ -1,44 +1,8 @@ -from ipaddress import ip_address -import uuid - import pytest -from libs.dns_lib import DNSLib, is_blocked, is_resolved -from libs.settings import get_settings from dns.rdatatype import A -import redis - -import moddns.api_client as client -import moddns.api as api -import moddns.configuration as api_config -from moddns import ( - RequestsProfileUpdates, - ModelProfileUpdate, - ApiCreateProfileBody, - ApiBlocklistsUpdates, -) - -from conftest import TEST_BLOCKLIST_ID, TEST_DOMAIN, TEST_SUBDOMAIN # noqa: F401 - - -def _is_blocked(resp) -> bool: - """Return True when the DNS response indicates a blocked domain (0.0.0.0).""" - if not resp.answer: - return False - ip_addr = resp.answer[0].to_text().split(" ")[-1] - return ip_addr == "0.0.0.0" - -def _is_not_blocked(resp) -> bool: - """Return True when the DNS response does NOT indicate blocking. - - A domain is considered not-blocked when: - - There is no answer section (NXDOMAIN / SERVFAIL), OR - - The answer IP is anything other than 0.0.0.0 - """ - if not resp.answer: - return True - ip_addr = resp.answer[0].to_text().split(" ")[-1] - return ip_addr != "0.0.0.0" +from libs.constants import BLOCKLISTED_DOMAIN, BLOCKLISTED_SUBDOMAIN +from libs.dns_lib import assert_blocked, assert_not_blocked, is_blocked, is_resolved class TestSubdomainBlocking: @@ -51,60 +15,9 @@ class TestSubdomainBlocking: are blocked; ``"allow"`` means only the exact parent domain is blocked. """ - def setup_class(self): - """Setup the test class.""" - self.config = get_settings() - self.api_config = api_config.Configuration(host=self.config.DNS_API_ADDR) - self.dns_lib = DNSLib(self.config.DOH_ENDPOINT) - self.redis_client = redis.Redis( - host=self.config.REDIS_HOST, port=self.config.REDIS_PORT, db=0 - ) - - # ------------------------------------------------------------------ - # Helpers - # ------------------------------------------------------------------ - - def _create_profile(self, cookie: str) -> str: - """Create a fresh profile with a unique name and return its profile_id.""" - with client.ApiClient(self.api_config) as api_client: - profiles_instance = api.ProfileApi(api_client) - profiles_instance.api_client.default_headers["Cookie"] = cookie - name = f"test_subdomain_{uuid.uuid4().hex[:8]}" - body = ApiCreateProfileBody(name=name) - resp = profiles_instance.api_v1_profiles_post_with_http_info(body=body) - assert ( - resp.status_code == 201 - ), f"Failed to create profile with status code: {resp.status_code}" - return resp.data.profile_id - - def _set_blocklists_subdomains_rule(self, cookie: str, profile_id: str, value: str) -> None: - """PATCH the blocklists_subdomains_rule setting on *profile_id*.""" - with client.ApiClient(self.api_config) as api_client: - profiles_instance = api.ProfileApi(api_client) - profiles_instance.api_client.default_headers["Cookie"] = cookie - update_request = RequestsProfileUpdates( - updates=[ - ModelProfileUpdate( - operation="replace", - path="/settings/privacy/blocklists_subdomains_rule", - value={"value": value}, - ) - ] - ) - resp = profiles_instance.api_v1_profiles_id_patch_with_http_info( - profile_id, body=update_request - ) - assert ( - resp.status_code == 200 - ), f"Failed to update blocklists_subdomains_rule to '{value}' with status code: {resp.status_code}" - - # ------------------------------------------------------------------ - # Tests - # ------------------------------------------------------------------ - @pytest.mark.asyncio async def test_parent_domain_blocked( - self, create_account_and_login, ensure_test_blocklisted + self, user, ensure_test_blocklisted ): """Verify that a domain explicitly present in the blocklist is blocked. @@ -112,17 +25,14 @@ async def test_parent_domain_blocked( the ``ensure_test_blocklisted`` fixture and a DNS query for it must return 0.0.0.0. """ - _, cookie = create_account_and_login - profile_id = self._create_profile(cookie) + profile_id = user.new_profile("subdomain") - resp = await self.dns_lib.wait_until(profile_id, TEST_DOMAIN, A, is_blocked) - assert _is_blocked( - resp - ), f"Blocklisted parent domain {TEST_DOMAIN} was not blocked (expected 0.0.0.0)" + resp = await user.wait_for(profile_id, BLOCKLISTED_DOMAIN, A, is_blocked) + assert_blocked(resp, BLOCKLISTED_DOMAIN) @pytest.mark.asyncio async def test_subdomain_blocked_by_default( - self, create_account_and_login, ensure_test_blocklisted + self, user, ensure_test_blocklisted ): """Verify that subdomains are blocked when the parent is in the blocklist. @@ -130,53 +40,44 @@ async def test_subdomain_blocked_by_default( the blocklist, yet it must be blocked because example.com is listed and the default blocklists_subdomains_rule is "block". """ - _, cookie = create_account_and_login - profile_id = self._create_profile(cookie) + profile_id = user.new_profile("subdomain") - resp = await self.dns_lib.wait_until(profile_id, TEST_SUBDOMAIN, A, is_blocked) - assert _is_blocked( - resp - ), f"Subdomain {TEST_SUBDOMAIN} was not blocked by default (expected 0.0.0.0)" + resp = await user.wait_for(profile_id, BLOCKLISTED_SUBDOMAIN, A, is_blocked) + assert_blocked(resp, BLOCKLISTED_SUBDOMAIN) @pytest.mark.asyncio async def test_www_subdomain_blocked( - self, create_account_and_login, ensure_test_blocklisted + self, user, ensure_test_blocklisted ): """Verify that www. is blocked when the parent is in the blocklist. Browsers commonly prepend ``www.`` to domains. The proxy must treat www.example.com as a subdomain of the blocklisted example.com. """ - _, cookie = create_account_and_login - profile_id = self._create_profile(cookie) + profile_id = user.new_profile("subdomain") - domain = f"www.{TEST_DOMAIN}" - resp = await self.dns_lib.wait_until(profile_id, domain, A, is_blocked) - assert _is_blocked( - resp - ), f"www subdomain {domain} was not blocked (expected 0.0.0.0)" + domain = f"www.{BLOCKLISTED_DOMAIN}" + resp = await user.wait_for(profile_id, domain, A, is_blocked) + assert_blocked(resp, domain) @pytest.mark.asyncio async def test_deep_subdomain_blocked( - self, create_account_and_login, ensure_test_blocklisted + self, user, ensure_test_blocklisted ): """Verify that deeply-nested subdomains are blocked. a.b.example.com should still be blocked when example.com is in the blocklist and blocklists_subdomains_rule is "block" (default). """ - _, cookie = create_account_and_login - profile_id = self._create_profile(cookie) + profile_id = user.new_profile("subdomain") - domain = f"a.b.{TEST_DOMAIN}" - resp = await self.dns_lib.wait_until(profile_id, domain, A, is_blocked) - assert _is_blocked( - resp - ), f"Deep subdomain {domain} was not blocked (expected 0.0.0.0)" + domain = f"a.b.{BLOCKLISTED_DOMAIN}" + resp = await user.wait_for(profile_id, domain, A, is_blocked) + assert_blocked(resp, domain) @pytest.mark.asyncio async def test_subdomain_allowed_when_rule_disabled( - self, create_account_and_login, ensure_test_blocklisted + self, user, ensure_test_blocklisted ): """Verify that subdomains pass through when blocklists_subdomains_rule is "allow". @@ -184,19 +85,18 @@ async def test_subdomain_allowed_when_rule_disabled( domain (example.com) should be blocked. sub.example.com must not be intercepted by the proxy. """ - _, cookie = create_account_and_login - profile_id = self._create_profile(cookie) + profile_id = user.new_profile("subdomain") - self._set_blocklists_subdomains_rule(cookie, profile_id, "allow") + user.patch_setting( + profile_id, "/settings/privacy/blocklists_subdomains_rule", "allow" + ) - resp = await self.dns_lib.wait_until(profile_id, TEST_SUBDOMAIN, A, is_resolved) - assert _is_not_blocked( - resp - ), f"Subdomain {TEST_SUBDOMAIN} was still blocked after setting blocklists_subdomains_rule to 'allow'" + resp = await user.wait_for(profile_id, BLOCKLISTED_SUBDOMAIN, A, is_resolved) + assert_not_blocked(resp, BLOCKLISTED_SUBDOMAIN) @pytest.mark.asyncio async def test_subdomain_rule_toggle( - self, create_account_and_login, ensure_test_blocklisted + self, user, ensure_test_blocklisted ): """Verify that toggling blocklists_subdomains_rule takes effect dynamically. @@ -205,71 +105,60 @@ async def test_subdomain_rule_toggle( 2. Switch to "allow" -- subdomain query is no longer blocked 3. Switch back to "block" -- subdomain query returns 0.0.0.0 again """ - _, cookie = create_account_and_login - profile_id = self._create_profile(cookie) + profile_id = user.new_profile("subdomain") # Step 1: default setting is "block" - resp1 = await self.dns_lib.wait_until(profile_id, TEST_SUBDOMAIN, A, is_blocked) - assert _is_blocked( - resp1 - ), f"Step 1 failed: {TEST_SUBDOMAIN} should be blocked with default blocklists_subdomains_rule" + resp1 = await user.wait_for(profile_id, BLOCKLISTED_SUBDOMAIN, A, is_blocked) + assert_blocked(resp1, BLOCKLISTED_SUBDOMAIN) # Step 2: switch to "allow" - self._set_blocklists_subdomains_rule(cookie, profile_id, "allow") - resp2 = await self.dns_lib.wait_until(profile_id, TEST_SUBDOMAIN, A, is_resolved) - assert _is_not_blocked( - resp2 - ), f"Step 2 failed: {TEST_SUBDOMAIN} should not be blocked after setting blocklists_subdomains_rule to 'allow'" + user.patch_setting( + profile_id, "/settings/privacy/blocklists_subdomains_rule", "allow" + ) + resp2 = await user.wait_for(profile_id, BLOCKLISTED_SUBDOMAIN, A, is_resolved) + assert_not_blocked(resp2, BLOCKLISTED_SUBDOMAIN) # Step 3: switch back to "block" - self._set_blocklists_subdomains_rule(cookie, profile_id, "block") - resp3 = await self.dns_lib.wait_until(profile_id, TEST_SUBDOMAIN, A, is_blocked) - assert _is_blocked( - resp3 - ), f"Step 3 failed: {TEST_SUBDOMAIN} should be blocked again after restoring blocklists_subdomains_rule to 'block'" + user.patch_setting( + profile_id, "/settings/privacy/blocklists_subdomains_rule", "block" + ) + resp3 = await user.wait_for(profile_id, BLOCKLISTED_SUBDOMAIN, A, is_blocked) + assert_blocked(resp3, BLOCKLISTED_SUBDOMAIN) @pytest.mark.asyncio async def test_unrelated_domain_not_blocked( - self, create_account_and_login, ensure_test_blocklisted + self, user, ensure_test_blocklisted ): """Verify that domains NOT in the blocklist are not affected. facebook.com is a well-known domain that is not present in the test blocklist. A DNS query for it must return a valid, non-blocked IP. """ - _, cookie = create_account_and_login - profile_id = self._create_profile(cookie) + profile_id = user.new_profile("subdomain") # NOTE: negative assertion — cannot poll; may read pre-mutation state (see DNSLib.wait_until docstring) - resp = await self.dns_lib.send_doh_request(profile_id, "facebook.com", A) - assert resp.answer, "Expected an answer for unrelated domain facebook.com" - ip_addr = resp.answer[0].to_text().split(" ")[-1] - assert ip_address(ip_addr) != ip_address( - "0.0.0.0" - ), "Unrelated domain facebook.com should not be blocked" + resp = await user.resolve(profile_id, "facebook.com", A) + assert_not_blocked(resp, "facebook.com") @pytest.mark.asyncio @pytest.mark.parametrize( "subdomain", [ - TEST_SUBDOMAIN, - f"www.{TEST_DOMAIN}", - f"deep.sub.{TEST_DOMAIN}", + BLOCKLISTED_SUBDOMAIN, + f"www.{BLOCKLISTED_DOMAIN}", + f"deep.sub.{BLOCKLISTED_DOMAIN}", ], ids=["one-level", "www-prefix", "two-levels"], ) async def test_multiple_subdomain_levels_blocked( - self, create_account_and_login, ensure_test_blocklisted, subdomain + self, user, ensure_test_blocklisted, subdomain ): """Parametrized: various subdomain depths are all blocked. When example.com is in the blocklist and blocklists_subdomains_rule is "block" (default), every subdomain regardless of depth must return 0.0.0.0. """ - _, cookie = create_account_and_login - profile_id = self._create_profile(cookie) + profile_id = user.new_profile("subdomain") - resp = await self.dns_lib.wait_until(profile_id, subdomain, A, is_blocked) - assert _is_blocked( - resp - ), f"Subdomain {subdomain} was not blocked (expected 0.0.0.0)" + resp = await user.wait_for(profile_id, subdomain, A, is_blocked) + assert_blocked(resp, subdomain) diff --git a/tests/libs/accounts.py b/tests/libs/accounts.py new file mode 100644 index 00000000..45e17a96 --- /dev/null +++ b/tests/libs/accounts.py @@ -0,0 +1,182 @@ +"""Account and subscription provisioning shared by fixtures and tests. + +Single home for the ZLA signup flow (mock-preauth entry → PASession add/rotate +→ register → login → fetch account) and account deletion. ``conftest`` +re-exports the entry points so existing ``from conftest import …`` sites keep +working. +""" + +import base64 +import hashlib +import random +import string +import uuid +from datetime import datetime, timedelta, timezone +from typing import Any, Optional + +import requests as http_requests + +import moddns.api as api +import moddns.api_client as client +import moddns.configuration as api_config +from moddns import RequestsLoginBody +from moddns.api.pa_session_api import PASessionApi +from moddns.models.requests_account_deletion_request import ( + RequestsAccountDeletionRequest, +) +from moddns.models.requests_pa_session_req import RequestsPASessionReq +from moddns.models.requests_rotate_pa_session_req import RequestsRotatePASessionReq + +from helpers import generate_complex_password +from libs.settings import get_settings + + +def random_email(prefix: str = "test") -> str: + return f"{prefix}{''.join(random.choice(string.digits) for _ in range(5))}@ivpn.net" + + +def create_temp_subscription( + validity_days: int = 30, + *, + token: Optional[str] = None, + tier: str = "Tier 2", +) -> tuple[str, str]: + """Provision a pre-auth session (PASession) for the ZLA signup flow. + + Flow: + 1. Generate a token (random unless ``token`` is given) and its SHA256 hash + 2. Create a preauth entry in the mock preauth service + 3. Call POST /api/v1/pasession/add with PSK to cache the PASession + 4. Call PUT /api/v1/pasession/rotate to get a rotated session cookie + 5. Return (subscription_id, pa_session_cookie) + + Pass ``token`` explicitly to make two signups share the same token_hash — + the signal modDNS uses to detect a signup-reset re-signup. + """ + config = get_settings() + + subscription_id = str(uuid.uuid4()) + session_id = str(uuid.uuid4()) + preauth_id = str(uuid.uuid4()) + if token is None: + token = str(uuid.uuid4()) + + active_until_dt = datetime.now(timezone.utc) + timedelta(days=validity_days) + active_until = active_until_dt.isoformat().replace("+00:00", "Z") + + # Compute token hash (SHA256, base64-encoded) matching what the API validates + token_hash = base64.b64encode(hashlib.sha256(token.encode()).digest()).decode() + + # 1. Create preauth entry in mock preauth service + http_requests.post( + f"{config.MOCK_PREAUTH_URL}/entry", + json={ + "id": preauth_id, + "token_hash": token_hash, + "is_active": True, + "active_until": active_until, + "tier": tier, + }, + ).raise_for_status() + + # 2. Add PASession via API (PSK-protected endpoint) + api_conf = api_config.Configuration(host=config.DNS_API_ADDR) + psk = "" # empty PSK works if no PSK is set in API .env + + with client.ApiClient(api_conf) as api_client: + pa_api = PASessionApi(api_client) + pa_api.api_client.default_headers["Authorization"] = f"Bearer {psk}" + body = RequestsPASessionReq(id=session_id, preauth_id=preauth_id, token=token) + resp = pa_api.api_v1_pasession_add_post(body=body) + assert ( + resp.get("message") == "pre-auth session added" + ), f"Unexpected PASession add response: {resp}" + + # 3. Rotate PASession to get cookie + with client.ApiClient(api_conf) as api_client: + pa_api = PASessionApi(api_client) + rotate_body = RequestsRotatePASessionReq(sessionid=session_id) + rotate_resp = pa_api.api_v1_pasession_rotate_put_with_http_info( + body=rotate_body + ) + assert rotate_resp.status_code == 200, ( + f"PASession rotation failed: {rotate_resp.status_code}" + ) + pa_cookie = rotate_resp.headers.get("Set-Cookie", "") + assert "pa_session=" in pa_cookie, ( + f"No pa_session cookie in rotation response: {pa_cookie}" + ) + + return subscription_id, pa_cookie + + +def create_account( + *, + email: Optional[str] = None, + password: Optional[str] = None, + token: Optional[str] = None, + tier: str = "Tier 2", +) -> tuple[Any, str, str, str]: + """Register a fresh account via the ZLA flow, log in, fetch the account. + + Returns ``(account, cookie, password, email)``. The plaintext password is + returned so callers can perform reauth flows (e.g. account deletion). + """ + config = get_settings() + api_conf = api_config.Configuration(host=config.DNS_API_ADDR) + email = email or random_email() + password = password or generate_complex_password() + + subscription_id, pa_cookie = create_temp_subscription(token=token, tier=tier) + + with client.ApiClient(api_conf) as api_client: + account_api = api.AccountApi(api_client) + auth_api = api.AuthenticationApi(api_client) + + account_api.api_client.default_headers["Cookie"] = pa_cookie + reg_resp = account_api.api_v1_accounts_post_with_http_info( + body={"email": email, "password": password, "subid": subscription_id} + ) + assert ( + reg_resp.status_code == 201 + ), f"Registration failed with status code: {reg_resp.status_code}" + + login_response = auth_api.api_v1_login_post_with_http_info( + body=RequestsLoginBody(email=email, password=password) + ) + assert ( + login_response.status_code == 200 + ), f"Login failed with status code: {login_response.status_code}" + cookie = login_response.headers.get("Set-Cookie") + assert cookie, "No session cookie returned after login" + + account_api.api_client.default_headers["Cookie"] = cookie + account = account_api.api_v1_accounts_current_get() + assert len(account.profiles) == 1 + return account, cookie, password, email + + +def delete_account(cookie: str, password: str, *, account_id: str = "?") -> None: + """Best-effort account deletion via the deletion-code + password-reauth flow. + + Deleting the account removes all its profiles and cached state, so test + runs don't accumulate data in Mongo/Redis. Failures are logged, not raised — + cleanup problems must not fail an otherwise green test. + """ + try: + config = get_settings() + api_conf = api_config.Configuration(host=config.DNS_API_ADDR) + with client.ApiClient(api_conf) as api_client: + account_api = api.AccountApi(api_client) + account_api.api_client.default_headers["Cookie"] = cookie + code_resp = account_api.api_v1_accounts_current_deletion_code_post() + resp = account_api.api_v1_accounts_current_delete_with_http_info( + body=RequestsAccountDeletionRequest( + deletion_code=code_resp.code, current_password=password + ) + ) + assert resp.status_code in (200, 204), ( + f"Account deletion failed with status code: {resp.status_code}" + ) + except Exception as e: + print(f"Warning: Failed to delete test account {account_id}: {e}") diff --git a/tests/libs/constants.py b/tests/libs/constants.py new file mode 100644 index 00000000..7207b10e --- /dev/null +++ b/tests/libs/constants.py @@ -0,0 +1,22 @@ +"""Shared deterministic test constants — single source of truth. + +Historically two different ``TEST_DOMAIN`` constants existed (``example.com`` +in conftest = blocklisted, ``test.com`` in profile_helpers = resolvable) with +opposite meanings. Import from this module and use the explicit names below; +never redefine these in test files. +""" + +# The blocklist seeded by fixtures and enabled on new profiles by default. +TEST_BLOCKLIST_ID = "hagezi_threat_intelligence_feeds_full" + +# Inserted into TEST_BLOCKLIST_ID by the ensure_test_blocklisted fixture, so it +# is BLOCKED for profiles with the default blocklist enabled. Resolvable upstream. +BLOCKLISTED_DOMAIN = "example.com" +# Intentionally NOT inserted into the blocklist; used to validate inherited +# subdomain blocking. +BLOCKLISTED_SUBDOMAIN = f"sub.{BLOCKLISTED_DOMAIN}" + +# Pinned in config/testhosts.txt (and mirrored in config/knot.config.yaml) — +# resolves deterministically to RESOLVABLE_TEST_IP and is in NO blocklist. +RESOLVABLE_TEST_DOMAIN = "test.com" +RESOLVABLE_TEST_IP = "104.18.74.230" # AS13335 (Cloudflare, not in catalog) diff --git a/tests/libs/dns_lib.py b/tests/libs/dns_lib.py index bd1d1670..7ba8da97 100644 --- a/tests/libs/dns_lib.py +++ b/tests/libs/dns_lib.py @@ -36,6 +36,27 @@ def answer_ip_is(expected: str) -> Callable[[Message], bool]: return lambda resp: first_answer_ip(resp) == expected +def assert_blocked(resp: Message, domain: str = "domain") -> None: + """Assert the response is the proxy's block sentinel (0.0.0.0 / ::).""" + assert resp.answer, f"Expected a blocked answer for {domain}, got empty answer" + ip = first_answer_ip(resp) + assert ip in BLOCKED_IPS, f"{domain} was not blocked; got {ip}" + + +def assert_not_blocked(resp: Message, domain: str = "domain") -> None: + """Assert the response is NOT the proxy's block sentinel. + + An empty answer (NXDOMAIN/NODATA) or a CNAME-first answer counts as "not + blocked" — blocking always yields a synthetic 0.0.0.0/:: answer, so only + the sentinel itself is a failure. When the test also requires the domain to + genuinely resolve, poll with ``wait_until(..., is_resolved)`` first. + """ + if not resp.answer: + return + ip = first_answer_ip(resp) + assert ip not in BLOCKED_IPS, f"{domain} was unexpectedly blocked (got {ip})" + + class DNSLib: def __init__(self, server: str): self.server = server diff --git a/tests/libs/export_import_helpers.py b/tests/libs/export_import_helpers.py index 00d4129c..95ba3d2a 100644 --- a/tests/libs/export_import_helpers.py +++ b/tests/libs/export_import_helpers.py @@ -25,7 +25,7 @@ import moddns.api_client as client import moddns.configuration as api_config from moddns import RequestsLoginBody -from helpers import generate_complex_password +from libs.accounts import create_account from libs.settings import get_settings @@ -35,47 +35,11 @@ def create_account_with_password() -> tuple[Any, str, str, str]: """Create a new account and return (account, cookie, password, email). - Mirrors conftest.create_acc_and_login_func but also surfaces the plaintext - password and email so tests can perform reauth via the current_password - path. Each call yields a fresh account so rate-limit / max-profiles tests + Thin wrapper over libs.accounts.create_account, kept for existing call + sites. Each call yields a fresh account so rate-limit / max-profiles tests stay isolated. """ - from conftest import create_temp_subscription # local to avoid cycles - - config = get_settings() - api_conf = api_config.Configuration(host=config.DNS_API_ADDR) - with client.ApiClient(api_conf) as api_client: - account_api = api.AccountApi(api_client) - auth_api = api.AuthenticationApi(api_client) - - email = ( - f"test{''.join(random.choice(string.digits) for _ in range(5))}@ivpn.net" - ) - password = generate_complex_password() - - subscription_id, pa_cookie = create_temp_subscription() - - account_api.api_client.default_headers["Cookie"] = pa_cookie - reg_resp = account_api.api_v1_accounts_post_with_http_info( - body={"email": email, "password": password, "subid": subscription_id} - ) - assert reg_resp.status_code == 201, ( - f"Registration failed with status code: {reg_resp.status_code}" - ) - - login_response = auth_api.api_v1_login_post_with_http_info( - body=RequestsLoginBody(email=email, password=password) - ) - assert login_response.status_code == 200, ( - f"Login failed with status code: {login_response.status_code}" - ) - cookie = login_response.headers.get("Set-Cookie") - assert cookie, "No session cookie returned after login" - - account_api.api_client.default_headers["Cookie"] = cookie - account = account_api.api_v1_accounts_current_get() - assert len(account.profiles) == 1 - return account, cookie, password, email + return create_account() # --------------------------------------------------------------------------- diff --git a/tests/libs/profile_helpers.py b/tests/libs/profile_helpers.py index 62213130..0e860b3d 100644 --- a/tests/libs/profile_helpers.py +++ b/tests/libs/profile_helpers.py @@ -34,10 +34,7 @@ # rather than break CI. REAL_HTTPS_HINTS_DOMAIN = "cloudflare.com" -TEST_DOMAIN = "test.com" -TEST_IP = "104.18.74.230" # AS13335 (Cloudflare, not in catalog) - -TEST_BLOCKLIST_ID = "hagezi_threat_intelligence_feeds_full" +from libs.constants import TEST_BLOCKLIST_ID # noqa: E402, F401 (re-export) # --------------------------------------------------------------------------- diff --git a/tests/libs/session.py b/tests/libs/session.py new file mode 100644 index 00000000..64dcfb6b --- /dev/null +++ b/tests/libs/session.py @@ -0,0 +1,203 @@ +"""ProfileSession — facade bundling a logged-in account, its cookie-authenticated +API access, and DNS resolution. + +Replaces the per-test ``ApiClient``/``ProfileApi``/``default_headers["Cookie"]`` +boilerplate. Typical use via the class-scoped ``user`` fixture from conftest: + + async def test_block(self, user): + pid = user.new_profile("my_case") + user.add_rule(pid, "block", "ads.example") + resp = await user.wait_for(pid, "ads.example", A, is_blocked) + assert_blocked(resp, "ads.example") + +For API calls the facade doesn't wrap, drop down to the raw client: + + with user.profiles_api() as p: + p.api_v1_profiles_id_logs_get_with_http_info(...) +""" + +import uuid +from contextlib import contextmanager +from dataclasses import dataclass +from typing import Any, Callable, Iterator, Optional + +import moddns.api as api +import moddns.api_client as client +import moddns.configuration as api_config +from moddns import ( + ApiBlocklistsUpdates, + ApiCreateProfileBody, + ApiServicesUpdates, + ModelProfileUpdate, + RequestsCreateProfileCustomRuleBody, + RequestsProfileUpdates, +) +from dns.message import Message + +from libs.accounts import create_account, delete_account +from libs.dns_lib import DNSLib +from libs.settings import Settings, get_settings + + +@dataclass +class ProfileSession: + """A logged-in test user: account, session cookie, API and DNS access.""" + + account: Any + cookie: str + password: str + email: str + config: Settings + dns: DNSLib + + @classmethod + def create(cls, **create_account_kwargs) -> "ProfileSession": + account, cookie, password, email = create_account(**create_account_kwargs) + config = get_settings() + return cls( + account=account, + cookie=cookie, + password=password, + email=email, + config=config, + dns=DNSLib(config.DOH_ENDPOINT), + ) + + # ------------------------------------------------------------------ + # API access + # ------------------------------------------------------------------ + @property + def default_profile_id(self) -> str: + """The profile created automatically at registration.""" + return self.account.profiles[0] + + @contextmanager + def profiles_api(self) -> Iterator[Any]: + """Cookie-authenticated ProfileApi for calls the facade doesn't wrap.""" + api_conf = api_config.Configuration(host=self.config.DNS_API_ADDR) + with client.ApiClient(api_conf) as api_client: + p = api.ProfileApi(api_client) + p.api_client.default_headers["Cookie"] = self.cookie + yield p + + # ------------------------------------------------------------------ + # Profile management + # ------------------------------------------------------------------ + def new_profile(self, name: Optional[str] = None) -> str: + """Create a fresh profile and return its id. + + A unique suffix is always appended — the API rejects duplicate profile + names per account, and parametrized tests re-enter with the same name. + The base is truncated so the result fits the API's 50-char name limit. + """ + suffix = f"-{uuid.uuid4().hex[:8]}" + unique_name = f"{(name or 'p')[: 50 - len(suffix)]}{suffix}" + with self.profiles_api() as p: + resp = p.api_v1_profiles_post_with_http_info( + body=ApiCreateProfileBody(name=unique_name) + ) + assert resp.status_code == 201, ( + f"Profile creation failed: {resp.status_code}" + ) + return resp.data.profile_id + + def get_profile(self, profile_id: str) -> Any: + with self.profiles_api() as p: + resp = p.api_v1_profiles_id_get_with_http_info(id=profile_id) + assert resp.status_code == 200, ( + f"Failed to get profile {profile_id}: {resp.status_code}" + ) + return resp.data + + def add_rule(self, profile_id: str, action: str, value: str) -> None: + with self.profiles_api() as p: + resp = p.api_v1_profiles_id_custom_rules_post_with_http_info( + id=profile_id, + body=RequestsCreateProfileCustomRuleBody(action=action, value=value), + ) + assert resp.status_code == 201, ( + f"Custom rule creation failed for {value}: {resp.status_code}" + ) + + def block_services(self, profile_id: str, service_ids: list) -> None: + with self.profiles_api() as p: + resp = p.api_v1_profiles_id_services_post_with_http_info( + id=profile_id, service_ids=ApiServicesUpdates(service_ids=service_ids) + ) + assert resp.status_code == 200, ( + f"Service block failed for {service_ids}: {resp.status_code}" + ) + + def unblock_services(self, profile_id: str, service_ids: list) -> None: + with self.profiles_api() as p: + resp = p.api_v1_profiles_id_services_delete_with_http_info( + id=profile_id, service_ids=ApiServicesUpdates(service_ids=service_ids) + ) + assert resp.status_code == 200, ( + f"Service unblock failed for {service_ids}: {resp.status_code}" + ) + + def enable_blocklists(self, profile_id: str, blocklist_ids: list) -> None: + with self.profiles_api() as p: + resp = p.api_v1_profiles_id_blocklists_post_with_http_info( + id=profile_id, + blocklist_ids=ApiBlocklistsUpdates(blocklist_ids=blocklist_ids), + ) + assert resp.status_code == 200, ( + f"Blocklist enable failed: {resp.status_code}" + ) + + def disable_blocklists(self, profile_id: str, blocklist_ids: list) -> None: + with self.profiles_api() as p: + resp = p.api_v1_profiles_id_blocklists_delete_with_http_info( + id=profile_id, + blocklist_ids=ApiBlocklistsUpdates(blocklist_ids=blocklist_ids), + ) + assert resp.status_code == 200, ( + f"Blocklist disable failed: {resp.status_code}" + ) + + def patch_setting(self, profile_id: str, path: str, value: Any) -> None: + """PATCH a single profile setting, e.g. + ``patch_setting(pid, "/settings/privacy/blocklists_subdomains_rule", "allow")``. + """ + with self.profiles_api() as p: + body = RequestsProfileUpdates( + updates=[ + ModelProfileUpdate( + operation="replace", path=path, value={"value": value} + ) + ] + ) + resp = p.api_v1_profiles_id_patch_with_http_info(profile_id, body=body) + assert resp.status_code == 200, ( + f"PATCH {path} failed: {resp.status_code}" + ) + + # ------------------------------------------------------------------ + # DNS + # ------------------------------------------------------------------ + async def resolve(self, profile_id: str, domain: str, record_type) -> Message: + return await self.dns.send_doh_request(profile_id, domain, record_type) + + async def wait_for( + self, + profile_id: str, + domain: str, + record_type, + predicate: Callable[[Message], bool], + **kwargs, + ) -> Message: + """``DNSLib.wait_until`` shorthand — see its docstring for when (not) + to poll.""" + return await self.dns.wait_until( + profile_id, domain, record_type, predicate, **kwargs + ) + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + def cleanup(self) -> None: + delete_account( + self.cookie, self.password, account_id=getattr(self.account, "id", "?") + ) From 9f0bef4b51e554270c7dbeccb4cf21798349f379 Mon Sep 17 00:00:00 2001 From: Maciek Date: Mon, 20 Jul 2026 14:36:54 +0200 Subject: [PATCH 06/67] refactor(e2e): Move generate_complex_password to libs/ Signed-off-by: Maciek --- tests/helpers.py | 29 ----------------------------- tests/libs/accounts.py | 22 +++++++++++++++++++++- 2 files changed, 21 insertions(+), 30 deletions(-) delete mode 100644 tests/helpers.py diff --git a/tests/helpers.py b/tests/helpers.py deleted file mode 100644 index e81c2a8d..00000000 --- a/tests/helpers.py +++ /dev/null @@ -1,29 +0,0 @@ -import string -import random - -def generate_complex_password(length: int = 16) -> str: - """ - Generate a random complex password with at least one uppercase letter, - one lowercase letter, one digit and one special character. - - Args: - length (int): The total length of the password (default: 16) - - Returns: - str: A random complex password - """ - # Ensure we have at least one of each required character type - password_chars = [ - random.choice(string.ascii_uppercase), # At least 1 uppercase - random.choice(string.ascii_lowercase), # At least 1 lowercase - random.choice(string.digits), # At least 1 digit - random.choice(string.punctuation) # At least 1 special char - ] - - # Add more random characters to reach the desired length - password_chars.extend(random.choice(string.ascii_letters + string.digits + string.punctuation) - for _ in range(length - 4)) - - # Shuffle to make it unpredictable - random.shuffle(password_chars) - return ''.join(password_chars) \ No newline at end of file diff --git a/tests/libs/accounts.py b/tests/libs/accounts.py index 45e17a96..c5cf9765 100644 --- a/tests/libs/accounts.py +++ b/tests/libs/accounts.py @@ -27,7 +27,6 @@ from moddns.models.requests_pa_session_req import RequestsPASessionReq from moddns.models.requests_rotate_pa_session_req import RequestsRotatePASessionReq -from helpers import generate_complex_password from libs.settings import get_settings @@ -35,6 +34,27 @@ def random_email(prefix: str = "test") -> str: return f"{prefix}{''.join(random.choice(string.digits) for _ in range(5))}@ivpn.net" +def generate_complex_password(length: int = 16) -> str: + """Generate a random password with at least one uppercase letter, one + lowercase letter, one digit and one special character. + + The API accepts any non-alphanumeric character as the special character + (OWASP guidance), so the full string.punctuation pool is safe. + """ + password_chars = [ + random.choice(string.ascii_uppercase), + random.choice(string.ascii_lowercase), + random.choice(string.digits), + random.choice(string.punctuation), + ] + password_chars.extend( + random.choice(string.ascii_letters + string.digits + string.punctuation) + for _ in range(length - 4) + ) + random.shuffle(password_chars) + return "".join(password_chars) + + def create_temp_subscription( validity_days: int = 30, *, From 4e30daf1d8124265a8fd55115280b4b6ec016337 Mon Sep 17 00:00:00 2001 From: Maciek Date: Mon, 20 Jul 2026 14:52:44 +0200 Subject: [PATCH 07/67] test(e2e): machine-parseable tableRef annotations, skip-compose flag, failover polling Signed-off-by: Maciek --- tests/conftest.py | 12 ++++ tests/dns_tests/infra/test_redis_failover.py | 70 +++++++++++-------- tests/dns_tests/test_cross_phase_filtering.py | 14 ++-- tests/dns_tests/test_ip_custom_rules.py | 2 +- tests/dns_tests/test_services.py | 10 +-- 5 files changed, 66 insertions(+), 42 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index fd3cd4ab..1d632220 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -169,6 +169,16 @@ def check_blocklists(): @pytest.fixture(scope="session") # autouse=True def start_compose(): + """Session-scoped compose lifecycle. + + Set ``TESTS_SKIP_COMPOSE=1`` to run against an already-running stack + (e.g. started manually with ``docker compose up``) — skips the build, + start, teardown, and container-log collection. Useful for running a + single test repeatedly without paying the compose round-trip. + """ + if os.getenv("TESTS_SKIP_COMPOSE") == "1": + yield None + return with DockerCompose("./", build=True, wait=True) as compose: yield compose @@ -183,6 +193,8 @@ def docker_logs(start_compose, request): # Get compose instance from the existing fixture compose = request.getfixturevalue("start_compose") + if compose is None: # TESTS_SKIP_COMPOSE=1 — external stack, no log access + return # Save logs for all containers save_container_logs(compose, logs_dir) diff --git a/tests/dns_tests/infra/test_redis_failover.py b/tests/dns_tests/infra/test_redis_failover.py index 6347651e..20c0d679 100644 --- a/tests/dns_tests/infra/test_redis_failover.py +++ b/tests/dns_tests/infra/test_redis_failover.py @@ -6,9 +6,13 @@ replica recovers. The proxy's DualClient health check runs every 3 s and requires 3 consecutive -failures before swapping (~9 s worst-case). We use 15 s waits to be safe. +failures before swapping (~9 s worst-case). Instead of fixed sleeps, tests +poll DNS resolution with a generous deadline — queries fail while the proxy +is still pointed at the dead replica and succeed once the swap completes, so +"first successful query" is the observable swap signal. """ +import asyncio import time import docker @@ -18,9 +22,10 @@ from libs.settings import get_settings REPLICA_CONTAINER = "redis-replica-dns" -# Health check: 3 failures × 3 s interval = ~9 s. Add generous margin. -FAILOVER_WAIT = 15 -RECOVERY_WAIT = 15 +# Health check: 3 failures × 3 s interval = ~9 s before the swap; poll with margin. +FAILOVER_TIMEOUT = 30.0 +RECOVERY_TIMEOUT = 30.0 +POLL_INTERVAL = 1.0 pytestmark = pytest.mark.redis_failover @@ -56,6 +61,25 @@ def teardown_class(self): def _get_replica(self): return self.docker_client.containers.get(REPLICA_CONTAINER) + async def _wait_dns_healthy(self, timeout: float, context: str): + """Poll until a DoH query returns an answer, tolerating errors while + the proxy's DualClient detects the topology change. Returns the first + healthy response; fails the test on deadline.""" + deadline = time.monotonic() + timeout + last_err = None + while time.monotonic() < deadline: + try: + resp = await self.dns_lib.send_doh_request( + self.profile_id, "example.com", "A" + ) + if resp.answer: + return resp + last_err = "empty answer" + except Exception as exc: # connection dropped mid-swap + last_err = exc + await asyncio.sleep(POLL_INTERVAL) + pytest.fail(f"{context}: DNS did not recover within {timeout}s (last: {last_err})") + @pytest.fixture(autouse=True) def _ensure_replica_running(self): """Guarantee the replica container is running after every test.""" @@ -65,8 +89,10 @@ def _ensure_replica_running(self): container.reload() if container.status != "running": container.start() - # Wait for replica to sync and proxy health check to detect recovery. - time.sleep(RECOVERY_WAIT) + # Wait for replica sync + proxy health-check recovery. + asyncio.run( + self._wait_dns_healthy(RECOVERY_TIMEOUT, "post-test replica restore") + ) @pytest.mark.asyncio async def test_proxy_falls_back_to_master_when_replica_stops(self): @@ -83,12 +109,9 @@ async def test_proxy_falls_back_to_master_when_replica_stops(self): # 2. Stop the read replica. self._get_replica().stop() - # 3. Wait for DualClient health check to detect the failure and swap. - time.sleep(FAILOVER_WAIT) - - # 4. Query must still succeed — now served via master. - resp = await self.dns_lib.send_doh_request( - self.profile_id, "example.com", "A" + # 3. Poll until the DualClient swaps to master and queries succeed again. + resp = await self._wait_dns_healthy( + FAILOVER_TIMEOUT, "fallback to master after replica stop" ) assert len(resp.answer) > 0, ( "DNS query failed after replica stop — fallback to master did not work" @@ -106,24 +129,13 @@ async def test_proxy_recovers_back_to_replica(self): ) assert len(resp.answer) > 0 - # 2. Stop replica → trigger failover to master. + # 2. Stop replica → poll until failover to master completes. self._get_replica().stop() - time.sleep(FAILOVER_WAIT) - - # 3. Verify queries work via master. - resp = await self.dns_lib.send_doh_request( - self.profile_id, "example.com", "A" - ) + resp = await self._wait_dns_healthy(FAILOVER_TIMEOUT, "fallback to master") assert len(resp.answer) > 0, "Fallback to master failed" - # 4. Restart replica. + # 3. Restart replica; poll until queries are healthy (proxy swaps back + # within one health-check cycle; master keeps serving meanwhile). self._get_replica().start() - time.sleep(RECOVERY_WAIT) - - # 5. Verify queries still work — proxy should have switched back. - resp = await self.dns_lib.send_doh_request( - self.profile_id, "example.com", "A" - ) - assert len(resp.answer) > 0, ( - "DNS query failed after replica recovery" - ) + resp = await self._wait_dns_healthy(RECOVERY_TIMEOUT, "replica recovery") + assert len(resp.answer) > 0, "DNS query failed after replica recovery" diff --git a/tests/dns_tests/test_cross_phase_filtering.py b/tests/dns_tests/test_cross_phase_filtering.py index 75429868..108a54c7 100644 --- a/tests/dns_tests/test_cross_phase_filtering.py +++ b/tests/dns_tests/test_cross_phase_filtering.py @@ -37,7 +37,7 @@ class TestCrossPhaseAggregation: async def test_domain_allow_overrides_services_block(self, user): """Domain custom allow + services block -> Processed. Domain Allow (T200) overrides services block (T100) through - unified cross-phase aggregation. Behaviour table #8.""" + unified cross-phase aggregation. tableRef: #8.""" with user.profiles_api() as p: if not await services_available(user.dns, p, user.cookie): pytest.skip("Services/ASN blocking not available") @@ -58,7 +58,7 @@ async def test_domain_allow_overrides_services_block(self, user): async def test_domain_allow_overrides_ip_block(self, user): """Domain custom allow + IP custom block -> Processed. Domain Allow (T200) overrides IP custom block (T200) — Allow - always wins. Behaviour table #9.""" + always wins. tableRef: #9.""" profile_id = user.new_profile("cross_phase_9") user.add_rule(profile_id, "allow", RESOLVABLE_TEST_DOMAIN) @@ -77,7 +77,7 @@ async def test_domain_allow_overrides_blocklist_and_ip_block( ): """BL block + domain CR allow + IP CR block -> Processed. Domain Allow (T200) overrides both blocklist (T100) and IP - custom block (T200). Behaviour table #15.""" + custom block (T200). tableRef: #15.""" ensure_domain_blocklisted(RESOLVABLE_TEST_DOMAIN) profile_id = user.new_profile("cross_phase_15") # Default blocklist (TEST_BLOCKLIST_ID) is already enabled on new profiles. @@ -98,7 +98,7 @@ async def test_domain_allow_overrides_blocklist_and_services_block( ): """BL block + domain CR allow + services block -> Processed. Domain Allow (T200) overrides both blocklist (T100) and services - block (T100). Behaviour table #14.""" + block (T100). tableRef: #14.""" ensure_domain_blocklisted(SVC_GOOGLE_DOMAIN) with user.profiles_api() as p: if not await services_available(user.dns, p, user.cookie): @@ -120,7 +120,7 @@ async def test_domain_allow_overrides_blocklist_and_services_block( @pytest.mark.asyncio async def test_ip_allow_overrides_services_with_domain_allow(self, user): """Domain allow + services block + IP allow -> Processed. - Both domain and IP allow, services blocked. Table #12.""" + Both domain and IP allow, services blocked. tableRef: #12.""" with user.profiles_api() as p: if not await services_available(user.dns, p, user.cookie): pytest.skip("Services/ASN blocking not available") @@ -150,7 +150,7 @@ class TestDomainBlockTerminal: async def test_domain_block_ignores_ip_allow(self, user): """Domain CR block + IP CR allow -> Blocked. IP allow can't fire because domain block prevents upstream resolution - (no response IPs to match). Table #24.""" + (no response IPs to match). tableRef: #24.""" profile_id = user.new_profile("terminal_24") user.add_rule(profile_id, "block", RESOLVABLE_TEST_DOMAIN) @@ -168,7 +168,7 @@ async def test_blocklist_block_ignores_ip_allow( self, user, ensure_domain_blocklisted ): """BL block (no domain CR allow to override) + IP CR allow -> Blocked. - Table #19 variant with IP allow configured.""" + tableRef: #19 variant with IP allow configured.""" ensure_domain_blocklisted(RESOLVABLE_TEST_DOMAIN) profile_id = user.new_profile("terminal_bl_19") # Default blocklist (TEST_BLOCKLIST_ID) is already enabled on new profiles. diff --git a/tests/dns_tests/test_ip_custom_rules.py b/tests/dns_tests/test_ip_custom_rules.py index 83a7121f..f6bb3a25 100644 --- a/tests/dns_tests/test_ip_custom_rules.py +++ b/tests/dns_tests/test_ip_custom_rules.py @@ -116,7 +116,7 @@ async def test_domain_allow_overrides_ip_block(self, user): domain allow wins through unified cross-phase aggregation. Domain Allow (T200) overrides IP custom block (T200) — any Allow - present wins. Behaviour table #9. + present wins. tableRef: #9. """ profile_id = user.new_profile("domain_allow_ip_block") # Allow the domain explicitly. diff --git a/tests/dns_tests/test_services.py b/tests/dns_tests/test_services.py index 06f59e3b..c0e2a52e 100644 --- a/tests/dns_tests/test_services.py +++ b/tests/dns_tests/test_services.py @@ -75,7 +75,7 @@ class TestServicesBlocking: ) async def test_services_block_by_asn(self, user, service_id, domain): """Blocking a service blocks every domain resolving into its ASN set. - Behaviour table #2. Each parametrized service resolves to an IP in the + tableRef: #2. Each parametrized service resolves to an IP in the service's ASN and must come back as the block sentinel (0.0.0.0): - google: svctest-google.com -> 8.8.8.8 (AS15169), pinned/deterministic. @@ -107,7 +107,7 @@ async def test_services_block_does_not_affect_other_asn(self, user, service_id): """Blocking the google service (by canonical ID or alias) must NOT over-block: test.com (Cloudflare AS13335) stays resolvable — a different ASN is unaffected. - Behaviour table #1 (no rules matched in IP phase).""" + tableRef: #1 (no rules matched in IP phase).""" with user.profiles_api() as p: if not await services_available(user.dns, p, user.cookie): pytest.skip("Services/ASN blocking not available") @@ -159,7 +159,7 @@ class TestIPAllowOverridesServices: @pytest.mark.asyncio async def test_ip_allow_overrides_services_block(self, user): """Services block + IP allow for the resolved IP -> Processed. - IP custom rule (T200) overrides services (T100). Table #6.""" + IP custom rule (T200) overrides services (T100). tableRef: #6.""" with user.profiles_api() as p: if not await services_available(user.dns, p, user.cookie): pytest.skip("Services/ASN blocking not available") @@ -184,7 +184,7 @@ class TestASNCustomRules: @pytest.mark.asyncio async def test_asn_custom_block(self, user): """Block ASN 15169 (Google) -> svctest-google.com should return 0.0.0.0. - Table #3 variant (IP CR block via ASN syntax).""" + tableRef: #3 variant (IP CR block via ASN syntax).""" profile_id = user.new_profile("asn_block") user.add_rule(profile_id, "block", "AS15169") @@ -204,7 +204,7 @@ async def test_asn_custom_block_does_not_affect_other_asn(self, user): @pytest.mark.asyncio async def test_asn_allow_overrides_services_block(self, user): """Services block + ASN allow -> Processed. - ASN custom allow (T200) overrides services block (T100). Table #6 variant.""" + ASN custom allow (T200) overrides services block (T100). tableRef: #6 variant.""" with user.profiles_api() as p: if not await services_available(user.dns, p, user.cookie): pytest.skip("Services/ASN blocking not available") From a5ede334453072b64f8d58b00db1e20519260e46 Mon Sep 17 00:00:00 2001 From: Maciek Date: Mon, 20 Jul 2026 14:53:20 +0200 Subject: [PATCH 08/67] test(e2e): run Redis failover tests as a dedicated CI step Signed-off-by: Maciek --- .github/workflows/integration_tests.yml | 6 ++++++ tests/Makefile | 3 +++ 2 files changed, 9 insertions(+) diff --git a/.github/workflows/integration_tests.yml b/.github/workflows/integration_tests.yml index 64e2e699..b6f42daf 100644 --- a/.github/workflows/integration_tests.yml +++ b/.github/workflows/integration_tests.yml @@ -66,3 +66,9 @@ jobs: - name: Run backend E2E tests run: cd tests/; make test_ci + + # Destructive (stops/starts the Redis replica container), so it is + # excluded from the default invocation via pytest addopts and runs + # here as its own session with a fresh compose stack. + - name: Run Redis failover backend E2E tests + run: cd tests/; make test_failover diff --git a/tests/Makefile b/tests/Makefile index f23bf272..a851526c 100644 --- a/tests/Makefile +++ b/tests/Makefile @@ -10,6 +10,9 @@ install_test_dependencies: ## Install the test dependencies test_ci: ## Run the tests in CI mode pytest -s dns_tests/ +test_failover: ## Run the destructive Redis failover tests (excluded from test_ci by default addopts) + pytest -s -m redis_failover dns_tests/infra/ + venv: source venv/bin/activate From 61028bd0e301ee9353f3892cef27c347f46d4376 Mon Sep 17 00:00:00 2001 From: Maciek Date: Wed, 1 Jul 2026 15:35:34 +0200 Subject: [PATCH 09/67] feat(app): Expand Query Logs card Signed-off-by: Maciek --- .../e2e/logs/logs-mobile-overflow.spec.ts | 185 +++++++++++++++ app/src/__tests__/unit/QueryLogCard.test.tsx | 118 ++++++++-- app/src/__tests__/unit/ReasonBadges.test.tsx | 65 ++++++ .../__tests__/unit/lib/formatReasons.test.ts | 99 ++++++++ app/src/components/ui/ReasonBadges.tsx | 58 +++++ app/src/lib/formatReasons.ts | 105 +++++++++ app/src/lib/utils.ts | 4 + app/src/pages/logs/Logs.tsx | 69 +++++- app/src/pages/logs/QueryLogCard.tsx | 220 +++++++++++------- app/src/pages/setup/SetupScreen.tsx | 53 +++-- app/src/store/general.ts | 5 + 11 files changed, 861 insertions(+), 120 deletions(-) create mode 100644 app/src/__tests__/unit/ReasonBadges.test.tsx create mode 100644 app/src/__tests__/unit/lib/formatReasons.test.ts create mode 100644 app/src/components/ui/ReasonBadges.tsx create mode 100644 app/src/lib/formatReasons.ts diff --git a/app/src/__tests__/e2e/logs/logs-mobile-overflow.spec.ts b/app/src/__tests__/e2e/logs/logs-mobile-overflow.spec.ts index e2271922..9d072ac1 100644 --- a/app/src/__tests__/e2e/logs/logs-mobile-overflow.spec.ts +++ b/app/src/__tests__/e2e/logs/logs-mobile-overflow.spec.ts @@ -70,4 +70,189 @@ test.describe('Logs mobile layout', () => { }); expect(hasHorizontalScrollbar).toBeFalsy(); }); + + test('whole-card expansion: every row expands, quick-rule is excluded, no overflow with long labels', async ({ page }) => { + await registerMocks(page, { + authenticated: true, + customProfiles: [{ id: 'prof1', profile_id: 'prof1', name: 'Default', settings: { logs: { enabled: true } } }] + }); + + // Register the logs route AFTER registerMocks so it is tested BEFORE the catch-all + // route (Playwright matches routes in reverse registration order). The catch-all in + // registerMocks matches `/api/v1/profiles` and would otherwise shadow this endpoint, + // returning the profiles array instead of our logs payload. + const now = new Date().toISOString(); + const items = [ + // Blocked row WITH reasons — deliberately long ids to challenge layout / overflow + { + profile_id: 'prof1', + timestamp: now, + status: 'blocked', + protocol: 'dns', + device_id: 'device-with-reasons', + client_ip: '10.0.0.1', + dns_request: { domain: 'blocked-with-reasons.example-longdomainforlayout-validation.test' }, + reasons: [ + 'blocklist: very-long-blocklist-identifier-xxxxxxxxxxxxxxxxxxxx', + 'service: another-long-service-id-yyyyyyyyyyyyyyyyyyyy' + ] + }, + // Processed row WITHOUT reasons — now also expandable (detail grid, no reasons block) + { + profile_id: 'prof1', + timestamp: now, + status: 'processed', + protocol: 'dns', + device_id: 'device-no-reasons', + client_ip: '10.0.0.2', + dns_request: { domain: 'processed-no-reasons.example.test' } + } + ]; + await page.route(/\/api\/v1\/profiles\/prof1\/logs/i, route => { + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(items) }); + }); + + await page.goto('/query-logs'); + + const scrollContainer = page.getByTestId('logs-scroll-container'); + await scrollContainer.first().waitFor({ state: 'attached', timeout: 10000 }); + + // Every row is expandable now → one toggle + one panel per mocked row (2). + const toggles = page.getByTestId('querylog-card-toggle'); + await expect(toggles).toHaveCount(items.length); + const panels = page.getByTestId('querylog-expanded-panel'); + await expect(panels).toHaveCount(items.length); + + const firstPanel = panels.nth(0); + const secondPanel = panels.nth(1); + + // Collapsed initial state + await expect(toggles.nth(0)).toHaveAttribute('aria-expanded', 'false'); + await expect(firstPanel).toHaveAttribute('data-expanded', 'false'); + + // Quick-rule button is excluded from the overlay: clicking it must NOT expand the card. + await page.getByTestId('logs-quick-rule-button').nth(0).click(); + await expect(firstPanel).toHaveAttribute('data-expanded', 'false'); + // Close any sheet the quick-rule action opened so it doesn't cover the cards below. + await page.keyboard.press('Escape'); + + // Keyboard: focus the first card's toggle and press Enter to expand. + await toggles.nth(0).focus(); + await page.keyboard.press('Enter'); + await expect(toggles.nth(0)).toHaveAttribute('aria-expanded', 'true'); + await expect(firstPanel).toHaveAttribute('data-expanded', 'true'); + + // Expanded panel shows the detail grid (protocol + timestamp always render) and the reasons block. + await expect(firstPanel.getByTestId('querylog-detail-grid')).toBeVisible(); + await expect(firstPanel.getByTestId('querylog-detail-protocol')).toBeVisible(); + await expect(firstPanel.getByTestId('querylog-detail-timestamp')).toBeVisible(); + await expect(firstPanel.getByTestId('querylog-reasons')).toBeVisible(); + + // The processed (no-reasons) row expands too: detail grid visible, but no reasons block. + await toggles.nth(1).click(); + await expect(secondPanel).toHaveAttribute('data-expanded', 'true'); + await expect(secondPanel.getByTestId('querylog-detail-grid')).toBeVisible(); + await expect(secondPanel.getByTestId('querylog-reasons')).toHaveCount(0); + + // Re-run overflow assertions AFTER expansion, with the long labels rendered + const result = await page.evaluate(() => { + const docEl = document.documentElement; + const body = document.body; + const vw = window.innerWidth; + const sc = document.querySelector('[data-testid="logs-scroll-container"]') as HTMLElement | null; + const scrollWidthDoc = Math.max( + body.scrollWidth, + docEl.scrollWidth, + body.offsetWidth, + docEl.offsetWidth + ); + const scrollingElWidth = document.scrollingElement ? document.scrollingElement.scrollWidth : docEl.scrollWidth; + const scOverflow = sc ? sc.scrollWidth - sc.clientWidth : 0; + return { vw, scrollWidthDoc, scrollingElWidth, scOverflow }; + }); + expect(result.scrollingElWidth).toBeLessThanOrEqual(result.vw + 1); + expect(result.scrollWidthDoc).toBeLessThanOrEqual(result.vw + 1); + expect(result.scOverflow).toBeLessThanOrEqual(1); + + // The expanded panel's bounding box must sit within the viewport horizontally + const viewport = page.viewportSize(); + const viewportWidth = viewport?.width ?? result.vw; + const box = await firstPanel.boundingBox(); + expect(box, 'Expected expanded panel to have a bounding box').not.toBeNull(); + if (box) { + expect(box.x).toBeGreaterThanOrEqual(0); + expect(box.x + box.width).toBeLessThanOrEqual(viewportWidth + 1); + } + }); + + test('expanded card collapses when clicking anywhere, including the expanded panel', async ({ page }) => { + await registerMocks(page, { + authenticated: true, + customProfiles: [{ id: 'prof1', profile_id: 'prof1', name: 'Default', settings: { logs: { enabled: true } } }] + }); + const now = new Date().toISOString(); + const items = [ + { + profile_id: 'prof1', timestamp: now, status: 'blocked', protocol: 'dns', + device_id: 'd1', client_ip: '10.0.0.1', + dns_request: { domain: 'blocked.example.test' }, + reasons: ['blocklist: some-blocklist-id'] + } + ]; + await page.route(/\/api\/v1\/profiles\/prof1\/logs/i, route => { + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(items) }); + }); + + await page.goto('/query-logs'); + await page.getByTestId('logs-scroll-container').first().waitFor({ state: 'attached', timeout: 10000 }); + + const toggle = page.getByTestId('querylog-card-toggle').first(); + const panel = page.getByTestId('querylog-expanded-panel').first(); + + // Expand. + await toggle.click(); + await expect(panel).toHaveAttribute('data-expanded', 'true'); + + // Click inside the EXPANDED PANEL region (below the header) — must collapse the card. + const box = await panel.boundingBox(); + expect(box, 'Expected an expanded panel bounding box').not.toBeNull(); + if (box) { + await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2); + } + await expect(panel).toHaveAttribute('data-expanded', 'false'); + }); + + test('mobile: one-time expand hint shows, dismisses after first expand, and stays gone', async ({ page }, testInfo) => { + // The hint is mobile-only (md:hidden); skip on desktop projects. + test.skip(!/(chromium-mobile|iphone15pro)/i.test(testInfo.project.name), 'mobile-only hint'); + + await registerMocks(page, { + authenticated: true, + customProfiles: [{ id: 'prof1', profile_id: 'prof1', name: 'Default', settings: { logs: { enabled: true } } }] + }); + const now = new Date().toISOString(); + const items = [ + { profile_id: 'prof1', timestamp: now, status: 'processed', protocol: 'dns', device_id: 'd1', client_ip: '10.0.0.1', dns_request: { domain: 'a.example.test' } }, + { profile_id: 'prof1', timestamp: now, status: 'blocked', protocol: 'dns', device_id: 'd2', client_ip: '10.0.0.2', dns_request: { domain: 'b.example.test' } } + ]; + await page.route(/\/api\/v1\/profiles\/prof1\/logs/i, route => { + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(items) }); + }); + + await page.goto('/query-logs'); + await page.getByTestId('logs-scroll-container').first().waitFor({ state: 'attached', timeout: 10000 }); + + // Hint is visible on first visit. + const hint = page.getByTestId('logs-expand-hint'); + await expect(hint).toBeVisible(); + + // Expanding a row dismisses the hint. + await page.getByTestId('querylog-card-toggle').first().click(); + await expect(hint).toHaveCount(0); + + // Persisted: reload keeps it gone. + await page.reload(); + await page.getByTestId('logs-scroll-container').first().waitFor({ state: 'attached', timeout: 10000 }); + await expect(page.getByTestId('logs-expand-hint')).toHaveCount(0); + }); }); diff --git a/app/src/__tests__/unit/QueryLogCard.test.tsx b/app/src/__tests__/unit/QueryLogCard.test.tsx index 7fd98c56..d91da9fe 100644 --- a/app/src/__tests__/unit/QueryLogCard.test.tsx +++ b/app/src/__tests__/unit/QueryLogCard.test.tsx @@ -25,7 +25,7 @@ function stubDesktopMatchMedia(isDesktop: boolean) { }; } -describe('QueryLogCard truncation interactions', () => { +describe('QueryLogCard truncation display', () => { beforeEach(() => { // Reset viewport width // Override viewport width for desktop simulation @@ -49,9 +49,6 @@ describe('QueryLogCard truncation interactions', () => { expect(fullEl).toHaveTextContent(deviceId); expect(fullEl.textContent).toHaveLength(deviceId.length); expect(fullEl.textContent?.endsWith('…')).toBeFalsy(); - // Tooltip still present wrapping element; hover should not change content - fireEvent.mouseEnter(fullEl); - expect(fullEl).toHaveTextContent(deviceId); }); test('desktop domain display strips trailing dot', () => { @@ -71,7 +68,7 @@ describe('QueryLogCard truncation interactions', () => { expect(domainSpan).not.toHaveTextContent(/\.$/); }); - test('mobile tap expands truncated domain (threshold 65)', () => { + test('mobile renders a static truncated domain span (no tap-to-reveal)', () => { stubDesktopMatchMedia(false); // Override viewport width for mobile simulation (window as unknown as { innerWidth: number }).innerWidth = 375; @@ -87,13 +84,109 @@ describe('QueryLogCard truncation interactions', () => { dns_request: { domain: longDomain } }; render(); - const truncatedDomainBtn = screen.getByTestId('querylog-domain-truncated'); - expect(truncatedDomainBtn).toBeInTheDocument(); - // Verify it contains ellipsis at end - expect(truncatedDomainBtn.textContent).toMatch(/…$/); - fireEvent.click(truncatedDomainBtn); - const fullDomainSpan = screen.getByTestId('querylog-domain-full'); - expect(fullDomainSpan).toHaveTextContent(longDomain); + const truncatedDomain = screen.getByTestId('querylog-domain-truncated'); + expect(truncatedDomain).toBeInTheDocument(); + // Static truncated text ends with an ellipsis; it is a plain span (not a button). + expect(truncatedDomain.textContent).toMatch(/…$/); + expect(truncatedDomain.tagName).toBe('SPAN'); + }); +}); + +describe('QueryLogCard whole-card expansion', () => { + beforeEach(() => { + (window as unknown as { innerWidth: number }).innerWidth = 1440; + stubDesktopMatchMedia(true); + }); + + const baseLog: ModelQueryLog = { + profile_id: 'p-exp', + timestamp: '2026-06-15T10:20:30.000Z', + status: 'processed', + protocol: 'dns', + device_id: 'expand-device', + client_ip: '10.0.0.9', + dns_request: { domain: 'expand.example.com', query_type: 'A', response_code: 'NOERROR', dnssec: true } + }; + + test('renders the whole-card toggle', () => { + render(); + expect(screen.getByTestId('querylog-card-toggle')).toBeInTheDocument(); + }); + + test('clicking the toggle flips the expanded panel state', () => { + render(); + const toggle = screen.getByTestId('querylog-card-toggle'); + const panel = screen.getByTestId('querylog-expanded-panel'); + expect(panel).toHaveAttribute('data-expanded', 'false'); + expect(toggle).toHaveAttribute('aria-expanded', 'false'); + fireEvent.click(toggle); + expect(panel).toHaveAttribute('data-expanded', 'true'); + expect(toggle).toHaveAttribute('aria-expanded', 'true'); + }); + + test('expanded panel shows the detail grid with protocol and timestamp', () => { + render(); + fireEvent.click(screen.getByTestId('querylog-card-toggle')); + expect(screen.getByTestId('querylog-detail-grid')).toBeInTheDocument(); + expect(screen.getByTestId('querylog-detail-protocol')).toHaveTextContent('DNS'); + expect(screen.getByTestId('querylog-detail-timestamp')).toBeInTheDocument(); + }); + + test('row with reasons renders the reasons block', () => { + const log: ModelQueryLog = { + ...baseLog, + status: 'blocked', + reasons: ['blocklist: some-blocklist-id'] + }; + render(); + fireEvent.click(screen.getByTestId('querylog-card-toggle')); + expect(screen.getByTestId('querylog-reasons')).toBeInTheDocument(); + }); + + test('row without reasons omits the reasons block but still expands', () => { + render(); + fireEvent.click(screen.getByTestId('querylog-card-toggle')); + expect(screen.getByTestId('querylog-detail-grid')).toBeInTheDocument(); + expect(screen.queryByTestId('querylog-reasons')).not.toBeInTheDocument(); + }); + + test('domain-logging-disabled row is still expandable and shows a placeholder', () => { + const log: ModelQueryLog = { + ...baseLog, + dns_request: undefined as unknown as ModelQueryLog['dns_request'] + }; + render(); + fireEvent.click(screen.getByTestId('querylog-card-toggle')); + expect(screen.getByTestId('querylog-detail-domain')).toHaveTextContent('Domain logging disabled'); + }); + + test('there is no visible chevron indicator', () => { + render(); + expect(screen.queryByTestId('querylog-expand-indicator')).not.toBeInTheDocument(); + }); + + test('onExpand fires only when expanding (not when collapsing)', () => { + const onExpand = vi.fn(); + render(); + const toggle = screen.getByTestId('querylog-card-toggle'); + fireEvent.click(toggle); // expand + expect(onExpand).toHaveBeenCalledTimes(1); + fireEvent.click(toggle); // collapse + expect(onExpand).toHaveBeenCalledTimes(1); + }); + + test('shows the DNSSEC badge on the collapsed row when validated', () => { + render(); // baseLog has dns_request.dnssec === true + expect(screen.getByTestId('querylog-dnssec-badge')).toHaveTextContent('DNSSEC'); + }); + + test('omits the DNSSEC badge when not validated', () => { + const log: ModelQueryLog = { + ...baseLog, + dns_request: { ...baseLog.dns_request, dnssec: false } + }; + render(); + expect(screen.queryByTestId('querylog-dnssec-badge')).not.toBeInTheDocument(); }); }); @@ -141,4 +234,3 @@ describe('QueryLogCard quick rule button', () => { expect(onQuickRule).not.toHaveBeenCalled(); }); }); - diff --git a/app/src/__tests__/unit/ReasonBadges.test.tsx b/app/src/__tests__/unit/ReasonBadges.test.tsx new file mode 100644 index 00000000..d0ed0f5d --- /dev/null +++ b/app/src/__tests__/unit/ReasonBadges.test.tsx @@ -0,0 +1,65 @@ +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { describe, test, expect } from 'vitest'; +import { ReasonBadges } from '@/components/ui/ReasonBadges'; + +const blocklistNames = { 'hagezi-tif': 'HaGeZi TIF', x: 'Blocklist X' }; +const serviceNames = { tiktok: 'TikTok' }; + +describe('ReasonBadges', () => { + test('renders resolved blocklist and service names', () => { + render( + + ); + const badges = screen.getAllByTestId('querylog-reason-badge'); + expect(badges).toHaveLength(2); + expect(badges[0]).toHaveTextContent('Blocklist: HaGeZi TIF'); + expect(badges[1]).toHaveTextContent('Service: TikTok'); + }); + + test('falls back to the raw id when the name map has no entry', () => { + render(); + expect(screen.getByTestId('querylog-reason-badge')).toHaveTextContent('Blocklist: unknown-id'); + }); + + test('renders nothing when there are no reasons', () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + expect(screen.queryByTestId('querylog-reason-badge')).not.toBeInTheDocument(); + }); + + test('collapses more than three chips into a +N overflow chip', () => { + render( + + ); + // 4 formatted chips → 3 visible + 1 overflow chip + expect(screen.getAllByTestId('querylog-reason-badge')).toHaveLength(3); + const overflow = screen.getByTestId('querylog-reason-badge-overflow'); + expect(overflow).toHaveTextContent('+1'); + }); + + test('does not render an overflow chip when there are three or fewer chips', () => { + render( + + ); + expect(screen.getAllByTestId('querylog-reason-badge')).toHaveLength(3); + expect(screen.queryByTestId('querylog-reason-badge-overflow')).not.toBeInTheDocument(); + }); +}); diff --git a/app/src/__tests__/unit/lib/formatReasons.test.ts b/app/src/__tests__/unit/lib/formatReasons.test.ts new file mode 100644 index 00000000..944a012a --- /dev/null +++ b/app/src/__tests__/unit/lib/formatReasons.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect } from 'vitest'; +import { formatReasons } from '@/lib/formatReasons'; + +const blocklistNames = { 'hagezi-tif': 'HaGeZi TIF', 'x': 'Blocklist X' }; +const serviceNames = { 'tiktok': 'TikTok', 'y': 'Service Y' }; + +describe('formatReasons', () => { + it('maps a specific blocklist id to a resolved name', () => { + // tableRef: logs-reason-display-behaviour #1 + expect(formatReasons(['blocklist: hagezi-tif'], blocklistNames, serviceNames)).toEqual([ + { kind: 'blocklist', label: 'Blocklist: HaGeZi TIF' }, + ]); + }); + + it('renders a generic Blocklist chip when only the generic token is present', () => { + // tableRef: logs-reason-display-behaviour #2 + expect(formatReasons(['blocklists'], blocklistNames, serviceNames)).toEqual([ + { kind: 'blocklist', label: 'Blocklist' }, + ]); + }); + + it('collapses generic + specific blocklist into the specific chip', () => { + // tableRef: logs-reason-display-behaviour #3 + expect(formatReasons(['blocklists', 'blocklist: x'], blocklistNames, serviceNames)).toEqual([ + { kind: 'blocklist', label: 'Blocklist: Blocklist X' }, + ]); + }); + + it('folds the subdomain rule into the blocklist chip as a qualifier', () => { + // tableRef: logs-reason-display-behaviour #4 + expect( + formatReasons(['blocklist: x', 'blocklists_subdomains_rule'], blocklistNames, serviceNames) + ).toEqual([{ kind: 'blocklist', label: 'Blocklist: Blocklist X (subdomain)' }]); + }); + + it('maps a specific service id to a resolved name', () => { + // tableRef: logs-reason-display-behaviour #5 + expect(formatReasons(['service: tiktok'], blocklistNames, serviceNames)).toEqual([ + { kind: 'service', label: 'Service: TikTok' }, + ]); + }); + + it('renders a generic Service chip when only the generic token is present', () => { + // tableRef: logs-reason-display-behaviour #6 + expect(formatReasons(['services'], blocklistNames, serviceNames)).toEqual([ + { kind: 'service', label: 'Service' }, + ]); + }); + + it('collapses generic + specific service into the specific chip', () => { + // tableRef: logs-reason-display-behaviour #7 + expect(formatReasons(['services', 'service: y'], blocklistNames, serviceNames)).toEqual([ + { kind: 'service', label: 'Service: Service Y' }, + ]); + }); + + it('maps custom_rules to a Custom rule chip', () => { + // tableRef: logs-reason-display-behaviour #8 + expect(formatReasons(['custom_rules'], blocklistNames, serviceNames)).toEqual([ + { kind: 'custom_rule', label: 'Custom rule' }, + ]); + }); + + it('maps default_rule to a Default rule chip', () => { + // tableRef: logs-reason-display-behaviour #9 + expect(formatReasons(['default_rule'], blocklistNames, serviceNames)).toEqual([ + { kind: 'default', label: 'Default rule' }, + ]); + }); + + it('renders nothing for empty input', () => { + // tableRef: logs-reason-display-behaviour #10 + expect(formatReasons([], blocklistNames, serviceNames)).toEqual([]); + }); + + it('renders multiple same-tier chips in a stable order (blocklist then service)', () => { + // tableRef: logs-reason-display-behaviour #11 + expect( + formatReasons(['service: y', 'blocklist: x'], blocklistNames, serviceNames) + ).toEqual([ + { kind: 'blocklist', label: 'Blocklist: Blocklist X' }, + { kind: 'service', label: 'Service: Service Y' }, + ]); + }); + + it('falls back to the raw id when the name map has no entry', () => { + // tableRef: logs-reason-display-behaviour #12 + expect(formatReasons(['blocklist: unknown-id'], blocklistNames, serviceNames)).toEqual([ + { kind: 'blocklist', label: 'Blocklist: unknown-id' }, + ]); + }); + + it('works without name maps, falling back to raw ids', () => { + // tableRef: logs-reason-display-behaviour #12 + expect(formatReasons(['service: some-svc'])).toEqual([ + { kind: 'service', label: 'Service: some-svc' }, + ]); + }); +}); diff --git a/app/src/components/ui/ReasonBadges.tsx b/app/src/components/ui/ReasonBadges.tsx new file mode 100644 index 00000000..504dba79 --- /dev/null +++ b/app/src/components/ui/ReasonBadges.tsx @@ -0,0 +1,58 @@ +import * as React from "react"; +import { Badge } from "@/components/ui/badge"; +import { Tooltip } from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; +import { formatReasons } from "@/lib/formatReasons"; + +interface ReasonBadgesProps { + reasons: string[]; + blocklistNames?: Record; + serviceNames?: Record; + className?: string; +} + +// Show at most this many chips inline; the remainder collapse into a "+N" chip. +const MAX_VISIBLE = 3; + +/** + * Render query-log reason tokens as human-readable chips. + * + * Mapping is delegated to `formatReasons` (see + * docs/specs/logs-reason-display-behaviour.md). Overflow beyond MAX_VISIBLE + * chips collapses into a tooltip-backed "+N" chip. + */ +export function ReasonBadges({ reasons, blocklistNames, serviceNames, className }: ReasonBadgesProps) { + const formatted = formatReasons(reasons, blocklistNames, serviceNames); + if (formatted.length === 0) return null; + + const visible = formatted.slice(0, MAX_VISIBLE); + const overflow = formatted.slice(MAX_VISIBLE); + + return ( +
+ {visible.map((reason, i) => ( + + {reason.label} + + ))} + {overflow.length > 0 && ( + r.label).join(", ")}> + + +{overflow.length} + + + )} +
+ ); +} + +export default ReasonBadges; diff --git a/app/src/lib/formatReasons.ts b/app/src/lib/formatReasons.ts new file mode 100644 index 00000000..36b91933 --- /dev/null +++ b/app/src/lib/formatReasons.ts @@ -0,0 +1,105 @@ +// formatReasons — map raw proxy reason tokens to human-readable chips. +// +// Source of truth: docs/specs/logs-reason-display-behaviour.md +// If the chip mapping changes, update that spec and formatReasons.test.ts with +// matching `tableRef: logs-reason-display-behaviour #N` annotations. + +export type ReasonKind = 'blocklist' | 'service' | 'custom_rule' | 'default' | 'subdomain'; + +export interface FormattedReason { + kind: ReasonKind; + label: string; +} + +const BLOCKLIST_PREFIX = 'blocklist: '; +const SERVICE_PREFIX = 'service: '; + +/** + * Convert stored proxy reason tokens into de-duplicated, ordered display chips. + * + * @param reasons Raw tokens from `ModelQueryLog.reasons` (order not assumed). + * @param blocklistNames Optional id → display-name map for `blocklist: `. + * @param serviceNames Optional id → display-name map for `service: `. + */ +export function formatReasons( + reasons: string[], + blocklistNames?: Record, + serviceNames?: Record, +): FormattedReason[] { + if (!reasons || reasons.length === 0) return []; + + // First-seen order preserved; a Set guards against duplicate ids. + const blocklistIds: string[] = []; + const blocklistIdSet = new Set(); + const serviceIds: string[] = []; + const serviceIdSet = new Set(); + let hasGenericBlocklist = false; + let hasGenericService = false; + let hasSubdomain = false; + let hasCustomRule = false; + let hasDefault = false; + + for (const reason of reasons) { + if (reason.startsWith(BLOCKLIST_PREFIX)) { + const id = reason.slice(BLOCKLIST_PREFIX.length); + if (!blocklistIdSet.has(id)) { + blocklistIdSet.add(id); + blocklistIds.push(id); + } + } else if (reason === 'blocklists') { + hasGenericBlocklist = true; + } else if (reason === 'blocklists_subdomains_rule') { + hasSubdomain = true; + } else if (reason.startsWith(SERVICE_PREFIX)) { + const id = reason.slice(SERVICE_PREFIX.length); + if (!serviceIdSet.has(id)) { + serviceIdSet.add(id); + serviceIds.push(id); + } + } else if (reason === 'services') { + hasGenericService = true; + } else if (reason === 'custom_rules') { + hasCustomRule = true; + } else if (reason === 'default_rule') { + hasDefault = true; + } + // Unknown tokens are ignored. + } + + const chips: FormattedReason[] = []; + const subdomainSuffix = hasSubdomain ? ' (subdomain)' : ''; + + // Blocklist tier — specific ids collapse the generic token; the subdomain + // qualifier folds into the chip label rather than becoming its own chip. + if (blocklistIds.length > 0) { + for (const id of blocklistIds) { + const name = blocklistNames?.[id] ?? id; + chips.push({ kind: 'blocklist', label: `Blocklist: ${name}${subdomainSuffix}` }); + } + } else if (hasGenericBlocklist || hasSubdomain) { + // Generic blocklist, or an orphan subdomain rule with nothing to attach to. + chips.push({ kind: 'blocklist', label: `Blocklist${subdomainSuffix}` }); + } + + // Service tier — specific ids collapse the generic token. + if (serviceIds.length > 0) { + for (const id of serviceIds) { + const name = serviceNames?.[id] ?? id; + chips.push({ kind: 'service', label: `Service: ${name}` }); + } + } else if (hasGenericService) { + chips.push({ kind: 'service', label: 'Service' }); + } + + if (hasCustomRule) { + chips.push({ kind: 'custom_rule', label: 'Custom rule' }); + } + + if (hasDefault) { + chips.push({ kind: 'default', label: 'Default rule' }); + } + + return chips; +} + +export default formatReasons; diff --git a/app/src/lib/utils.ts b/app/src/lib/utils.ts index bd0c391d..6227b49a 100644 --- a/app/src/lib/utils.ts +++ b/app/src/lib/utils.ts @@ -4,3 +4,7 @@ import { twMerge } from "tailwind-merge" export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } + +// Subtle "raise/grow on hover" affordance shared by interactive cards (setup platform cards, query-log rows). +export const INTERACTIVE_CARD = + "transition-all duration-300 cursor-pointer hover:scale-[1.02] active:scale-100 motion-reduce:transform-none motion-reduce:transition-none"; diff --git a/app/src/pages/logs/Logs.tsx b/app/src/pages/logs/Logs.tsx index 08043a9f..26a564eb 100644 --- a/app/src/pages/logs/Logs.tsx +++ b/app/src/pages/logs/Logs.tsx @@ -14,6 +14,7 @@ import QuickRuleSheet, { type QuickRuleAction } from "./QuickRuleSheet"; import api from "@/api/api"; import { useAppStore } from "@/store/general"; import { Skeleton } from "@/components/ui/skeleton"; +import { Info, X } from "lucide-react"; import { useScreenDetector } from "@/hooks/useScreenDetector"; import { useSubscriptionGuard } from "@/hooks/useSubscriptionGuard"; import LimitedAccessBanner from "@/components/LimitedAccessBanner"; @@ -51,6 +52,20 @@ const QueryLogs = ({ profiles }: QueryLogsProps): JSX.Element => { // Maintain a separate list of all available device IDs (not filtered by current selection) const [allAvailableDeviceIds, setAllAvailableDeviceIds] = useState([]); + // id→name catalogs for enriching query-log reasons (blocklist/service ids). Loaded once on + // mount; failures degrade gracefully to raw ids and must never block logs from rendering. + const [blocklistNames, setBlocklistNames] = useState>({}); + const [serviceNames, setServiceNames] = useState>({}); + + // One-time mobile hint teaching that a row is tappable (there is no visible chevron). + // Dismissed on the ✕ or after the first row expand. Persisted in the shared "moddns-storage" + // zustand store (alongside the other one-time dismissals) so it never reappears. + const expandHintDismissed = useAppStore((state) => state.logsExpandHintDismissed); + const setLogsExpandHintDismissed = useAppStore((state) => state.setLogsExpandHintDismissed); + const dismissExpandHint = useCallback(() => { + setLogsExpandHintDismissed(true); + }, [setLogsExpandHintDismissed]); + // Compose filters object for API const filters = { Limit: QUERY_LIMIT, @@ -97,6 +112,37 @@ const QueryLogs = ({ profiles }: QueryLogsProps): JSX.Element => { // eslint-disable-next-line react-hooks/exhaustive-deps -- activeProfile is intentionally excluded to avoid re-running this effect when the profile object changes (which this effect itself triggers via setActiveProfile) }, [profiles, setActiveProfile]); + // Load blocklist + service catalogs once to resolve reason ids to human names in the + // expandable log card. Best-effort: on failure the maps stay empty and reasons fall back + // to raw ids — never block logs on catalog load. + useEffect(() => { + let cancelled = false; + const loadCatalogs = async () => { + try { + const [blocklistsResp, servicesResp] = await Promise.all([ + api.Client.blocklistsApi.apiV1BlocklistsGet(), + api.Client.servicesApi.apiV1ServicesGet(), + ]); + if (cancelled) return; + const blMap: Record = {}; + (blocklistsResp.data || []).forEach(bl => { + if (bl.blocklist_id) blMap[bl.blocklist_id] = bl.name; + }); + setBlocklistNames(blMap); + + const svcMap: Record = {}; + (servicesResp.data?.services || []).forEach(svc => { + if (svc.id && svc.name) svcMap[svc.id] = svc.name; + }); + setServiceNames(svcMap); + } catch { + // Leave maps empty; reasons degrade to raw ids. + } + }; + loadCatalogs(); + return () => { cancelled = true; }; + }, []); + const handleOpenQuickRule = useCallback((domain?: string, defaultAction: QuickRuleAction = "denylist") => { if (!domain) return; if (isRestricted) return; // POST custom_rules is blocked in Limited Access / Pending Delete @@ -406,7 +452,25 @@ const QueryLogs = ({ profiles }: QueryLogsProps): JSX.Element => { : "Pull to refresh"} )} -
+
+ {!expandHintDismissed && logs.length > 0 && ( +
+ + Tap any entry to see full request details. + +
+ )} {logs.map((log, index) => { const isLast = index === logs.length - 1; return ( @@ -417,6 +481,9 @@ const QueryLogs = ({ profiles }: QueryLogsProps): JSX.Element => { lastLogRef={isLast ? lastLogRef : undefined} onQuickRule={handleOpenQuickRule} quickRuleRestricted={isRestricted} + blocklistNames={blocklistNames} + serviceNames={serviceNames} + onExpand={dismissExpandHint} /> ); })} diff --git a/app/src/pages/logs/QueryLogCard.tsx b/app/src/pages/logs/QueryLogCard.tsx index 59832b95..9ec72ae4 100644 --- a/app/src/pages/logs/QueryLogCard.tsx +++ b/app/src/pages/logs/QueryLogCard.tsx @@ -1,4 +1,4 @@ -import { useState, type JSX } from "react"; +import { useId, useState, type JSX } from "react"; import { useScreenDetector } from "@/hooks/useScreenDetector"; import { formatDistanceToNow, parseISO, format } from "date-fns"; import { Clock, ShieldPlus } from "lucide-react"; @@ -6,6 +6,8 @@ import { Clock, ShieldPlus } from "lucide-react"; import { Badge } from "@/components/ui/badge"; // still used for Blocked status only import { Button } from "@/components/ui/button"; import { Tooltip } from "@/components/ui/tooltip"; +import { ReasonBadges } from "@/components/ui/ReasonBadges"; +import { cn, INTERACTIVE_CARD } from "@/lib/utils"; import type { ModelQueryLog } from "@/api/client"; interface QueryLogCardProps { @@ -14,9 +16,13 @@ interface QueryLogCardProps { lastLogRef?: (node: HTMLDivElement | null) => void; onQuickRule?: (domain?: string, defaultAction?: "denylist" | "allowlist") => void; quickRuleRestricted?: boolean; + blocklistNames?: Record; + serviceNames?: Record; + /** Called the first time this row is expanded (used to dismiss the one-time mobile hint). */ + onExpand?: () => void; } -const QueryLogCard = ({ log, isLast, lastLogRef, onQuickRule, quickRuleRestricted }: QueryLogCardProps): JSX.Element | null => { +const QueryLogCard = ({ log, isLast, lastLogRef, onQuickRule, quickRuleRestricted, blocklistNames, serviceNames, onExpand }: QueryLogCardProps): JSX.Element | null => { // If domain logging is disabled, dns_request.domain may be absent. Provide a placeholder. const rawDomain = log.dns_request?.domain; const normalizedDomain = rawDomain ? rawDomain.replace(/\.$/, "") : undefined; @@ -38,6 +44,8 @@ const QueryLogCard = ({ log, isLast, lastLogRef, onQuickRule, quickRuleRestricte : isProcessed ? "bg-[var(--tailwind-colors-slate-800)] text-[var(--tailwind-colors-slate-100)] hover:!bg-[var(--tailwind-colors-red-600)] hover:!text-[var(--tailwind-colors-slate-50)]" : "bg-[var(--tailwind-colors-rdns-600)] text-[var(--tailwind-colors-slate-900)] hover:!bg-[var(--tailwind-colors-slate-900)] hover:!text-[var(--tailwind-colors-rdns-600)]"; + // Quick-rule is the ONLY control excluded from the whole-card expand overlay; its wrapper + // sits above the overlay (relative z-20) so it stays clickable. const renderQuickRuleButton = (wrapperClassName: string) => (
@@ -50,21 +58,28 @@ const QueryLogCard = ({ log, isLast, lastLogRef, onQuickRule, quickRuleRestricte aria-label="Quick custom rule" onClick={handleQuickRule} disabled={quickRuleDisabled} - className={`h-9 w-9 lg:min-h-0 p-0 aspect-square rounded-full disabled:opacity-40 ${quickRuleButtonClasses}`} + className={`h-11 w-11 md:h-9 md:w-9 min-h-0 p-0 aspect-square rounded-full disabled:opacity-40 ${quickRuleButtonClasses}`} data-testid="logs-quick-rule-button" > - +
); - // Track timestamp expansion to increase card height smoothly on mobile - const [timestampExpanded, setTimestampExpanded] = useState(false); - - // Expansion state for mobile tap-to-expand of truncated domain (device id no longer truncates) - const [showFullDomainMobile, setShowFullDomainMobile] = useState(false); + // Whole-card expand: every row is expandable (blocked and processed, with or without reasons). + // There is no visible chevron — expandability is signalled by the hover lift (desktop), + // the press/active feedback (both), and a one-time hint (mobile, owned by the Logs page). + const reasons = log.reasons ?? []; + const hasReasons = reasons.length > 0; + const [expanded, setExpanded] = useState(false); + const panelId = useId(); + const toggleExpanded = () => setExpanded(v => { + const next = !v; + if (next) onExpand?.(); + return next; + }); // Device ID: backend allows up to 36 chars; truncate only for mobile (<=768px) const { isMobile } = useScreenDetector(); @@ -75,24 +90,56 @@ const QueryLogCard = ({ log, isLast, lastLogRef, onQuickRule, quickRuleRestricte else deviceIdOrIp = rawDeviceId.slice(0, 36); const DOMAIN_TRUNCATE_THRESHOLD = 65; // existing logic threshold - const MOBILE_EXPANDED_DOMAIN_LIMIT = 50; - const TIMESTAMP_COLLAPSED_MAX_HEIGHT = 24; - const TIMESTAMP_EXPANDED_MAX_HEIGHT = 48; const isDomainTruncatable = displayDomain ? displayDomain.length > DOMAIN_TRUNCATE_THRESHOLD : false; const truncatedDomain = displayDomain && isDomainTruncatable ? displayDomain.slice(0, DOMAIN_TRUNCATE_THRESHOLD) + '…' : displayDomain; - const mobileExpandedDomain = displayDomain - ? displayDomain.length > MOBILE_EXPANDED_DOMAIN_LIMIT - ? displayDomain.slice(0, MOBILE_EXPANDED_DOMAIN_LIMIT) + '…' - : displayDomain - : undefined; const protocolLabel = log?.protocol ? log.protocol.toUpperCase() : '—'; + // DNSSEC-validated is a positive security signal (like the HTTPS padlock): shown inline next to + // the protocol ONLY when validated. The "not validated" case stays in the expanded panel only. + const dnssecValidated = log.dns_request?.dnssec === true; + const renderDnssecBadge = (className?: string) => ( + + DNSSEC + + ); + + // Detail-grid field: uppercase micro-label + selectable value. + const renderDetailField = (label: string, value: string, testid: string) => ( +
+
{label}
+
{value}
+
+ ); + return (
-
+ {/* Whole-card expand/collapse trigger: a real button (native keyboard/focus/aria). + Spans the ENTIRE card (absolute inset-0 on the card root), so clicking anywhere — + the header row OR the expanded detail panel — toggles it. Collapsed, the panel is + 0-height so the button only covers the header. Quick-rule (z-20) stays above it. */} + - ) : ( - {displayDomain} - ) + + {isDomainTruncatable ? truncatedDomain : displayDomain} + ) : ( '-' )}
-
-
- -
+
+
@@ -185,6 +210,7 @@ const QueryLogCard = ({ log, isLast, lastLogRef, onQuickRule, quickRuleRestricte
{protocolLabel}
+ {dnssecValidated && renderDnssecBadge("order-2 md:order-2")} Blocked @@ -198,46 +224,66 @@ const QueryLogCard = ({ log, isLast, lastLogRef, onQuickRule, quickRuleRestricte {deviceIdOrIp}
- + - {renderQuickRuleButton("flex items-center justify-center")} + {renderQuickRuleButton("flex items-center justify-center relative z-20")} )} +
+
+
+
+ {normalizedDomain !== undefined + ? renderDetailField("Domain", normalizedDomain, "querylog-detail-domain") + : ( +
+
Domain
+
Domain logging disabled
+
+ )} + {log.dns_request?.query_type && renderDetailField("Query type", log.dns_request.query_type, "querylog-detail-query-type")} + {log.dns_request?.response_code && renderDetailField("Response code", log.dns_request.response_code, "querylog-detail-response-code")} + {log.dns_request?.dnssec !== undefined && renderDetailField("DNSSEC", log.dns_request.dnssec ? "Validated" : "Not validated", "querylog-detail-dnssec")} + {renderDetailField("Protocol", protocolLabel, "querylog-detail-protocol")} + {log.client_ip && renderDetailField("Client IP", log.client_ip, "querylog-detail-client-ip")} + {log.device_id && renderDetailField("Device ID", log.device_id, "querylog-detail-device-id")} + {renderDetailField("Time", log.timestamp ? format(parseISO(log.timestamp), "MMMM d, yyyy 'at' hh:mm:ss a") : "—", "querylog-detail-timestamp")} +
+ {hasReasons && ( +
+ {isBlocked ? "Block reason" : "Allow reason"} + +
+ )} +
+
+
); }; -interface TimestampDisplayProps { timestamp?: string; onToggle?: (expanded: boolean) => void } +interface TimestampDisplayProps { timestamp?: string } -const TimestampDisplay = ({ timestamp, onToggle }: TimestampDisplayProps) => { - const [expanded, setExpanded] = useState(false); +// Static relative-time label (Clock icon + "x ago"). The absolute timestamp moves to the panel. +const TimestampDisplay = ({ timestamp }: TimestampDisplayProps) => { if (!timestamp) return null; - const date = parseISO(timestamp); - const relative = formatDistanceToNow(date, { addSuffix: true }); - const absolute = format(date, "MMMM d, yyyy 'at' hh:mm:ss a"); + const relative = formatDistanceToNow(parseISO(timestamp), { addSuffix: true }); return ( - + + {relative} + ); }; diff --git a/app/src/pages/setup/SetupScreen.tsx b/app/src/pages/setup/SetupScreen.tsx index f5508682..afb3edee 100644 --- a/app/src/pages/setup/SetupScreen.tsx +++ b/app/src/pages/setup/SetupScreen.tsx @@ -24,6 +24,7 @@ import VerificationBanner from '@/pages/setup/VerificationBanner'; import modDNSLogoDarkTheme from '@/assets/logos/modDNS-dark-theme.svg'; import modDNSLogoLightTheme from '@/assets/logos/modDNS-light-theme.svg'; import { useTheme } from "@/components/theme-provider"; +import { cn, INTERACTIVE_CARD } from "@/lib/utils"; import SetupGuidePanel from './RightPanelGuide'; @@ -235,13 +236,18 @@ export default function Setup({ profiles }: SetupProps): JSX.Element { handlePlatformClick(platform.name)} > @@ -259,10 +265,13 @@ export default function Setup({ profiles }: SetupProps): JSX.Element { {/* Device Identification Card - full width */} handlePlatformClick('Device Identification')} > @@ -300,10 +309,13 @@ export default function Setup({ profiles }: SetupProps): JSX.Element { handlePlatformClick(platform.name)} > @@ -318,10 +330,13 @@ export default function Setup({ profiles }: SetupProps): JSX.Element { ))} handlePlatformClick('Device Identification')} > diff --git a/app/src/store/general.ts b/app/src/store/general.ts index e4d01fd0..98e25502 100644 --- a/app/src/store/general.ts +++ b/app/src/store/general.ts @@ -21,6 +21,8 @@ interface AppState { setBlocklistsAlertDismissed: (dismissed: boolean) => void; customRulesAlertDismissed: boolean; // session-only dismissal (not persisted) setCustomRulesAlertDismissed: (dismissed: boolean) => void; + logsExpandHintDismissed: boolean; // persisted dismissal of the one-time "tap a row" logs hint + setLogsExpandHintDismissed: (dismissed: boolean) => void; passkeys: ModelCredential[]; setPasskeys: (passkeys: ModelCredential[]) => void; subscriptionStatus: string | null; @@ -79,6 +81,8 @@ export const useAppStore = create()( setBlocklistsAlertDismissed: (dismissed) => set({ blocklistsAlertDismissed: dismissed }), customRulesAlertDismissed: false, setCustomRulesAlertDismissed: (dismissed) => set({ customRulesAlertDismissed: dismissed }), + logsExpandHintDismissed: false, + setLogsExpandHintDismissed: (dismissed) => set({ logsExpandHintDismissed: dismissed }), passkeys: [], setPasskeys: (passkeys) => set({ passkeys }), subscriptionStatus: null, @@ -104,6 +108,7 @@ export const useAppStore = create()( connectionStatusVisible: state.connectionStatusVisible, announcementsLastSeenAt: state.announcementsLastSeenAt, customRulesCollapsed: state.customRulesCollapsed, + logsExpandHintDismissed: state.logsExpandHintDismissed, }), } ) From 763e691284d87382701ef59c7fa3398f537f9bd8 Mon Sep 17 00:00:00 2001 From: Maciek Date: Wed, 1 Jul 2026 23:47:59 +0200 Subject: [PATCH 10/67] chore(app): Improve badges positioning Signed-off-by: Maciek --- app/src/__tests__/unit/QueryLogCard.test.tsx | 50 +++++++++++++- .../__tests__/unit/lib/formatReasons.test.ts | 12 ++++ app/src/lib/formatReasons.ts | 12 +++- app/src/pages/logs/QueryLogCard.tsx | 66 ++++++++++++++----- 4 files changed, 120 insertions(+), 20 deletions(-) diff --git a/app/src/__tests__/unit/QueryLogCard.test.tsx b/app/src/__tests__/unit/QueryLogCard.test.tsx index d91da9fe..1849a78d 100644 --- a/app/src/__tests__/unit/QueryLogCard.test.tsx +++ b/app/src/__tests__/unit/QueryLogCard.test.tsx @@ -180,7 +180,7 @@ describe('QueryLogCard whole-card expansion', () => { expect(screen.getByTestId('querylog-dnssec-badge')).toHaveTextContent('DNSSEC'); }); - test('omits the DNSSEC badge when not validated', () => { + test('omits the DNSSEC badge when neither validated nor failed', () => { const log: ModelQueryLog = { ...baseLog, dns_request: { ...baseLog.dns_request, dnssec: false } @@ -188,6 +188,54 @@ describe('QueryLogCard whole-card expansion', () => { render(); expect(screen.queryByTestId('querylog-dnssec-badge')).not.toBeInTheDocument(); }); + + test('shows a red (failed) DNSSEC badge when validation failed', () => { + const log: ModelQueryLog = { + ...baseLog, + status: 'processed', + dns_request: { ...baseLog.dns_request, dnssec: false, response_code: 'SERVFAIL' }, + reasons: ['dnssec_failed'], + }; + render(); + const badge = screen.getByTestId('querylog-dnssec-badge'); + expect(badge).toHaveTextContent('DNSSEC'); + expect(badge).toHaveAttribute('data-dnssec', 'failed'); + }); + + test('labels the reason "Block reason" for a DNSSEC-failed row (not "Allow reason")', () => { + const log: ModelQueryLog = { + ...baseLog, + status: 'processed', + dns_request: { ...baseLog.dns_request, dnssec: false, response_code: 'SERVFAIL' }, + reasons: ['dnssec_failed'], + }; + render(); + fireEvent.click(screen.getByTestId('querylog-card-toggle')); + const reasons = screen.getByTestId('querylog-reasons'); + expect(reasons).toHaveTextContent('Block reason'); + expect(reasons).not.toHaveTextContent('Allow reason'); + }); + + test('DNSSEC detail field distinguishes the three states', () => { + const detailText = (log: ModelQueryLog) => { + const { unmount } = render(); + fireEvent.click(screen.getByTestId('querylog-card-toggle')); + const text = screen.getByTestId('querylog-detail-dnssec').textContent; + unmount(); + return text; + }; + // validated + expect(detailText(baseLog)).toBe('Validated'); + // unsigned (dnssec false, no failure reason) + expect(detailText({ ...baseLog, dns_request: { ...baseLog.dns_request, dnssec: false } })).toBe('No DNSSEC'); + // failed (bogus) + expect(detailText({ + ...baseLog, + status: 'processed', + dns_request: { ...baseLog.dns_request, dnssec: false, response_code: 'SERVFAIL' }, + reasons: ['dnssec_failed'], + })).toBe('Validation failed'); + }); }); describe('QueryLogCard quick rule button', () => { diff --git a/app/src/__tests__/unit/lib/formatReasons.test.ts b/app/src/__tests__/unit/lib/formatReasons.test.ts index 944a012a..9503fddf 100644 --- a/app/src/__tests__/unit/lib/formatReasons.test.ts +++ b/app/src/__tests__/unit/lib/formatReasons.test.ts @@ -96,4 +96,16 @@ describe('formatReasons', () => { { kind: 'service', label: 'Service: some-svc' }, ]); }); + + it('maps dnssec_failed to a "DNSSEC validation failed" chip, shown first', () => { + // tableRef: logs-reason-display-behaviour #13 + expect(formatReasons(['dnssec_failed'])).toEqual([ + { kind: 'dnssec', label: 'DNSSEC validation failed' }, + ]); + // when combined with other reasons it is ordered first + expect(formatReasons(['default_rule', 'dnssec_failed'])).toEqual([ + { kind: 'dnssec', label: 'DNSSEC validation failed' }, + { kind: 'default', label: 'Default rule' }, + ]); + }); }); diff --git a/app/src/lib/formatReasons.ts b/app/src/lib/formatReasons.ts index 36b91933..8eb1154a 100644 --- a/app/src/lib/formatReasons.ts +++ b/app/src/lib/formatReasons.ts @@ -4,7 +4,7 @@ // If the chip mapping changes, update that spec and formatReasons.test.ts with // matching `tableRef: logs-reason-display-behaviour #N` annotations. -export type ReasonKind = 'blocklist' | 'service' | 'custom_rule' | 'default' | 'subdomain'; +export type ReasonKind = 'blocklist' | 'service' | 'custom_rule' | 'default' | 'subdomain' | 'dnssec'; export interface FormattedReason { kind: ReasonKind; @@ -38,8 +38,13 @@ export function formatReasons( let hasSubdomain = false; let hasCustomRule = false; let hasDefault = false; + let hasDnssecFailed = false; for (const reason of reasons) { + if (reason === 'dnssec_failed') { + hasDnssecFailed = true; + continue; + } if (reason.startsWith(BLOCKLIST_PREFIX)) { const id = reason.slice(BLOCKLIST_PREFIX.length); if (!blocklistIdSet.has(id)) { @@ -69,6 +74,11 @@ export function formatReasons( const chips: FormattedReason[] = []; const subdomainSuffix = hasSubdomain ? ' (subdomain)' : ''; + // DNSSEC validation failure — shown first; explains an otherwise-opaque SERVFAIL. + if (hasDnssecFailed) { + chips.push({ kind: 'dnssec', label: 'DNSSEC validation failed' }); + } + // Blocklist tier — specific ids collapse the generic token; the subdomain // qualifier folds into the chip label rather than becoming its own chip. if (blocklistIds.length > 0) { diff --git a/app/src/pages/logs/QueryLogCard.tsx b/app/src/pages/logs/QueryLogCard.tsx index 9ec72ae4..ea240db2 100644 --- a/app/src/pages/logs/QueryLogCard.tsx +++ b/app/src/pages/logs/QueryLogCard.tsx @@ -94,27 +94,57 @@ const QueryLogCard = ({ log, isLast, lastLogRef, onQuickRule, quickRuleRestricte const truncatedDomain = displayDomain && isDomainTruncatable ? displayDomain.slice(0, DOMAIN_TRUNCATE_THRESHOLD) + '…' : displayDomain; const protocolLabel = log?.protocol ? log.protocol.toUpperCase() : '—'; - // DNSSEC-validated is a positive security signal (like the HTTPS padlock): shown inline next to - // the protocol ONLY when validated. The "not validated" case stays in the expanded panel only. + // DNSSEC status shown inline next to the protocol as a plain text badge (styled like the + // protocol label — no outline/background): + // - validated (AD bit true) -> brand-coloured "DNSSEC" + // - failed (bogus/misconfigured) -> red "DNSSEC" (recursor SERVFAILed on validation) + // Neither shows for domains without a DNSSEC signal. const dnssecValidated = log.dns_request?.dnssec === true; - const renderDnssecBadge = (className?: string) => ( - - DNSSEC - - ); + const dnssecFailed = reasons.includes('dnssec_failed'); + const dnssecShown = dnssecValidated || dnssecFailed; + // When reserveWhenHidden is set (desktop), the badge is always rendered — invisible + // when there's no DNSSEC — so it reserves a constant slot and the protocol label + // never shifts depending on whether DNSSEC is shown. The testid/color are only + // applied when actually shown. + const renderDnssecBadge = (className?: string, reserveWhenHidden = false) => { + if (!dnssecShown && !reserveWhenHidden) return null; + return ( + + DNSSEC + + ); + }; - // Detail-grid field: uppercase micro-label + selectable value. - const renderDetailField = (label: string, value: string, testid: string) => ( + // Detail-grid field: uppercase micro-label + selectable value (optionally coloured). + const renderDetailField = (label: string, value: string, testid: string, valueClassName?: string) => (
{label}
-
{value}
+
{value}
); + // DNSSEC has three distinct states — keep them clearly worded and colour-coded: + // failed (bogus) -> "Validation failed" (red) — signatures broken + // validated (AD=1) -> "Validated" (brand/green) — authentic + // unsigned -> "No DNSSEC" (muted) — domain isn't signed + const dnssecDetail = dnssecFailed + ? { text: 'Validation failed', className: 'text-[var(--tailwind-colors-red-600)]' } + : dnssecValidated + ? { text: 'Validated', className: 'text-[var(--tailwind-colors-rdns-600)]' } + : { text: 'No DNSSEC', className: 'text-[var(--tailwind-colors-slate-200)]' }; + return (
{protocolLabel} - {dnssecValidated && renderDnssecBadge()} + {dnssecShown && renderDnssecBadge()} {isBlocked && ( {protocolLabel}
- {dnssecValidated && renderDnssecBadge("order-2 md:order-2")} + {renderDnssecBadge("order-2 md:order-2", true)} Blocked @@ -252,7 +282,7 @@ const QueryLogCard = ({ log, isLast, lastLogRef, onQuickRule, quickRuleRestricte )} {log.dns_request?.query_type && renderDetailField("Query type", log.dns_request.query_type, "querylog-detail-query-type")} {log.dns_request?.response_code && renderDetailField("Response code", log.dns_request.response_code, "querylog-detail-response-code")} - {log.dns_request?.dnssec !== undefined && renderDetailField("DNSSEC", log.dns_request.dnssec ? "Validated" : "Not validated", "querylog-detail-dnssec")} + {(log.dns_request?.dnssec !== undefined || dnssecFailed) && renderDetailField("DNSSEC", dnssecDetail.text, "querylog-detail-dnssec", dnssecDetail.className)} {renderDetailField("Protocol", protocolLabel, "querylog-detail-protocol")} {log.client_ip && renderDetailField("Client IP", log.client_ip, "querylog-detail-client-ip")} {log.device_id && renderDetailField("Device ID", log.device_id, "querylog-detail-device-id")} @@ -260,7 +290,7 @@ const QueryLogCard = ({ log, isLast, lastLogRef, onQuickRule, quickRuleRestricte {hasReasons && (
- {isBlocked ? "Block reason" : "Allow reason"} + {(isBlocked || dnssecFailed) ? "Block reason" : "Allow reason"}
)} From ccfe4a595ff2880a522bb7aad0b50cde9c56357d Mon Sep 17 00:00:00 2001 From: Maciek Date: Thu, 2 Jul 2026 00:19:27 +0200 Subject: [PATCH 11/67] feat(proxy): Read EDE codes to identify DNSSEC validation errors Signed-off-by: Maciek --- proxy/cache/memory/serialization_test.go | 5 + proxy/internal/dnssec/dnssec.go | 132 ++++++++++++++++ proxy/internal/dnssec/dnssec_test.go | 185 +++++++++++++++++++++++ proxy/requestcontext/request_context.go | 2 +- proxy/server/proxy.go | 6 +- proxy/server/query_logs.go | 17 +++ proxy/server/server.go | 22 ++- 7 files changed, 354 insertions(+), 15 deletions(-) create mode 100644 proxy/internal/dnssec/dnssec.go create mode 100644 proxy/internal/dnssec/dnssec_test.go diff --git a/proxy/cache/memory/serialization_test.go b/proxy/cache/memory/serialization_test.go index b77a4053..48d8f147 100644 --- a/proxy/cache/memory/serialization_test.go +++ b/proxy/cache/memory/serialization_test.go @@ -40,6 +40,10 @@ func TestRequestContextSerialization(t *testing.T) { assert.Equal(t, "test-profile", reqCtx.LoggerConfig.ProfileID, "Logger config should have correct profile ID") assert.False(t, reqCtx.LoggerConfig.Enabled, "Logger config should show enabled=false") + // UpstreamName must survive the cache round-trip — EmitQueryLog needs it to pick + // the recursor for the DNSSEC-failure CD probe. Regression guard: it was json:"-". + reqCtx.UpstreamName = "knot" + // Test serialization by setting in cache requestID := "test-request-123" err = profileIDCache.SetRequestCtx(requestID, reqCtx) @@ -55,6 +59,7 @@ func TestRequestContextSerialization(t *testing.T) { assert.Equal(t, map[string]string{"privacy": "setting"}, retrievedCtx.PrivacySettings, "Privacy settings should be preserved") assert.Equal(t, map[string]string{"dnssec": "enabled"}, retrievedCtx.DNSSECSettings, "DNSSEC settings should be preserved") assert.Equal(t, map[string]string{"advanced": "setting"}, retrievedCtx.AdvancedSettings, "Advanced settings should be preserved") + assert.Equal(t, "knot", retrievedCtx.UpstreamName, "UpstreamName must survive the cache round-trip (needed by the DNSSEC-failure probe)") // Verify the logger is recreated correctly require.NotNil(t, retrievedCtx.Logger, "Logger should be recreated") diff --git a/proxy/internal/dnssec/dnssec.go b/proxy/internal/dnssec/dnssec.go new file mode 100644 index 00000000..57178a8a --- /dev/null +++ b/proxy/internal/dnssec/dnssec.go @@ -0,0 +1,132 @@ +// Package dnssec holds the proxy's DNSSEC request/response helpers: setting the +// request flags that make recursors return the Authenticated Data flag and +// Extended DNS Errors, and capturing/classifying those EDE codes so a DNSSEC +// validation failure can be surfaced on the query log. +package dnssec + +import ( + "sync" + + "github.com/AdguardTeam/dnsproxy/upstream" + "github.com/miekg/dns" +) + +// ReasonFailed is appended to a query log's reasons when the recursor reports a +// DNSSEC validation failure via an Extended DNS Error (RFC 8914). The frontend +// renders it as a "DNSSEC validation failed" chip. +const ReasonFailed = "dnssec_failed" + +// ApplyRequestFlags configures the upstream request's DNSSEC-related bits. +// +// The logged DNSSEC-validation status (QueryLog.DNSRequest.DNSSEC, sourced from the +// response AD bit) is deliberately decoupled from the client-facing send_do_bit +// setting: validation happens at the recursor regardless of whether DNSSEC RRs are +// returned to the end device. +// - validation enabled -> set the request AD bit so the recursor returns, and the +// dnsproxy library preserves (filterMsg keeps AD when the request's AD or DO bit +// is set), the Authenticated Data flag — even when the DO bit is not sent. +// - validation disabled -> set CD (CheckingDisabled) so the recursor skips validation. +// +// EDNS(0) is attached whenever validation is enabled — so the recursor can return +// Extended DNS Errors (carried in the OPT record) on validation failure, which +// happens whenever the query carries EDNS, independent of the DO bit — or when the +// client asked for DNSSEC RRs (sendDoBit). The DO bit, set to sendDoBit, governs +// returning RRSIG/DNSKEY records to the client. +func ApplyRequestFlags(req *dns.Msg, dnssecEnabled, sendDoBit bool) { + req.Extra = make([]dns.RR, 0) + if dnssecEnabled { + req.AuthenticatedData = true + } else { + req.CheckingDisabled = true + } + + if dnssecEnabled || sendDoBit { + req.SetEdns0(2048, sendDoBit) + } +} + +// IsFailureEDE reports whether an EDE InfoCode denotes a DNSSEC *validation +// failure* (bogus zone), as opposed to merely insecure/indeterminate. RFC 8914: +// +// 6 DNSSEC Bogus, 7 Signature Expired, 8 Signature Not Yet Valid, +// 9 DNSKEY Missing, 10 RRSIGs Missing, 11 No Zone Key Bit Set, 12 NSEC Missing. +// +// Codes 1/2/5 (unsupported algorithm/digest, indeterminate) mean the zone is +// treated as insecure, not failed, so they are deliberately excluded — an +// unsigned/insecure domain must never be flagged. Verified against sdns and +// knot-resolver v6.4.0, which both emit codes in this range on SERVFAIL. +func IsFailureEDE(code uint16) bool { + return code >= 6 && code <= 12 +} + +// FailureEDE returns the first DNSSEC-failure EDE InfoCode found in msg's OPT +// record, if any. +func FailureEDE(msg *dns.Msg) (uint16, bool) { + if msg == nil { + return 0, false + } + opt := msg.IsEdns0() + if opt == nil { + return 0, false + } + for _, o := range opt.Option { + if ede, ok := o.(*dns.EDNS0_EDE); ok && IsFailureEDE(ede.InfoCode) { + return ede.InfoCode, true + } + } + return 0, false +} + +// EDEStore correlates a captured DNSSEC-failure EDE code with the request that +// produced it, keyed by the request *dns.Msg pointer. dnsproxy passes the same +// dctx.Req pointer to the upstream Exchange and later exposes it to EmitQueryLog, +// so the pointer is a stable per-request key. Entries are set by CapturingUpstream +// at exchange time and drained by EmitQueryLog. Only DNSSEC-failure responses store +// an entry, so the map stays tiny and short-lived. +type EDEStore struct{ m sync.Map } + +// Set records the EDE code for req. +func (s *EDEStore) Set(req *dns.Msg, code uint16) { + if s == nil { + return + } + s.m.Store(req, code) +} + +// Take returns and removes the stored EDE code for req. Nil-safe so a caller +// constructed without an EDEStore (e.g. in unit tests) is a harmless no-op. +func (s *EDEStore) Take(req *dns.Msg) (uint16, bool) { + if s == nil { + return 0, false + } + v, ok := s.m.LoadAndDelete(req) + if !ok { + return 0, false + } + return v.(uint16), true +} + +// CapturingUpstream wraps an upstream to capture DNSSEC-failure EDE codes from +// responses BEFORE dnsproxy's filterMsg strips the OPT record (which happens +// before the query log is emitted, so the EDE is otherwise unavailable at log +// time). Address()/Close() come from the embedded upstream; only Exchange is +// intercepted. +type CapturingUpstream struct { + upstream.Upstream + store *EDEStore +} + +// NewCapturingUpstream wraps u so DNSSEC-failure EDE codes are captured into store. +func NewCapturingUpstream(u upstream.Upstream, store *EDEStore) *CapturingUpstream { + return &CapturingUpstream{Upstream: u, store: store} +} + +func (u *CapturingUpstream) Exchange(req *dns.Msg) (*dns.Msg, error) { + resp, err := u.Upstream.Exchange(req) + if err == nil { + if code, ok := FailureEDE(resp); ok { + u.store.Set(req, code) + } + } + return resp, err +} diff --git a/proxy/internal/dnssec/dnssec_test.go b/proxy/internal/dnssec/dnssec_test.go new file mode 100644 index 00000000..5b93154b --- /dev/null +++ b/proxy/internal/dnssec/dnssec_test.go @@ -0,0 +1,185 @@ +package dnssec + +import ( + "errors" + "testing" + + "github.com/miekg/dns" + "github.com/stretchr/testify/assert" +) + +// mockUpstream implements upstream.Upstream for wrapper tests. +type mockUpstream struct { + resp *dns.Msg + err error + gotReq *dns.Msg +} + +func (m *mockUpstream) Exchange(req *dns.Msg) (*dns.Msg, error) { + m.gotReq = req + return m.resp, m.err +} +func (m *mockUpstream) Address() string { return "mock" } +func (m *mockUpstream) Close() error { return nil } + +// msgWithEDE builds a response carrying an OPT record with the given EDE InfoCode. +func msgWithEDE(rcode int, code uint16) *dns.Msg { + m := new(dns.Msg) + m.SetQuestion(dns.Fqdn("dnssec-failed.org"), dns.TypeA) + m.Rcode = rcode + opt := &dns.OPT{Hdr: dns.RR_Header{Name: ".", Rrtype: dns.TypeOPT}} + opt.Option = append(opt.Option, &dns.EDNS0_EDE{InfoCode: code}) + m.Extra = append(m.Extra, opt) + return m +} + +func newReq() *dns.Msg { + req := new(dns.Msg) + req.SetQuestion(dns.Fqdn("example.com"), dns.TypeA) + // seed Extra to confirm it is reset + req.Extra = []dns.RR{&dns.TXT{Hdr: dns.RR_Header{Name: "x.", Rrtype: dns.TypeTXT}, Txt: []string{"seed"}}} + return req +} + +// ApplyRequestFlags decouples logged validation status from the client-facing +// send_do_bit and always attaches EDNS when validation is enabled so the recursor +// can return EDE. +func TestApplyRequestFlags(t *testing.T) { + t.Run("enabled, send_do_bit off: AD set, no CD, EDNS present but DO=0", func(t *testing.T) { + req := newReq() + ApplyRequestFlags(req, true, false) + assert.True(t, req.AuthenticatedData, "AD bit must be set so validation is logged") + assert.False(t, req.CheckingDisabled) + if o := req.IsEdns0(); assert.NotNil(t, o, "EDNS(0) must be present so EDE can be returned") { + assert.False(t, o.Do(), "DO must be off when send_do_bit is off") + } + }) + + t.Run("enabled, send_do_bit on: AD set and DO set", func(t *testing.T) { + req := newReq() + ApplyRequestFlags(req, true, true) + assert.True(t, req.AuthenticatedData) + assert.False(t, req.CheckingDisabled) + if o := req.IsEdns0(); assert.NotNil(t, o) { + assert.True(t, o.Do()) + } + }) + + t.Run("disabled: CD set, AD not set, no EDNS", func(t *testing.T) { + req := newReq() + ApplyRequestFlags(req, false, false) + assert.True(t, req.CheckingDisabled, "CD must be set so the recursor skips validation") + assert.False(t, req.AuthenticatedData) + assert.Nil(t, req.IsEdns0(), "no EDNS when validation is disabled") + }) + + t.Run("disabled, send_do_bit on: CD set, DO set, AD not set", func(t *testing.T) { + req := newReq() + ApplyRequestFlags(req, false, true) + assert.True(t, req.CheckingDisabled) + assert.False(t, req.AuthenticatedData) + if o := req.IsEdns0(); assert.NotNil(t, o) { + assert.True(t, o.Do()) + } + }) + + t.Run("Extra is reset (seed cleared)", func(t *testing.T) { + req := newReq() + ApplyRequestFlags(req, true, false) + for _, rr := range req.Extra { + _, isTXT := rr.(*dns.TXT) + assert.False(t, isTXT, "seeded/stale RRs must be cleared") + } + }) +} + +func TestIsFailureEDE(t *testing.T) { + // DNSSEC validation-failure codes 6..12 are failures. + for _, c := range []uint16{6, 7, 8, 9, 10, 11, 12} { + assert.True(t, IsFailureEDE(c), "code %d should be a DNSSEC failure", c) + } + // Insecure/indeterminate/other codes must NOT be treated as failures + // (so unsigned domains are never flagged). + for _, c := range []uint16{0, 1, 2, 3, 4, 5, 13, 29} { + assert.False(t, IsFailureEDE(c), "code %d should NOT be a DNSSEC failure", c) + } +} + +// tableRef: logs-reason-display-behaviour #13 +func TestFailureEDE(t *testing.T) { + t.Run("SERVFAIL with EDE 9 -> detected", func(t *testing.T) { + code, ok := FailureEDE(msgWithEDE(dns.RcodeServerFailure, 9)) + assert.True(t, ok) + assert.Equal(t, uint16(9), code) + }) + t.Run("EDE 5 (indeterminate) -> not a failure", func(t *testing.T) { + _, ok := FailureEDE(msgWithEDE(dns.RcodeServerFailure, 5)) + assert.False(t, ok) + }) + t.Run("no OPT/EDE -> not a failure", func(t *testing.T) { + m := new(dns.Msg) + m.SetQuestion(dns.Fqdn("example.com"), dns.TypeA) + _, ok := FailureEDE(m) + assert.False(t, ok) + _, ok = FailureEDE(nil) + assert.False(t, ok) + }) +} + +func TestEDEStore(t *testing.T) { + s := &EDEStore{} + req := new(dns.Msg) + req.SetQuestion(dns.Fqdn("x.org"), dns.TypeA) + + _, ok := s.Take(req) + assert.False(t, ok, "empty store returns nothing") + + s.Set(req, 9) + code, ok := s.Take(req) + assert.True(t, ok) + assert.Equal(t, uint16(9), code) + + _, ok = s.Take(req) + assert.False(t, ok, "Take must remove the entry") + + // nil-safe + var ns *EDEStore + ns.Set(req, 9) + _, ok = ns.Take(req) + assert.False(t, ok) +} + +func TestCapturingUpstream(t *testing.T) { + req := new(dns.Msg) + req.SetQuestion(dns.Fqdn("dnssec-failed.org"), dns.TypeA) + + t.Run("captures DNSSEC-failure EDE keyed by request pointer", func(t *testing.T) { + store := &EDEStore{} + u := NewCapturingUpstream(&mockUpstream{resp: msgWithEDE(dns.RcodeServerFailure, 9)}, store) + _, err := u.Exchange(req) + assert.NoError(t, err) + code, ok := store.Take(req) + assert.True(t, ok, "EDE must be captured for the exact request") + assert.Equal(t, uint16(9), code) + }) + + t.Run("no capture for a clean response", func(t *testing.T) { + store := &EDEStore{} + clean := new(dns.Msg) + clean.SetQuestion(dns.Fqdn("cloudflare.com"), dns.TypeA) + clean.Rcode = dns.RcodeSuccess + u := NewCapturingUpstream(&mockUpstream{resp: clean}, store) + _, _ = u.Exchange(req) + _, ok := store.Take(req) + assert.False(t, ok) + }) + + t.Run("no capture on exchange error", func(t *testing.T) { + store := &EDEStore{} + u := NewCapturingUpstream(&mockUpstream{err: errors.New("timeout")}, store) + _, err := u.Exchange(req) + assert.Error(t, err) + _, ok := store.Take(req) + assert.False(t, ok) + }) +} diff --git a/proxy/requestcontext/request_context.go b/proxy/requestcontext/request_context.go index 6ca34ca9..f9bf2272 100644 --- a/proxy/requestcontext/request_context.go +++ b/proxy/requestcontext/request_context.go @@ -23,7 +23,7 @@ type RequestContext struct { Logger logging.LoggerInterface `json:"-"` LoggerConfig logging.LoggingConfig `json:"logger_config"` StartTime time.Time `json:"-"` - UpstreamName string `json:"-"` + UpstreamName string `json:"upstream_name"` } func NewRequestContext(ctx context.Context, p *proxy.Proxy, profileId string, deviceId string, privacySettings, logsSettings, dnssecSettings, advancedSettings map[string]string, logger logging.LoggerInterface) *RequestContext { diff --git a/proxy/server/proxy.go b/proxy/server/proxy.go index e7717ef1..bec3d9f8 100644 --- a/proxy/server/proxy.go +++ b/proxy/server/proxy.go @@ -11,6 +11,7 @@ import ( "github.com/AdguardTeam/golibs/netutil" "github.com/AdguardTeam/golibs/service" "github.com/ivpn/dns/proxy/config" + "github.com/ivpn/dns/proxy/internal/dnssec" "github.com/rs/zerolog/log" ) @@ -67,9 +68,12 @@ func (s *Server) newProxyConfig(serverConfig *config.Config) (*proxy.Config, err if err != nil { return nil, fmt.Errorf("failed to create upstream: %w", err) } + // Wrap the upstream so we can read the DNSSEC-failure EDE code from the + // response before dnsproxy's filterMsg strips the OPT (see EmitQueryLog). + wrappedUps := dnssec.NewCapturingUpstream(ups, s.edeStore) upCfg := &proxy.UpstreamConfig{ Upstreams: []upstream.Upstream{ - ups, + wrappedUps, }, } customUpstreamConfig := proxy.NewCustomUpstreamConfig( diff --git a/proxy/server/query_logs.go b/proxy/server/query_logs.go index a52b55d2..de16f2c8 100644 --- a/proxy/server/query_logs.go +++ b/proxy/server/query_logs.go @@ -6,14 +6,27 @@ import ( "github.com/AdguardTeam/dnsproxy/proxy" "github.com/getsentry/sentry-go" + "github.com/ivpn/dns/proxy/internal/dnssec" "github.com/ivpn/dns/proxy/model" "github.com/ivpn/dns/proxy/requestcontext" "github.com/miekg/dns" ) +// appendReason returns a new slice with r appended, without mutating existing +// (which is shared with the request context's FilterResult). +func appendReason(existing []string, r string) []string { + out := make([]string, len(existing), len(existing)+1) + copy(out, existing) + return append(out, r) +} + func (s *Server) EmitQueryLog(reqCtx *requestcontext.RequestContext, dctx *proxy.DNSContext) { defer sentry.Recover() + // Drain any captured DNSSEC-failure EDE for this request unconditionally (even + // if logging is disabled) so the edeStore never leaks entries. + _, dnssecFailed := s.edeStore.Take(dctx.Req) + // Use the contextual logger from the request context logger := reqCtx.Logger @@ -59,6 +72,10 @@ func (s *Server) EmitQueryLog(reqCtx *requestcontext.RequestContext, dctx *proxy queryLog.DNSRequest.ResponseCode = dns.RcodeToString[dctx.Res.Rcode] queryLog.DNSRequest.DNSSEC = dctx.Res.AuthenticatedData } + + if dnssecFailed { + queryLog.Reasons = appendReason(queryLog.Reasons, dnssec.ReasonFailed) + } retention := model.Retention(logsSettings["retention"]) // send event to channel if sendErr := s.CollectorChannels[model.TYPE_QUERY_LOGS].Send( diff --git a/proxy/server/server.go b/proxy/server/server.go index f40656bb..3b2e4d5e 100644 --- a/proxy/server/server.go +++ b/proxy/server/server.go @@ -19,6 +19,7 @@ import ( "github.com/ivpn/dns/proxy/config" "github.com/ivpn/dns/proxy/filter" "github.com/ivpn/dns/proxy/internal/asnlookup" + "github.com/ivpn/dns/proxy/internal/dnssec" "github.com/ivpn/dns/proxy/internal/metrics" "github.com/ivpn/dns/proxy/internal/ratelimit" "github.com/ivpn/dns/proxy/model" @@ -40,9 +41,12 @@ type RequestManager interface { } type Server struct { - Config *config.Config - Proxy *proxy.Proxy // service.Interface - Upstreams map[string]*proxy.CustomUpstreamConfig + Config *config.Config + Proxy *proxy.Proxy // service.Interface + Upstreams map[string]*proxy.CustomUpstreamConfig + // edeStore holds DNSSEC-failure Extended DNS Error codes captured from upstream + // responses (by dnssec.CapturingUpstream), drained per-request by EmitQueryLog. + edeStore *dnssec.EDEStore DomainFilter filter.Filter IPFilter filter.Filter Cache cache.Cache @@ -96,6 +100,7 @@ func NewServer(serverConfig *config.Config, collectorChannels map[string]channel ProfileSettingsCache: profileSettingsCache, CollectorChannels: collectorChannels, Upstreams: make(map[string]*proxy.CustomUpstreamConfig, 0), + edeStore: &dnssec.EDEStore{}, LoggerFactory: loggerFactory, RateLimiter: rl, Metrics: metrics.NewServerMetrics(prometheus.DefaultRegisterer), @@ -303,16 +308,7 @@ func (s *Server) HandleBefore(p *proxy.Proxy, dctx *proxy.DNSContext) (err error return err } - dctx.Req.Extra = make([]dns.RR, 0) - if !dnssecEnabled { - dctx.Req.CheckingDisabled = true - } - - if sendDoBit { - // Enable EDNS0 with a reasonable UDP buffer size and DO=1 - // This sets a proper OPT RR instead of constructing one manually. - dctx.Req.SetEdns0(2048, true) - } + dnssec.ApplyRequestFlags(dctx.Req, dnssecEnabled, sendDoBit) } return nil From 39276f0f999942862d520dbd0459c853f9fc8606 Mon Sep 17 00:00:00 2001 From: Maciek Date: Tue, 14 Jul 2026 13:09:57 +0200 Subject: [PATCH 12/67] feat(app): Query Logs visual deduplication Signed-off-by: Maciek --- .../e2e/logs/logs-mobile-overflow.spec.ts | 79 ++++++++++++ app/src/__tests__/unit/QueryLogCard.test.tsx | 56 +++++++++ app/src/__tests__/unit/QueryLogs.test.tsx | 33 +++++- .../unit/lib/consolidateLogs.test.ts | 112 ++++++++++++++++++ app/src/lib/consolidateLogs.ts | Bin 0 -> 4958 bytes app/src/pages/logs/Logs.tsx | 21 +++- app/src/pages/logs/QueryLogCard.tsx | 68 +++++++++-- 7 files changed, 353 insertions(+), 16 deletions(-) create mode 100644 app/src/__tests__/unit/lib/consolidateLogs.test.ts create mode 100644 app/src/lib/consolidateLogs.ts diff --git a/app/src/__tests__/e2e/logs/logs-mobile-overflow.spec.ts b/app/src/__tests__/e2e/logs/logs-mobile-overflow.spec.ts index 9d072ac1..cbff1c2e 100644 --- a/app/src/__tests__/e2e/logs/logs-mobile-overflow.spec.ts +++ b/app/src/__tests__/e2e/logs/logs-mobile-overflow.spec.ts @@ -255,4 +255,83 @@ test.describe('Logs mobile layout', () => { await page.getByTestId('logs-scroll-container').first().waitFor({ state: 'attached', timeout: 10000 }); await expect(page.getByTestId('logs-expand-hint')).toHaveCount(0); }); + + test('consolidation: adjacent duplicate queries collapse into one card with a ×N badge', async ({ page }) => { + await registerMocks(page, { + authenticated: true, + customProfiles: [{ id: 'prof1', profile_id: 'prof1', name: 'Default', settings: { logs: { enabled: true } } }] + }); + + // Two adjacent rows share domain/status/device/client_ip/protocol and differ only in + // query_type (A + AAAA) → they consolidate. A third distinct row stays separate. + const now = new Date().toISOString(); + const items = [ + { profile_id: 'prof1', timestamp: now, status: 'processed', protocol: 'dns', device_id: 'd1', client_ip: '10.0.0.1', dns_request: { domain: 'dup.example.test', query_type: 'A', response_code: 'NOERROR' } }, + { profile_id: 'prof1', timestamp: now, status: 'processed', protocol: 'dns', device_id: 'd1', client_ip: '10.0.0.1', dns_request: { domain: 'dup.example.test', query_type: 'AAAA', response_code: 'NOERROR' } }, + { profile_id: 'prof1', timestamp: now, status: 'processed', protocol: 'dns', device_id: 'd1', client_ip: '10.0.0.1', dns_request: { domain: 'other.example.test', query_type: 'A', response_code: 'NOERROR' } } + ]; + await page.route(/\/api\/v1\/profiles\/prof1\/logs/i, route => { + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(items) }); + }); + + await page.goto('/query-logs'); + await page.getByTestId('logs-scroll-container').first().waitFor({ state: 'attached', timeout: 10000 }); + + // 3 raw logs → 2 cards (the A+AAAA pair merges). The badge renders once per layout + // branch (desktop + mobile, one CSS-hidden), so assert on the single VISIBLE badge. + await expect(page.getByTestId('querylog-card-toggle')).toHaveCount(2); + const badge = page.getByTestId('querylog-count-badge').and(page.locator(':visible')); + await expect(badge).toHaveCount(1); + await expect(badge).toHaveText('×2'); + + // Expanding the merged card surfaces the aggregated occurrence count and query types. + await page.getByTestId('querylog-card-toggle').first().click(); + const panel = page.getByTestId('querylog-expanded-panel').first(); + await expect(panel).toHaveAttribute('data-expanded', 'true'); + await expect(panel.getByTestId('querylog-detail-occurrences')).toHaveText('2'); + await expect(panel.getByTestId('querylog-detail-query-type')).toHaveText('A, AAAA'); + }); + + test('tablet width: meta labels stack vertically and the row has no horizontal overflow', async ({ page }, testInfo) => { + // The tablet band (769–1023px) renders the desktop branch at Tailwind `md`. No project sits + // there, so drive it on the desktop project with an explicit tablet viewport. + test.skip(!/chromium-desktop/i.test(testInfo.project.name), 'tablet-band layout is desktop-branch only'); + await page.setViewportSize({ width: 820, height: 1000 }); + + await registerMocks(page, { + authenticated: true, + customProfiles: [{ id: 'prof1', profile_id: 'prof1', name: 'Default', settings: { logs: { enabled: true } } }] + }); + const now = new Date().toISOString(); + const items = [ + // Long domain + blocked (so DNSSEC/Blocked labels are present in the stack). + { profile_id: 'prof1', timestamp: now, status: 'blocked', protocol: 'dns', device_id: 'device-tablet', client_ip: '10.0.0.1', dns_request: { domain: 'a-very-long-subdomain-name.example-reallylongdomainforlayout-validation.test', query_type: 'A', response_code: 'NOERROR', dnssec: true } }, + { profile_id: 'prof1', timestamp: now, status: 'processed', protocol: 'dns', device_id: 'device-tablet', client_ip: '10.0.0.2', dns_request: { domain: 'short.example.test', query_type: 'A', response_code: 'NOERROR' } } + ]; + await page.route(/\/api\/v1\/profiles\/prof1\/logs/i, route => { + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(items) }); + }); + + await page.goto('/query-logs'); + await page.getByTestId('logs-scroll-container').first().waitFor({ state: 'attached', timeout: 10000 }); + + // The meta-label group (protocol/DNSSEC/Blocked) must be a vertical stack at tablet width. + const flexDir = await page.evaluate(() => { + const group = document.querySelector('.md\\:flex.flex-col.lg\\:flex-row') as HTMLElement | null; + return group ? getComputedStyle(group).flexDirection : 'not-found'; + }); + expect(flexDir).toBe('column'); + + // No horizontal overflow at tablet width even with the long domain. + const result = await page.evaluate(() => { + const docEl = document.documentElement; + const vw = window.innerWidth; + const scrollingElWidth = document.scrollingElement ? document.scrollingElement.scrollWidth : docEl.scrollWidth; + const sc = document.querySelector('[data-testid="logs-scroll-container"]') as HTMLElement | null; + const scOverflow = sc ? sc.scrollWidth - sc.clientWidth : 0; + return { vw, scrollingElWidth, scOverflow }; + }); + expect(result.scrollingElWidth).toBeLessThanOrEqual(result.vw + 1); + expect(result.scOverflow).toBeLessThanOrEqual(1); + }); }); diff --git a/app/src/__tests__/unit/QueryLogCard.test.tsx b/app/src/__tests__/unit/QueryLogCard.test.tsx index 1849a78d..9efd71be 100644 --- a/app/src/__tests__/unit/QueryLogCard.test.tsx +++ b/app/src/__tests__/unit/QueryLogCard.test.tsx @@ -238,6 +238,62 @@ describe('QueryLogCard whole-card expansion', () => { }); }); +describe('QueryLogCard consolidation (issue #161)', () => { + beforeEach(() => { + (window as unknown as { innerWidth: number }).innerWidth = 1440; + stubDesktopMatchMedia(true); + }); + + const memberA: ModelQueryLog = { + profile_id: 'p-con', + timestamp: '2026-06-15T10:20:32.000Z', + status: 'processed', + protocol: 'dns', + device_id: 'con-device', + client_ip: '10.0.0.9', + dns_request: { domain: 'dup.example.com', query_type: 'A', response_code: 'NOERROR' }, + }; + const memberAAAA: ModelQueryLog = { + ...memberA, + timestamp: '2026-06-15T10:20:30.000Z', + dns_request: { domain: 'dup.example.com', query_type: 'AAAA', response_code: 'NXDOMAIN' }, + }; + const group = { + key: 'con-group', + representative: memberA, + count: 3, + members: [memberA, memberAAAA, memberA], + firstTimestamp: memberA.timestamp, + lastTimestamp: memberAAAA.timestamp, + queryTypes: ['A', 'AAAA'], + responseCodes: ['NOERROR', 'NXDOMAIN'], + }; + + test('single-entry row (no group / count 1) shows no count badge', () => { + render(); + expect(screen.queryByTestId('querylog-count-badge')).not.toBeInTheDocument(); + render(); + expect(screen.queryByTestId('querylog-count-badge')).not.toBeInTheDocument(); + }); + + test('consolidated row shows a ×N count badge', () => { + render(); + const badge = screen.getByTestId('querylog-count-badge'); + expect(badge).toHaveTextContent('×3'); + expect(badge).toHaveAttribute('data-count', '3'); + }); + + test('expanded panel aggregates query types, response codes, occurrences and a time range', () => { + render(); + fireEvent.click(screen.getByTestId('querylog-card-toggle')); + expect(screen.getByTestId('querylog-detail-query-type')).toHaveTextContent('A, AAAA'); + expect(screen.getByTestId('querylog-detail-response-code')).toHaveTextContent('NOERROR, NXDOMAIN'); + expect(screen.getByTestId('querylog-detail-occurrences')).toHaveTextContent('3'); + // Time range renders both endpoints separated by an en dash. + expect(screen.getByTestId('querylog-detail-timestamp').textContent).toMatch(/–/); + }); +}); + describe('QueryLogCard quick rule button', () => { beforeEach(() => { (window as unknown as { innerWidth: number }).innerWidth = 1280; diff --git a/app/src/__tests__/unit/QueryLogs.test.tsx b/app/src/__tests__/unit/QueryLogs.test.tsx index 0866e9de..7bef149d 100644 --- a/app/src/__tests__/unit/QueryLogs.test.tsx +++ b/app/src/__tests__/unit/QueryLogs.test.tsx @@ -59,10 +59,11 @@ vi.mock("@/pages/logs/Filters", () => ({ onSearchInputChange, onSearchCommit, onFilterChange, + onSortChange, onTimespanChange, onDeviceIdChange, onRefresh, - }: { searchInputValue: string; onSearchInputChange?: (v: string) => void; onSearchCommit?: () => void; onFilterChange?: (v: string) => void; onTimespanChange?: (v: string) => void; onDeviceIdChange?: (v: string) => void; onRefresh?: () => void }) => ( + }: { searchInputValue: string; onSearchInputChange?: (v: string) => void; onSearchCommit?: () => void; onFilterChange?: (v: string) => void; onSortChange?: (v: string) => void; onTimespanChange?: (v: string) => void; onDeviceIdChange?: (v: string) => void; onRefresh?: () => void }) => (
({ /> + @@ -231,6 +233,35 @@ describe("QueryLogs", () => { ); }); + test("consolidates adjacent duplicate rows into a single card under the default time sort", async () => { + // Same domain/status/device/client_ip/protocol, differing only in query_type (A + AAAA): + // these are sequential duplicates and collapse into one card. + const dupA = makeLog({ dns_request: { domain: "dup.example.com", query_type: "A" }, timestamp: "2024-01-01T00:00:02Z" }); + const dupAAAA = makeLog({ dns_request: { domain: "dup.example.com", query_type: "AAAA" }, timestamp: "2024-01-01T00:00:01Z" }); + const other = makeLog({ dns_request: { domain: "other.example.com", query_type: "A" }, timestamp: "2024-01-01T00:00:00Z" }); + queryLogsMock.mockResolvedValue({ status: 200, data: [dupA, dupAAAA, other] }); + + render(); + // 3 raw logs → 2 cards (the A+AAAA pair merges). + await waitFor(() => expect(screen.getAllByTestId("log-card")).toHaveLength(2)); + }); + + test("does not consolidate when sorted by domain", async () => { + const dupA = makeLog({ dns_request: { domain: "dup.example.com", query_type: "A" }, timestamp: "2024-01-01T00:00:02Z" }); + const dupAAAA = makeLog({ dns_request: { domain: "dup.example.com", query_type: "AAAA" }, timestamp: "2024-01-01T00:00:01Z" }); + queryLogsMock.mockResolvedValue({ status: 200, data: [dupA, dupAAAA] }); + + render(); + await waitFor(() => expect(screen.getAllByTestId("log-card")).toHaveLength(1)); + + act(() => { + fireEvent.click(screen.getByTestId("sort-domain")); + }); + + // Under domain sort, sequential-duplicate consolidation is disabled → both rows render. + await waitFor(() => expect(screen.getAllByTestId("log-card")).toHaveLength(2)); + }); + test("shows not active state when logs disabled", async () => { const disabledProfile = { ...baseProfile, profile_id: "profile-disabled", id: "profile-disabled", settings: { logs: { enabled: false } } }; queryLogsMock.mockResolvedValue({ status: 200, data: [] }); diff --git a/app/src/__tests__/unit/lib/consolidateLogs.test.ts b/app/src/__tests__/unit/lib/consolidateLogs.test.ts new file mode 100644 index 00000000..ba7ebee2 --- /dev/null +++ b/app/src/__tests__/unit/lib/consolidateLogs.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect } from 'vitest'; +import { consolidateLogs, toSingletonGroup } from '@/lib/consolidateLogs'; +import type { ModelQueryLog } from '@/api/client'; + +// Minimal log factory — override only what a test cares about. +const log = (over: Partial & { domain?: string; query_type?: string; response_code?: string }): ModelQueryLog => { + const { domain, query_type, response_code, ...rest } = over; + return { + profile_id: 'p1', + status: 'processed', + protocol: 'dns', + device_id: 'dev1', + client_ip: '10.0.0.1', + timestamp: '2026-06-15T10:00:00.000Z', + dns_request: { domain, query_type, response_code }, + ...rest, + }; +}; + +describe('consolidateLogs', () => { + it('merges an adjacent A + AAAA run for the same domain into one group', () => { + const groups = consolidateLogs([ + log({ domain: 'example.com', query_type: 'A', response_code: 'NOERROR', timestamp: '2026-06-15T10:00:02.000Z' }), + log({ domain: 'example.com', query_type: 'AAAA', response_code: 'NOERROR', timestamp: '2026-06-15T10:00:01.000Z' }), + ]); + expect(groups).toHaveLength(1); + expect(groups[0].count).toBe(2); + expect(groups[0].queryTypes).toEqual(['A', 'AAAA']); + expect(groups[0].responseCodes).toEqual(['NOERROR']); + expect(groups[0].representative.dns_request?.query_type).toBe('A'); + expect(groups[0].firstTimestamp).toBe('2026-06-15T10:00:02.000Z'); + expect(groups[0].lastTimestamp).toBe('2026-06-15T10:00:01.000Z'); + }); + + it('keeps non-adjacent same-domain entries separate (X, Y, X -> 3 groups)', () => { + const groups = consolidateLogs([ + log({ domain: 'x.com' }), + log({ domain: 'y.com' }), + log({ domain: 'x.com' }), + ]); + expect(groups.map((g) => g.representative.dns_request?.domain)).toEqual(['x.com', 'y.com', 'x.com']); + expect(groups.every((g) => g.count === 1)).toBe(true); + }); + + it('does not merge across a status boundary', () => { + const groups = consolidateLogs([ + log({ domain: 'ads.com', status: 'processed' }), + log({ domain: 'ads.com', status: 'blocked' }), + ]); + expect(groups).toHaveLength(2); + }); + + it('does not merge across differing device_id, client_ip, or protocol', () => { + expect(consolidateLogs([log({ domain: 'a.com', device_id: 'dev1' }), log({ domain: 'a.com', device_id: 'dev2' })])).toHaveLength(2); + expect(consolidateLogs([log({ domain: 'a.com', client_ip: '10.0.0.1' }), log({ domain: 'a.com', client_ip: '10.0.0.2' })])).toHaveLength(2); + expect(consolidateLogs([log({ domain: 'a.com', protocol: 'dns' }), log({ domain: 'a.com', protocol: 'doh' })])).toHaveLength(2); + }); + + it('merges an adjacent run of empty-domain rows but never empty with non-empty', () => { + const merged = consolidateLogs([ + log({ domain: undefined, query_type: 'A' }), + log({ domain: undefined, query_type: 'AAAA' }), + ]); + expect(merged).toHaveLength(1); + expect(merged[0].count).toBe(2); + + const split = consolidateLogs([ + log({ domain: undefined }), + log({ domain: 'real.com' }), + ]); + expect(split).toHaveLength(2); + }); + + it('normalizes case and a trailing dot when comparing domains', () => { + const groups = consolidateLogs([ + log({ domain: 'Example.com.', query_type: 'A' }), + log({ domain: 'example.com', query_type: 'AAAA' }), + ]); + expect(groups).toHaveLength(1); + expect(groups[0].count).toBe(2); + }); + + it('preserves order and assigns count 1 to singletons', () => { + const groups = consolidateLogs([ + log({ domain: 'a.com', query_type: 'A' }), + log({ domain: 'a.com', query_type: 'AAAA' }), + log({ domain: 'b.com' }), + ]); + expect(groups.map((g) => g.count)).toEqual([2, 1]); + expect(groups.map((g) => g.representative.dns_request?.domain)).toEqual(['a.com', 'b.com']); + }); + + it('produces distinct, stable keys for non-adjacent groups with the same signature', () => { + const groups = consolidateLogs([ + log({ domain: 'x.com' }), + log({ domain: 'y.com' }), + log({ domain: 'x.com' }), + ]); + expect(new Set(groups.map((g) => g.key)).size).toBe(3); + }); + + it('returns [] for an empty input', () => { + expect(consolidateLogs([])).toEqual([]); + }); + + it('toSingletonGroup wraps one log as a count-1 group', () => { + const g = toSingletonGroup(log({ domain: 'a.com', query_type: 'A' }), 0); + expect(g.count).toBe(1); + expect(g.queryTypes).toEqual(['A']); + expect(g.representative.dns_request?.domain).toBe('a.com'); + }); +}); diff --git a/app/src/lib/consolidateLogs.ts b/app/src/lib/consolidateLogs.ts new file mode 100644 index 0000000000000000000000000000000000000000..f5883f7a6d12fee3ba18dadf84b43ae5d3a7e9f5 GIT binary patch literal 4958 zcmb_g+io1k5zX^{MR{Y`v(oOY1j9x^N>moKY$#DICsYsz3_&$#dUsl!nVxj_klZK) z@{|t<@`e7Aoa*kmt*Kmq5CqBXOWjV@scNp*l=`w`W;UWxp@$gn>0!zTkv_OZ`( zB~(q;i9Oq9Wg`ryMk3Q)N=k4yT2e*F7CV2nYa8G$aeh^}S>Dk*9>v29EEmPfmD zxfN~cSg2C~*=pmK_zBR;QW^lmYIF^`!coE?a^j}W-YwGEOjQjocqHiq{YK}q{4HlI z&d@z=jjm|^%e83KIz(-LJe$coe+PI=LRJ=@jK}DSqN}jRzH}tDa%mrAxoCXQ_@E5 z6z3pnLYiJlwERH&`Nbe?@GWKRkQ`Oxy^J0Pl*hjH!odR8z3nc&_;}pBAFHPq2O4yV zP`L-9*J~)nzgO`4TA|7#?=TZs4hHCg3v%T{fvAW4KA&&qOPYhHHuH-CaLGIyOZrn`^3QF@ z&8Y3sVHT2<`c|4uz@EoGSDS&-oDr(@Jf+=&OK|@34v3aA&2*K{JzdY`urz0Z!hlsn z_L2xWJ0gs9#tp~ycF5?&b4miE^vEwdhw1r=FP?aSUc8|Be33f+nk_$t<;fyN0hFlR zWc{b~(>0KpFHqO8=9O;+ZqKwUxTPIYPi+(03ygGINc5rCulYEt4MC}d%L;A@DDx_E zol_{9Ty3|YER>5!{gN|=xRV5QFBx5Y9_nCCd2OxCQhJBNaIJj@UMJ8dNOsG%dTv#_ z+P6(lc_I)!pQ|MkJD)}u0(V}OI=hy6N-wL%?N^bGV~n^pqk?xV6)hrl3AF#uKI2EeFy684!TUA2LmIUvEPFfN4N2miTA`poRGww+G|lwsKc zf&2vMa}PI@ZI`VvOZak%AYK~o$JV__gQCT7nSfZ3N{{Nicgc%HrzURGFsa*SWk*oCx|_0ARe{P^)AUS|0C za*`DE>=}Kz0KF7UF*uGf#xSRCT%r~W`YS|v0Pp=zBQZACgnK6^C-j6X{ml2!Eh*+1kvHJhcmi$9kwihx=!RV3`cG_4TBwH*whx*(Py90C#)xh zlgSNW`rw7L0{-$_BG0iEw1#CO=uMl}7L$#D>;zw&8-od-xrs>*&Bq3f{|3_2heo%9 z@J**BXdLF2;7d%w!DBzkb@|50hT;md`VC$m0DhccBRV=b*+(!AV^MFcpMk65`miva zj*dnK4`a}9dNSz@ho{1fLh_Q0TDBo_b=c0<{KWJoDQLfv&eP#y4d3R#*?HMGJ0`yQv(f5Cie3S z6#qk+j7knk845VOQ9pWgD0=4X2>(x1jktl2g=ecf8^{x^RJWLTKsfjnDuhoCJclh% zxX*4MXmHF+krfI1h5^@S%1}(fc0>u+`dIPN5tA?n4VKU+&x3MiC4Uj0hwVdd>Jr`4 z&{Qt7RH*LY2Y(K<#DB5Y?sDj9#2x5>`a;wL$#t~VU0aUK;_)}|`h*=uUJjb0R>lGb z$0rq=G3(w3{> zZyay&vf$Yl|1II(*a-X+1wp; kf2gJ+y3vImP>+M>_E5||L8C-O87@YP;y)}7>-u>1FAOYC { [loading, hasMore] ); + // Consolidate sequential duplicate rows (issue #161). Runs over the FULL accumulated + // logs array, so groups that straddle a pagination boundary heal automatically once the + // next page appends. Sequential adjacency is only meaningful under the default time sort; + // under domain/client_ip sort every row stays un-merged (identical to before this feature). + const displayGroups = useMemo( + () => (sortValue === "created" ? consolidateLogs(logs) : logs.map(toSingletonGroup)), + [logs, sortValue] + ); + const activeProfile = useAppStore((state) => state.activeProfile); const { setActiveProfile } = useAppStore(); @@ -471,12 +481,13 @@ const QueryLogs = ({ profiles }: QueryLogsProps): JSX.Element => {
)} - {logs.map((log, index) => { - const isLast = index === logs.length - 1; + {displayGroups.map((group, index) => { + const isLast = index === displayGroups.length - 1; return ( 1` the row shows a + * ×N badge and the expanded panel aggregates the members. Omitted / count 1 → single-entry + * row, rendered identically to before this feature. + */ + group?: ConsolidatedLogGroup; isLast?: boolean; lastLogRef?: (node: HTMLDivElement | null) => void; onQuickRule?: (domain?: string, defaultAction?: "denylist" | "allowlist") => void; @@ -22,7 +29,10 @@ interface QueryLogCardProps { onExpand?: () => void; } -const QueryLogCard = ({ log, isLast, lastLogRef, onQuickRule, quickRuleRestricted, blocklistNames, serviceNames, onExpand }: QueryLogCardProps): JSX.Element | null => { +const QueryLogCard = ({ log, group, isLast, lastLogRef, onQuickRule, quickRuleRestricted, blocklistNames, serviceNames, onExpand }: QueryLogCardProps): JSX.Element | null => { + // Consolidation: count>1 means this card stands in for a run of adjacent duplicate queries. + const count = group?.count ?? 1; + const isConsolidated = count > 1; // If domain logging is disabled, dns_request.domain may be absent. Provide a placeholder. const rawDomain = log.dns_request?.domain; const normalizedDomain = rawDomain ? rawDomain.replace(/\.$/, "") : undefined; @@ -118,7 +128,9 @@ const QueryLogCard = ({ log, isLast, lastLogRef, onQuickRule, quickRuleRestricte "font-text-xs-leading-4-semibold font-semibold text-[10px] md:text-[length:var(--text-xs-leading-4-semibold-font-size)] tracking-wide leading-4 md:leading-[var(--text-xs-leading-4-semibold-line-height)] uppercase whitespace-nowrap", dnssecShown ? (dnssecFailed ? "text-[var(--tailwind-colors-red-600)]" : "text-[var(--tailwind-colors-rdns-600)]") - : "opacity-0 pointer-events-none select-none", + // Reserve placeholder (desktop only): take no vertical line in the tablet + // stack (md), but keep reserving horizontal space in the lg row. + : "opacity-0 pointer-events-none select-none md:hidden lg:inline-block", className, )} > @@ -145,6 +157,39 @@ const QueryLogCard = ({ log, isLast, lastLogRef, onQuickRule, quickRuleRestricte ? { text: 'Validated', className: 'text-[var(--tailwind-colors-rdns-600)]' } : { text: 'No DNSSEC', className: 'text-[var(--tailwind-colors-slate-200)]' }; + // Consolidation badge: a small non-interactive "×N" pill shown next to the domain when + // this card merges multiple adjacent duplicate queries. Styled like the protocol/DNSSEC + // micro-labels. Plain text, so it can sit under the whole-card toggle overlay. + const renderCountBadge = (className?: string) => { + if (!isConsolidated) return null; + return ( + + ×{count} + + ); + }; + + // Expanded-panel field values: aggregate across members when consolidated, else fall back + // to the representative's single value. + const queryTypeText = isConsolidated ? group?.queryTypes.join(', ') : log.dns_request?.query_type; + const responseCodeText = isConsolidated ? group?.responseCodes.join(', ') : log.dns_request?.response_code; + const timeText = (() => { + if (isConsolidated && group?.firstTimestamp && group?.lastTimestamp) { + const first = format(parseISO(group.firstTimestamp), "MMMM d, yyyy 'at' hh:mm:ss a"); + const last = format(parseISO(group.lastTimestamp), "hh:mm:ss a"); + return `${first} – ${last}`; + } + return log.timestamp ? format(parseISO(log.timestamp), "MMMM d, yyyy 'at' hh:mm:ss a") : "—"; + })(); + return (
-
+
{displayDomain ? (
@@ -224,6 +270,7 @@ const QueryLogCard = ({ log, isLast, lastLogRef, onQuickRule, quickRuleRestricte ) : ( '-' )} + {renderCountBadge()}
@@ -236,12 +283,12 @@ const QueryLogCard = ({ log, isLast, lastLogRef, onQuickRule, quickRuleRestricte
{!isMobile && (
-
-
+
+
{protocolLabel}
- {renderDnssecBadge("order-2 md:order-2", true)} - + {renderDnssecBadge("order-2", true)} + Blocked
@@ -280,13 +327,14 @@ const QueryLogCard = ({ log, isLast, lastLogRef, onQuickRule, quickRuleRestricte
Domain logging disabled
)} - {log.dns_request?.query_type && renderDetailField("Query type", log.dns_request.query_type, "querylog-detail-query-type")} - {log.dns_request?.response_code && renderDetailField("Response code", log.dns_request.response_code, "querylog-detail-response-code")} + {queryTypeText && renderDetailField(isConsolidated ? "Query types" : "Query type", queryTypeText, "querylog-detail-query-type")} + {responseCodeText && renderDetailField(isConsolidated ? "Response codes" : "Response code", responseCodeText, "querylog-detail-response-code")} {(log.dns_request?.dnssec !== undefined || dnssecFailed) && renderDetailField("DNSSEC", dnssecDetail.text, "querylog-detail-dnssec", dnssecDetail.className)} {renderDetailField("Protocol", protocolLabel, "querylog-detail-protocol")} + {isConsolidated && renderDetailField("Occurrences", String(count), "querylog-detail-occurrences")} {log.client_ip && renderDetailField("Client IP", log.client_ip, "querylog-detail-client-ip")} {log.device_id && renderDetailField("Device ID", log.device_id, "querylog-detail-device-id")} - {renderDetailField("Time", log.timestamp ? format(parseISO(log.timestamp), "MMMM d, yyyy 'at' hh:mm:ss a") : "—", "querylog-detail-timestamp")} + {renderDetailField(isConsolidated ? "Time range" : "Time", timeText, "querylog-detail-timestamp")} {hasReasons && (
From 565cd1a9f1d8d1346f95d7fdb9a04bf16a9cc093 Mon Sep 17 00:00:00 2001 From: Maciek Date: Wed, 15 Jul 2026 11:24:51 +0200 Subject: [PATCH 13/67] fix(app): Show single time for one-second consolidated log groups Signed-off-by: Maciek --- app/src/__tests__/unit/QueryLogCard.test.tsx | 18 +++++++++++++++- app/src/pages/logs/QueryLogCard.tsx | 22 ++++++++++++-------- 2 files changed, 30 insertions(+), 10 deletions(-) diff --git a/app/src/__tests__/unit/QueryLogCard.test.tsx b/app/src/__tests__/unit/QueryLogCard.test.tsx index 9efd71be..c8891f50 100644 --- a/app/src/__tests__/unit/QueryLogCard.test.tsx +++ b/app/src/__tests__/unit/QueryLogCard.test.tsx @@ -289,8 +289,24 @@ describe('QueryLogCard consolidation (issue #161)', () => { expect(screen.getByTestId('querylog-detail-query-type')).toHaveTextContent('A, AAAA'); expect(screen.getByTestId('querylog-detail-response-code')).toHaveTextContent('NOERROR, NXDOMAIN'); expect(screen.getByTestId('querylog-detail-occurrences')).toHaveTextContent('3'); - // Time range renders both endpoints separated by an en dash. + // group spans 2s (10:20:30 → 10:20:32) → a time RANGE with an en dash and "Time range" label. expect(screen.getByTestId('querylog-detail-timestamp').textContent).toMatch(/–/); + expect(screen.getByText('Time range')).toBeInTheDocument(); + }); + + test('a group whose members share the same second shows a single "Time", not a range', () => { + // A + AAAA fired back-to-back: same second, differing only in milliseconds. + const sameSecondGroup = { + ...group, + firstTimestamp: '2026-06-15T10:20:32.480Z', + lastTimestamp: '2026-06-15T10:20:32.010Z', + }; + render(); + fireEvent.click(screen.getByTestId('querylog-card-toggle')); + // No en dash → single time; label is the plain "Time" (exact, not "Time range"). + expect(screen.getByTestId('querylog-detail-timestamp').textContent).not.toMatch(/–/); + expect(screen.getByText('Time')).toBeInTheDocument(); + expect(screen.queryByText('Time range')).not.toBeInTheDocument(); }); }); diff --git a/app/src/pages/logs/QueryLogCard.tsx b/app/src/pages/logs/QueryLogCard.tsx index 040c1a8e..80b1ed5b 100644 --- a/app/src/pages/logs/QueryLogCard.tsx +++ b/app/src/pages/logs/QueryLogCard.tsx @@ -181,14 +181,18 @@ const QueryLogCard = ({ log, group, isLast, lastLogRef, onQuickRule, quickRuleRe // to the representative's single value. const queryTypeText = isConsolidated ? group?.queryTypes.join(', ') : log.dns_request?.query_type; const responseCodeText = isConsolidated ? group?.responseCodes.join(', ') : log.dns_request?.response_code; - const timeText = (() => { - if (isConsolidated && group?.firstTimestamp && group?.lastTimestamp) { - const first = format(parseISO(group.firstTimestamp), "MMMM d, yyyy 'at' hh:mm:ss a"); - const last = format(parseISO(group.lastTimestamp), "hh:mm:ss a"); - return `${first} – ${last}`; - } - return log.timestamp ? format(parseISO(log.timestamp), "MMMM d, yyyy 'at' hh:mm:ss a") : "—"; - })(); + // A group only shows a first–last time RANGE when its endpoints differ at second granularity. + // A + AAAA fired back-to-back land in the same second, so those collapse to a single "Time" + // (like a non-consolidated row); groups that genuinely span >=1s (e.g. grouped blocked queries) + // keep the range. + const secKey = (ts?: string) => (ts ? format(parseISO(ts), "yyyy-MM-dd'T'HH:mm:ss") : undefined); + const hasTimeRange = Boolean( + isConsolidated && group?.firstTimestamp && group?.lastTimestamp && + secKey(group.firstTimestamp) !== secKey(group.lastTimestamp) + ); + const timeText = hasTimeRange + ? `${format(parseISO(group!.firstTimestamp!), "MMMM d, yyyy 'at' hh:mm:ss a")} – ${format(parseISO(group!.lastTimestamp!), "hh:mm:ss a")}` + : (log.timestamp ? format(parseISO(log.timestamp), "MMMM d, yyyy 'at' hh:mm:ss a") : "—"); return (
{hasReasons && (
From 9b1a4a18733748ebf8f0aa5786629f8c039f7b1a Mon Sep 17 00:00:00 2001 From: Maciek Date: Wed, 15 Jul 2026 11:57:21 +0200 Subject: [PATCH 14/67] fix(app): Cap consolidated query-log group time span at 10s Signed-off-by: Maciek --- .../unit/lib/consolidateLogs.test.ts | 52 ++++++++++++++++++ app/src/lib/consolidateLogs.ts | Bin 4958 -> 6212 bytes 2 files changed, 52 insertions(+) diff --git a/app/src/__tests__/unit/lib/consolidateLogs.test.ts b/app/src/__tests__/unit/lib/consolidateLogs.test.ts index ba7ebee2..b2df2947 100644 --- a/app/src/__tests__/unit/lib/consolidateLogs.test.ts +++ b/app/src/__tests__/unit/lib/consolidateLogs.test.ts @@ -103,6 +103,58 @@ describe('consolidateLogs', () => { expect(consolidateLogs([])).toEqual([]); }); + it('does not merge same-domain entries more than the span window apart', () => { + // Blocked-filter scenario: two blocks of the same domain 5 minutes apart become adjacent + // in the filtered stream, but must NOT merge (default 10s window). + const groups = consolidateLogs([ + log({ domain: 'ads.tracker.com', status: 'blocked', timestamp: '2026-06-15T11:38:09.000Z' }), + log({ domain: 'ads.tracker.com', status: 'blocked', timestamp: '2026-06-15T11:33:09.000Z' }), + ]); + expect(groups).toHaveLength(2); + expect(groups.every((g) => g.count === 1)).toBe(true); + }); + + it('splits a domain blocked repeatedly over an hour into one row per block', () => { + const base = Date.parse('2026-06-15T11:38:09.000Z'); + const items = Array.from({ length: 8 }, (_, i) => + // ~8 minutes apart, newest first (created-desc). + log({ domain: 'ads.tracker.com', status: 'blocked', timestamp: new Date(base - i * 8 * 60_000).toISOString() }) + ); + const groups = consolidateLogs(items); + expect(groups).toHaveLength(8); + expect(groups.every((g) => g.count === 1)).toBe(true); + }); + + it('measures the span from the run first member, not the previous member', () => { + // 12:00:00 anchors the run. 11:59:55 is 5s away → merges. 11:59:48 is only 7s from the + // previous member but 12s from the anchor → it starts a new group. + const groups = consolidateLogs([ + log({ domain: 'a.com', query_type: 'A', timestamp: '2026-06-15T12:00:00.000Z' }), + log({ domain: 'a.com', query_type: 'AAAA', timestamp: '2026-06-15T11:59:55.000Z' }), + log({ domain: 'a.com', query_type: 'A', timestamp: '2026-06-15T11:59:48.000Z' }), + ]); + expect(groups.map((g) => g.count)).toEqual([2, 1]); + }); + + it('respects a custom span window', () => { + const items = [ + log({ domain: 'a.com', query_type: 'A', timestamp: '2026-06-15T12:00:00.000Z' }), + log({ domain: 'a.com', query_type: 'AAAA', timestamp: '2026-06-15T11:59:30.000Z' }), + ]; + // 30s apart: outside the default 10s window (2 groups) but inside a 60s window (1 group). + expect(consolidateLogs(items)).toHaveLength(2); + expect(consolidateLogs(items, 60_000)).toHaveLength(1); + }); + + it('still merges a sub-second A + AAAA pair', () => { + const groups = consolidateLogs([ + log({ domain: 'example.com', query_type: 'A', timestamp: '2026-06-15T10:00:00.400Z' }), + log({ domain: 'example.com', query_type: 'AAAA', timestamp: '2026-06-15T10:00:00.000Z' }), + ]); + expect(groups).toHaveLength(1); + expect(groups[0].count).toBe(2); + }); + it('toSingletonGroup wraps one log as a count-1 group', () => { const g = toSingletonGroup(log({ domain: 'a.com', query_type: 'A' }), 0); expect(g.count).toBe(1); diff --git a/app/src/lib/consolidateLogs.ts b/app/src/lib/consolidateLogs.ts index f5883f7a6d12fee3ba18dadf84b43ae5d3a7e9f5..38bbff1dc7d49a778849272990e74eb57ac11aa0 100644 GIT binary patch delta 1278 zcmZuxJ#Q015S0*$kc9+@f_7+K#GJ=GUH*X7ep1dlBT*bOe{p>lXqCGh-rQ_5{vmaX_gsc)rMi+ZjiX90}b5#iWT=rVa z%QWPopsDh?(ugUC|2i{MGQ(NOAv@LpuVE?DG8N}z6e<(3boBZiIRlQFDH->Z!lXNr zk(El=O9n_Y5gTBb3Zq90fz2r%Dz32>+#7YpS);LH*rE;zw3U(1QEUOx&>4(wA8dUE>d?!(Z8n4b} zBxf@~ra28Pw+3|+UF8Nmh&T>(&xDfrFY0yS<7U>`=Uq-*#MGfArkl60RM2L%_3H4) z!siR^cKCh}%oPMb6%CC38P&nJi`Nc5UHUbHHuoDDj=j+s>%>xY>b#PDn!|bh!CJks zDeFvSVFzoI9UHFkZ%sT*2lY?S0g^A1=phw77`F k&{dE#YQWsn-m^R1@J!XK(Z(Y%n*TgIf%x$2<@e`)19}I#1poj5 delta 25 hcmX?Na8GT+Beu;}Tst@x%Ly}1wh`6cyjFBN699&t2|NG* From c5f6567a2f28eb4b496d89a34d23a70866bd0b3e Mon Sep 17 00:00:00 2001 From: Maciek Date: Mon, 20 Jul 2026 18:14:54 +0200 Subject: [PATCH 15/67] test(e2e): Fix flaky tests Signed-off-by: Maciek --- tests/dns_tests/infra/test_redis_failover.py | 8 ++--- tests/dns_tests/test_multiple_users.py | 7 ++++- .../test_profile_export_import_behaviour.py | 29 +++++++------------ 3 files changed, 20 insertions(+), 24 deletions(-) diff --git a/tests/dns_tests/infra/test_redis_failover.py b/tests/dns_tests/infra/test_redis_failover.py index 20c0d679..70445bc7 100644 --- a/tests/dns_tests/infra/test_redis_failover.py +++ b/tests/dns_tests/infra/test_redis_failover.py @@ -100,11 +100,11 @@ async def test_proxy_falls_back_to_master_when_replica_stops(self): Stop the DNS read-replica and verify the proxy continues to resolve queries by falling back to the sentinel-managed master. """ - # 1. Baseline: query succeeds via replica. - resp = await self.dns_lib.send_doh_request( - self.profile_id, "example.com", "A" + # 1. Baseline: poll rather than one-shot — the class account was just + # created and its profile must replicate to the proxy's replica first. + await self._wait_dns_healthy( + RECOVERY_TIMEOUT, "baseline (fresh profile replication)" ) - assert len(resp.answer) > 0, "Baseline DNS query failed" # 2. Stop the read replica. self._get_replica().stop() diff --git a/tests/dns_tests/test_multiple_users.py b/tests/dns_tests/test_multiple_users.py index 84c2b742..38b5822a 100644 --- a/tests/dns_tests/test_multiple_users.py +++ b/tests/dns_tests/test_multiple_users.py @@ -6,6 +6,7 @@ from dns.rdataclass import IN from dns.rdatatype import A +from libs.dns_lib import is_resolved from libs.session import ProfileSession @@ -49,9 +50,13 @@ async def test_multiple_temporary_accounts_sending_doh_requests(self): ), ] + # wait_for, not resolve: the accounts were just created, so the + # first query must poll until each profile replicates to the proxy. results = await asyncio.gather( *[ - session.resolve(session.default_profile_id, dns_request.domain, A) + session.wait_for( + session.default_profile_id, dns_request.domain, A, is_resolved + ) for session, dns_request in requests ] ) diff --git a/tests/dns_tests/test_profile_export_import_behaviour.py b/tests/dns_tests/test_profile_export_import_behaviour.py index 98302b05..f23f70ea 100644 --- a/tests/dns_tests/test_profile_export_import_behaviour.py +++ b/tests/dns_tests/test_profile_export_import_behaviour.py @@ -7,7 +7,7 @@ """ import pytest -from libs.dns_lib import DNSLib +from libs.dns_lib import DNSLib, assert_blocked, is_blocked from libs.settings import get_settings from libs.profile_helpers import ( ProfileHelpers, @@ -111,29 +111,20 @@ async def test_export_then_import_preserves_dns_filtering( new_profile_id = body["createdProfileIds"][0] assert isinstance(new_profile_id, str) and new_profile_id - resp = await self.dns_lib.send_doh_request(new_profile_id, BLOCKLISTED_DOMAIN, A) - ip_addr = resp.answer[0].to_text().split(" ")[-1] - assert ip_addr == "0.0.0.0", ( - f"Imported profile did not apply blocklist; {BLOCKLISTED_DOMAIN} -> {ip_addr}" + resp = await self.dns_lib.wait_until( + new_profile_id, BLOCKLISTED_DOMAIN, A, is_blocked ) + assert_blocked(resp, f"{BLOCKLISTED_DOMAIN} (imported blocklist)") - resp = await self.dns_lib.send_doh_request( - new_profile_id, SVC_GOOGLE_DOMAIN, A - ) - ip_addr = resp.answer[0].to_text().split(" ")[-1] - assert ip_addr == "0.0.0.0", ( - f"Imported profile did not apply service block; " - f"{SVC_GOOGLE_DOMAIN} -> {ip_addr}" + resp = await self.dns_lib.wait_until( + new_profile_id, SVC_GOOGLE_DOMAIN, A, is_blocked ) + assert_blocked(resp, f"{SVC_GOOGLE_DOMAIN} (imported service block)") - resp = await self.dns_lib.send_doh_request( - new_profile_id, CUSTOM_RULE_DOMAIN, A - ) - ip_addr = resp.answer[0].to_text().split(" ")[-1] - assert ip_addr == "0.0.0.0", ( - f"Imported profile did not apply custom rule; " - f"{CUSTOM_RULE_DOMAIN} -> {ip_addr}" + resp = await self.dns_lib.wait_until( + new_profile_id, CUSTOM_RULE_DOMAIN, A, is_blocked ) + assert_blocked(resp, f"{CUSTOM_RULE_DOMAIN} (imported custom rule)") imported = _get_profile(self.api_config, cookie_b, new_profile_id) assert imported.settings.security.dnssec.enabled is True, ( From a251833107a4df5cae7c990663c7f50e186806a2 Mon Sep 17 00:00:00 2001 From: "LamTrinh.Dev" Date: Thu, 30 Apr 2026 21:30:12 +0700 Subject: [PATCH 16/67] perf(proxy): optimize subdomain checking by building candidates incrementally MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace O(n²) strings.Join in loop with O(n) incremental string building. For domains with many subdomains (e.g., a.b.c.d.e.com), this reduces string operations from n*(n+1)/2 to n. Before: strings.Join(parts[i:], ".") in loop creates n+(n-1)+...+1 operations After: Build strings incrementally by prepending parts: n operations --- proxy/filter/blocklists.go | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/proxy/filter/blocklists.go b/proxy/filter/blocklists.go index b6a97fed..4df68664 100644 --- a/proxy/filter/blocklists.go +++ b/proxy/filter/blocklists.go @@ -50,8 +50,20 @@ func (f *DomainFilter) filterBlocklists(reqCtx *requestcontext.RequestContext, d if reqCtx.PrivacySettings[SUBDOMAINS_RULE] == RULE_BLOCK { // iterate over all subdomains parts := strings.Split(fqdn, ".") - for i := range len(parts) - 1 { - candidate := strings.Join(parts[i:], ".") + var candidate string + for i := len(parts) - 1; i >= 0; i-- { + // Build candidate incrementally by prepending current part + if i == len(parts)-1 { + candidate = parts[i] + } else { + candidate = parts[i] + "." + candidate + } + + // Skip the full domain as it was already checked above + if i == 0 { + continue + } + // now, check if candidate domain is part of any blocklist entry blocklisted, err = f.Cache.GetBlocklistEntry(context.Background(), blocklistId, candidate) if err != nil { From 0edb6c1765fd52ef69765e6af83af1fe3411e207 Mon Sep 17 00:00:00 2001 From: "LamTrinh.Dev" Date: Fri, 29 May 2026 22:17:39 +0700 Subject: [PATCH 17/67] Enhance after Copilot feedback. --- proxy/filter/blocklists.go | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/proxy/filter/blocklists.go b/proxy/filter/blocklists.go index 4df68664..59410852 100644 --- a/proxy/filter/blocklists.go +++ b/proxy/filter/blocklists.go @@ -48,22 +48,17 @@ func (f *DomainFilter) filterBlocklists(reqCtx *requestcontext.RequestContext, d } if reqCtx.PrivacySettings[SUBDOMAINS_RULE] == RULE_BLOCK { - // iterate over all subdomains + // iterate over all subdomains (excluding TLD and full FQDN) parts := strings.Split(fqdn, ".") var candidate string - for i := len(parts) - 1; i >= 0; i-- { + for i := len(parts) - 2; i >= 0; i-- { // Build candidate incrementally by prepending current part - if i == len(parts)-1 { - candidate = parts[i] + if i == len(parts)-2 { + candidate = parts[i] + "." + parts[i+1] } else { candidate = parts[i] + "." + candidate } - // Skip the full domain as it was already checked above - if i == 0 { - continue - } - // now, check if candidate domain is part of any blocklist entry blocklisted, err = f.Cache.GetBlocklistEntry(context.Background(), blocklistId, candidate) if err != nil { From 36cbcf2b94abbeb1a74c5d6ea2009d68a5c684bf Mon Sep 17 00:00:00 2001 From: Maciek Date: Tue, 21 Jul 2026 09:05:43 +0200 Subject: [PATCH 18/67] perf(proxy): Skip redundant full-FQDN re-check in subdomain loop Signed-off-by: Maciek --- proxy/filter/blocklists.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/proxy/filter/blocklists.go b/proxy/filter/blocklists.go index 59410852..16702c40 100644 --- a/proxy/filter/blocklists.go +++ b/proxy/filter/blocklists.go @@ -48,10 +48,11 @@ func (f *DomainFilter) filterBlocklists(reqCtx *requestcontext.RequestContext, d } if reqCtx.PrivacySettings[SUBDOMAINS_RULE] == RULE_BLOCK { - // iterate over all subdomains (excluding TLD and full FQDN) + // iterate over all parent domains, excluding the TLD and the full + // FQDN (already covered by the exact-match check above) parts := strings.Split(fqdn, ".") var candidate string - for i := len(parts) - 2; i >= 0; i-- { + for i := len(parts) - 2; i >= 1; i-- { // Build candidate incrementally by prepending current part if i == len(parts)-2 { candidate = parts[i] + "." + parts[i+1] From 57880de36852c3d3d5895fb1a7d5c6304ec6251d Mon Sep 17 00:00:00 2001 From: Maciek Date: Tue, 21 Jul 2026 09:06:52 +0200 Subject: [PATCH 19/67] test(proxy): Add benchmarks for subdomain candidate building Signed-off-by: Maciek --- proxy/filter/blocklists_benchmark_test.go | 90 +++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 proxy/filter/blocklists_benchmark_test.go diff --git a/proxy/filter/blocklists_benchmark_test.go b/proxy/filter/blocklists_benchmark_test.go new file mode 100644 index 00000000..c78f22df --- /dev/null +++ b/proxy/filter/blocklists_benchmark_test.go @@ -0,0 +1,90 @@ +package filter + +import ( + "strings" + "testing" +) + +// Benchmarks for the subdomain candidate-building strategies used by +// filterBlocklists. "Join" is the previous implementation (strings.Join per +// suffix), "Prepend" is the current one (incremental prepending). Both emit +// the same candidate set: every parent domain excluding the TLD and the full +// FQDN. In production each candidate is followed by a blocklist cache lookup, +// which dominates the cost of this loop; these benchmarks isolate the string +// construction itself. + +var subdomainBenchDomains = []struct { + name string + fqdn string +}{ + {"4_Labels", "a.b.c.com"}, + {"7_Labels", "a.b.c.d.e.f.com"}, + {"11_Labels", "a.b.c.d.e.f.g.h.i.j.com"}, +} + +var benchCandidateSink string + +func joinCandidates(fqdn string, visit func(string)) { + parts := strings.Split(fqdn, ".") + for i := 1; i < len(parts)-1; i++ { + visit(strings.Join(parts[i:], ".")) + } +} + +func prependCandidates(fqdn string, visit func(string)) { + parts := strings.Split(fqdn, ".") + var candidate string + for i := len(parts) - 2; i >= 1; i-- { + if i == len(parts)-2 { + candidate = parts[i] + "." + parts[i+1] + } else { + candidate = parts[i] + "." + candidate + } + visit(candidate) + } +} + +func BenchmarkSubdomainCandidatesJoin(b *testing.B) { + for _, tc := range subdomainBenchDomains { + b.Run(tc.name, func(b *testing.B) { + b.ReportAllocs() + for i := 0; i < b.N; i++ { + joinCandidates(tc.fqdn, func(c string) { benchCandidateSink = c }) + } + }) + } +} + +func BenchmarkSubdomainCandidatesPrepend(b *testing.B) { + for _, tc := range subdomainBenchDomains { + b.Run(tc.name, func(b *testing.B) { + b.ReportAllocs() + for i := 0; i < b.N; i++ { + prependCandidates(tc.fqdn, func(c string) { benchCandidateSink = c }) + } + }) + } +} + +// TestSubdomainCandidatesEquivalence guards the refactoring: both strategies +// must produce the identical candidate set, in reverse order of each other. +func TestSubdomainCandidatesEquivalence(t *testing.T) { + fqdns := []string{"com", "b.com", "a.b.com", "a.b.c.com", "a.b.c.d.e.f.g.h.i.j.com"} + for _, fqdn := range fqdns { + var joined, prepended []string + joinCandidates(fqdn, func(c string) { joined = append(joined, c) }) + prependCandidates(fqdn, func(c string) { prepended = append(prepended, c) }) + + for i, j := 0, len(prepended)-1; i < len(prepended)/2; i, j = i+1, j-1 { + prepended[i], prepended[j] = prepended[j], prepended[i] + } + if len(joined) != len(prepended) { + t.Fatalf("%s: candidate count mismatch: %v vs %v", fqdn, joined, prepended) + } + for i := range joined { + if joined[i] != prepended[i] { + t.Fatalf("%s: candidate mismatch at %d: %q vs %q", fqdn, i, joined[i], prepended[i]) + } + } + } +} From 9dcdea2ecb91f870de68b3e5df32dc29f9475e58 Mon Sep 17 00:00:00 2001 From: Maciek Date: Tue, 21 Jul 2026 10:38:37 +0200 Subject: [PATCH 20/67] feat(app): Link login screen logo to landing page Signed-off-by: Maciek --- .../e2e/functional/login-basic.spec.ts | 9 ++++++++ app/src/__tests__/unit/LoginCard.test.tsx | 22 +++++++++++++++++++ app/src/pages/auth/LoginCard.tsx | 16 ++++++++------ 3 files changed, 40 insertions(+), 7 deletions(-) create mode 100644 app/src/__tests__/unit/LoginCard.test.tsx diff --git a/app/src/__tests__/e2e/functional/login-basic.spec.ts b/app/src/__tests__/e2e/functional/login-basic.spec.ts index 31a32f9b..f6aac01c 100644 --- a/app/src/__tests__/e2e/functional/login-basic.spec.ts +++ b/app/src/__tests__/e2e/functional/login-basic.spec.ts @@ -108,4 +108,13 @@ test.describe('Login basic flows (desktop only)', () => { await expect(page).toHaveURL(/\/login/); await expect(page.getByTestId(AUTH_TOAST_IDS.loginTooManyAttempts)).toBeVisible(); }); + + test('clicking the modDNS logo navigates to the landing page', async ({ page }) => { + await registerMocks(page, { authenticated: false }); + await page.goto('/login'); + await page.getByTestId('login-page').waitFor(); + await page.getByRole('link', { name: 'modDNS home' }).click(); + await expect(page).toHaveURL(/\/$/); + await expect(page.locator('.moddns-landing')).toBeVisible(); + }); }); diff --git a/app/src/__tests__/unit/LoginCard.test.tsx b/app/src/__tests__/unit/LoginCard.test.tsx new file mode 100644 index 00000000..c79f34a5 --- /dev/null +++ b/app/src/__tests__/unit/LoginCard.test.tsx @@ -0,0 +1,22 @@ +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { describe, test, expect } from 'vitest'; +import { MemoryRouter } from 'react-router-dom'; +import LoginCard from '@/pages/auth/LoginCard'; + +function renderLoginCard() { + return render( + + + + ); +} + +describe('LoginCard logo link', () => { + test('logo is wrapped in a link pointing to the landing page', () => { + renderLoginCard(); + const link = screen.getByRole('link', { name: /modDNS home/i }); + expect(link).toHaveAttribute('href', '/'); + expect(link.querySelector('img[alt="modDNS logo"]')).not.toBeNull(); + }); +}); diff --git a/app/src/pages/auth/LoginCard.tsx b/app/src/pages/auth/LoginCard.tsx index 3d98035e..ff97d6bd 100644 --- a/app/src/pages/auth/LoginCard.tsx +++ b/app/src/pages/auth/LoginCard.tsx @@ -1,4 +1,4 @@ -import { useNavigate } from "react-router-dom"; +import { Link, useNavigate } from "react-router-dom"; import { Card, CardContent } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; @@ -51,12 +51,14 @@ const LoginCard = ({ onLogin, onPasskeyLogin, loading = false, showOtp = false,
{/* Logo */} - modDNS logo + + modDNS logo +
From 0831ad7cfe8cdc913db94e8f59e7b35f1d8a1e6a Mon Sep 17 00:00:00 2001 From: Maciek Date: Tue, 21 Jul 2026 10:42:35 +0200 Subject: [PATCH 21/67] fix(app): Un-clip search input focus ring on Blocklists page Signed-off-by: Maciek --- .../blocklists-search-focus-ring.spec.ts | 51 +++++++++++++++++++ .../pages/blocklists/MainContentSection.tsx | 6 ++- 2 files changed, 55 insertions(+), 2 deletions(-) create mode 100644 app/src/__tests__/e2e/layout/blocklists-search-focus-ring.spec.ts diff --git a/app/src/__tests__/e2e/layout/blocklists-search-focus-ring.spec.ts b/app/src/__tests__/e2e/layout/blocklists-search-focus-ring.spec.ts new file mode 100644 index 00000000..66f0a4a9 --- /dev/null +++ b/app/src/__tests__/e2e/layout/blocklists-search-focus-ring.spec.ts @@ -0,0 +1,51 @@ +import { test, expect } from '@playwright/test'; +import { registerMocks } from '../../mocks/registerMocks'; + +// #120: the desktop blocklists search sits inside an overflow-x-auto scroll +// container. The shared Input paints its focus ring as a 3px box-shadow outside +// its border box, so the input needs >=3px of room inside the scroller's clip +// box (its padding box) or the ring gets clipped. Geometric proxy assertion: +// the input must be inset >=3px from the clipping ancestor on top/left/bottom. +const VIEWPORTS = [ + { width: 800, height: 600, label: 'md' }, + { width: 1280, height: 800, label: 'lg' }, +]; + +for (const vp of VIEWPORTS) { + test.describe(`@layout blocklists search focus ring (${vp.label})`, () => { + test.beforeEach(async ({ page }) => { + await registerMocks(page, { authenticated: true }); + await page.setViewportSize({ width: vp.width, height: vp.height }); + }); + + test('search input has room for its focus ring inside the scroll container', async ({ page }) => { + await page.goto('/blocklists'); + const search = page.locator('input[aria-label="Search blocklists"]:visible'); + await expect(search).toBeVisible(); + await search.focus(); + + const insets = await search.evaluate((el) => { + let node = el.parentElement; + while (node) { + const cs = getComputedStyle(node); + if (['auto', 'scroll', 'hidden', 'clip'].includes(cs.overflowX)) { + const r = el.getBoundingClientRect(); + const c = node.getBoundingClientRect(); + return { + left: r.left - (c.left + parseFloat(cs.borderLeftWidth)), + top: r.top - (c.top + parseFloat(cs.borderTopWidth)), + bottom: (c.bottom - parseFloat(cs.borderBottomWidth)) - r.bottom, + }; + } + node = node.parentElement; + } + return null; + }); + + expect(insets, 'search input should be inside an overflow container').not.toBeNull(); + expect(insets!.left).toBeGreaterThanOrEqual(3); + expect(insets!.top).toBeGreaterThanOrEqual(3); + expect(insets!.bottom).toBeGreaterThanOrEqual(3); + }); + }); +} diff --git a/app/src/pages/blocklists/MainContentSection.tsx b/app/src/pages/blocklists/MainContentSection.tsx index e29bfdef..6912b927 100644 --- a/app/src/pages/blocklists/MainContentSection.tsx +++ b/app/src/pages/blocklists/MainContentSection.tsx @@ -455,8 +455,10 @@ export default function MainContentSection(): JSX.Element {
- {/* Row 2: horizontal scroll filters line (mobile) / single row on desktop */} -
+ {/* Row 2: horizontal scroll filters line (mobile) / single row on desktop. + md:p-1/-m-1 keeps the 3px focus ring of the search input (and trailing + icon button) inside the overflow-x-auto clip box without shifting layout. */} +
{/* Desktop search (hidden on mobile second row) */}
From 73f1cd806f173095b6e9a398f17d9ca452061951 Mon Sep 17 00:00:00 2001 From: Maciek Date: Tue, 21 Jul 2026 11:04:15 +0200 Subject: [PATCH 22/67] fix(app): Un-clip search and filter focus rings on Logs page Signed-off-by: Maciek --- .../blocklists-search-focus-ring.spec.ts | 51 -------------- .../e2e/layout/search-focus-ring.spec.ts | 67 +++++++++++++++++++ app/src/pages/logs/Filters.tsx | 6 +- 3 files changed, 71 insertions(+), 53 deletions(-) delete mode 100644 app/src/__tests__/e2e/layout/blocklists-search-focus-ring.spec.ts create mode 100644 app/src/__tests__/e2e/layout/search-focus-ring.spec.ts diff --git a/app/src/__tests__/e2e/layout/blocklists-search-focus-ring.spec.ts b/app/src/__tests__/e2e/layout/blocklists-search-focus-ring.spec.ts deleted file mode 100644 index 66f0a4a9..00000000 --- a/app/src/__tests__/e2e/layout/blocklists-search-focus-ring.spec.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { test, expect } from '@playwright/test'; -import { registerMocks } from '../../mocks/registerMocks'; - -// #120: the desktop blocklists search sits inside an overflow-x-auto scroll -// container. The shared Input paints its focus ring as a 3px box-shadow outside -// its border box, so the input needs >=3px of room inside the scroller's clip -// box (its padding box) or the ring gets clipped. Geometric proxy assertion: -// the input must be inset >=3px from the clipping ancestor on top/left/bottom. -const VIEWPORTS = [ - { width: 800, height: 600, label: 'md' }, - { width: 1280, height: 800, label: 'lg' }, -]; - -for (const vp of VIEWPORTS) { - test.describe(`@layout blocklists search focus ring (${vp.label})`, () => { - test.beforeEach(async ({ page }) => { - await registerMocks(page, { authenticated: true }); - await page.setViewportSize({ width: vp.width, height: vp.height }); - }); - - test('search input has room for its focus ring inside the scroll container', async ({ page }) => { - await page.goto('/blocklists'); - const search = page.locator('input[aria-label="Search blocklists"]:visible'); - await expect(search).toBeVisible(); - await search.focus(); - - const insets = await search.evaluate((el) => { - let node = el.parentElement; - while (node) { - const cs = getComputedStyle(node); - if (['auto', 'scroll', 'hidden', 'clip'].includes(cs.overflowX)) { - const r = el.getBoundingClientRect(); - const c = node.getBoundingClientRect(); - return { - left: r.left - (c.left + parseFloat(cs.borderLeftWidth)), - top: r.top - (c.top + parseFloat(cs.borderTopWidth)), - bottom: (c.bottom - parseFloat(cs.borderBottomWidth)) - r.bottom, - }; - } - node = node.parentElement; - } - return null; - }); - - expect(insets, 'search input should be inside an overflow container').not.toBeNull(); - expect(insets!.left).toBeGreaterThanOrEqual(3); - expect(insets!.top).toBeGreaterThanOrEqual(3); - expect(insets!.bottom).toBeGreaterThanOrEqual(3); - }); - }); -} diff --git a/app/src/__tests__/e2e/layout/search-focus-ring.spec.ts b/app/src/__tests__/e2e/layout/search-focus-ring.spec.ts new file mode 100644 index 00000000..e01e6f6f --- /dev/null +++ b/app/src/__tests__/e2e/layout/search-focus-ring.spec.ts @@ -0,0 +1,67 @@ +import { test, expect } from '@playwright/test'; +import { registerMocks } from '../../mocks/registerMocks'; + +// #120: desktop search inputs on the Blocklists and Logs pages sit inside an +// overflow-x-auto scroll container. The shared Input paints its focus ring as a +// 3px box-shadow outside its border box, so the input needs >=3px of room inside +// the scroller's clip box (its padding box) or the ring gets clipped. Geometric +// proxy assertion: the input must be inset >=3px from the clipping ancestor. +// +// The Logs filters row is only "desktop" at lg (1024px); Blocklists at md (768px). +const CASES = [ + { path: '/blocklists', label: 'Search blocklists', viewports: [ + { width: 800, height: 600, tag: 'md' }, + { width: 1280, height: 800, tag: 'lg' }, + ]}, + { path: '/query-logs', label: 'Search domain or its part', viewports: [ + { width: 1280, height: 800, tag: 'lg' }, + ]}, +]; + +for (const c of CASES) { + for (const vp of c.viewports) { + test.describe(`@layout search focus ring ${c.path} (${vp.tag})`, () => { + test.beforeEach(async ({ page }) => { + await registerMocks(page, { + authenticated: true, + customProfiles: [{ id: 'prof1', profile_id: 'prof1', name: 'Default', settings: { logs: { enabled: true } } }] + }); + // Register AFTER registerMocks so it wins over the /profiles catch-all + await page.route(/\/api\/v1\/profiles\/prof1\/logs/i, route => { + route.fulfill({ status: 200, contentType: 'application/json', body: '[]' }); + }); + await page.setViewportSize({ width: vp.width, height: vp.height }); + }); + + test('search input has room for its focus ring inside the scroll container', async ({ page }) => { + await page.goto(c.path); + const search = page.locator(`input[aria-label="${c.label}"]:visible`); + await expect(search).toBeVisible(); + await search.focus(); + + const insets = await search.evaluate((el) => { + let node = el.parentElement; + while (node) { + const cs = getComputedStyle(node); + if (['auto', 'scroll', 'hidden', 'clip'].includes(cs.overflowX)) { + const r = el.getBoundingClientRect(); + const c2 = node.getBoundingClientRect(); + return { + left: r.left - (c2.left + parseFloat(cs.borderLeftWidth)), + top: r.top - (c2.top + parseFloat(cs.borderTopWidth)), + bottom: (c2.bottom - parseFloat(cs.borderBottomWidth)) - r.bottom, + }; + } + node = node.parentElement; + } + return null; + }); + + expect(insets, 'search input should be inside an overflow container').not.toBeNull(); + expect(insets!.left).toBeGreaterThanOrEqual(3); + expect(insets!.top).toBeGreaterThanOrEqual(3); + expect(insets!.bottom).toBeGreaterThanOrEqual(3); + }); + }); + } +} diff --git a/app/src/pages/logs/Filters.tsx b/app/src/pages/logs/Filters.tsx index 50ff5b56..85e5d5e4 100644 --- a/app/src/pages/logs/Filters.tsx +++ b/app/src/pages/logs/Filters.tsx @@ -76,8 +76,10 @@ const Filters = ({
- {/* Row 2 (mobile: single horizontal scroll line) / Full single row (desktop) */} -
+ {/* Row 2 (mobile: single horizontal scroll line) / Full single row (desktop). + p-1/-m-1 keeps the 3px focus rings of the search input and filter + controls inside the overflow-x-auto clip box without shifting layout. */} +
{/* Desktop search (hidden on mobile) */}
From cc0020f753bb55b91d1334dc754bd4016f76be4d Mon Sep 17 00:00:00 2001 From: Maciek Date: Tue, 21 Jul 2026 11:07:36 +0200 Subject: [PATCH 23/67] fix(app): Stop animating mobile header on scroll reflows Signed-off-by: Maciek --- app/src/App.tsx | 6 +++- .../layout/mobile-header-transition.spec.ts | 35 +++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 app/src/__tests__/e2e/layout/mobile-header-transition.spec.ts diff --git a/app/src/App.tsx b/app/src/App.tsx index 9e3a7789..c94d4708 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -532,7 +532,11 @@ function ProtectedLayout() {
{ + test.beforeEach(async ({ page, isMobile }) => { + test.skip(!isMobile, 'mobile-only regression guard'); + await registerMocks(page, { authenticated: true }); + }); + + test('fixed header wrapper does not animate geometry on mobile', async ({ page }) => { + await page.goto('/home'); + const wrapper = page.getByTestId('app-header-wrapper'); + await expect(wrapper).toBeVisible(); + + const transition = await wrapper.evaluate((el) => { + const cs = getComputedStyle(el); + return { property: cs.transitionProperty, duration: cs.transitionDuration }; + }); + + const props = transition.property.split(',').map(p => p.trim()); + const durations = transition.duration.split(',').map(d => parseFloat(d)); + const animated = props.filter((p, i) => (durations[i] ?? durations[0] ?? 0) > 0); + + for (const forbidden of ['all', 'top', 'left', 'right', 'bottom', 'width', 'height', 'transform']) { + expect(animated, `mobile header must not animate "${forbidden}"`).not.toContain(forbidden); + } + }); +}); From 42f073c0a230b0263cdd3dc307c5c98b5c66cfe5 Mon Sep 17 00:00:00 2001 From: Maciek Date: Tue, 21 Jul 2026 12:06:54 +0200 Subject: [PATCH 24/67] fix(app): Stack delete-profile danger card on narrow screens Signed-off-by: Maciek --- .../e2e/layout/edit-profile-dialog.spec.ts | 39 +++++++++++++++++++ app/src/pages/header/EditProfileDialog.tsx | 6 +-- 2 files changed, 42 insertions(+), 3 deletions(-) create mode 100644 app/src/__tests__/e2e/layout/edit-profile-dialog.spec.ts diff --git a/app/src/__tests__/e2e/layout/edit-profile-dialog.spec.ts b/app/src/__tests__/e2e/layout/edit-profile-dialog.spec.ts new file mode 100644 index 00000000..96ba442a --- /dev/null +++ b/app/src/__tests__/e2e/layout/edit-profile-dialog.spec.ts @@ -0,0 +1,39 @@ +import { test, expect } from '@playwright/test'; +import { registerMocks } from '../../mocks/registerMocks'; + +// #122: in the Edit Profile dialog the "Delete profile" danger card used a +// non-wrapping flex row, so on narrow (mobile) viewports the button overflowed +// the card and overlapped the description text. The card must lay the button +// out below the text on mobile and never intersect the description. +test.describe('@layout edit profile dialog danger card', () => { + test.beforeEach(async ({ page }) => { + await registerMocks(page, { authenticated: true }); + }); + + test('delete button does not overlap the danger card description', async ({ page }) => { + // The profile dropdown is hidden on /home; use the Rules page like the + // issue's repro steps (Rules tab -> select profile -> edit icon). + await page.goto('/custom-rules'); + + // Open the profile dropdown and its edit (settings) action + await page.getByRole('combobox').first().click(); + await page.getByTestId('edit-profile-settings').click(); + + const description = page.getByText(/You can delete your profile immediately/); + await expect(description).toBeVisible(); + const deleteButton = page.getByRole('button', { name: 'Delete profile' }); + await expect(deleteButton).toBeVisible(); + + const textBox = await description.boundingBox(); + const buttonBox = await deleteButton.boundingBox(); + expect(textBox).not.toBeNull(); + expect(buttonBox).not.toBeNull(); + + const intersects = + buttonBox!.x < textBox!.x + textBox!.width && + buttonBox!.x + buttonBox!.width > textBox!.x && + buttonBox!.y < textBox!.y + textBox!.height && + buttonBox!.y + buttonBox!.height > textBox!.y; + expect(intersects, 'delete button must not overlap the description text').toBe(false); + }); +}); diff --git a/app/src/pages/header/EditProfileDialog.tsx b/app/src/pages/header/EditProfileDialog.tsx index 7ec1be29..45dc2669 100644 --- a/app/src/pages/header/EditProfileDialog.tsx +++ b/app/src/pages/header/EditProfileDialog.tsx @@ -117,8 +117,8 @@ export default function EditProfileDialog({ {/* Delete profile section */} - -
+ +

Delete profile

@@ -131,7 +131,7 @@ export default function EditProfileDialog({
+ + +
+ ); +} + +function trigger() { + // Handlers live on the wrapper span around the child button + return screen.getByLabelText('info trigger').parentElement as HTMLElement; +} + +// jsdom has no real PointerEvent, so fireEvent.pointerDown drops pointerType; +// dispatch a hand-built event carrying it instead. +function firePointerDown(el: HTMLElement | Document, pointerType: string) { + const ev = new Event('pointerdown', { bubbles: true, cancelable: true }); + Object.defineProperty(ev, 'pointerType', { value: pointerType }); + fireEvent(el, ev); +} + +function tap(el: HTMLElement) { + firePointerDown(el, 'touch'); + fireEvent.click(el); +} + +describe('Tooltip touch support (#127)', () => { + test('tap shows the tooltip immediately', () => { + renderTooltip(); + tap(trigger()); + expect(screen.getByRole('tooltip')).toBeInTheDocument(); + }); + + test('second tap on the trigger hides the tooltip', () => { + renderTooltip(); + tap(trigger()); + expect(screen.getByRole('tooltip')).toBeInTheDocument(); + tap(trigger()); + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); + }); + + test('tap outside hides a tap-opened tooltip', () => { + renderTooltip(); + tap(trigger()); + expect(screen.getByRole('tooltip')).toBeInTheDocument(); + firePointerDown(screen.getByLabelText('outside'), 'touch'); + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); + }); + + test('Escape hides a tap-opened tooltip', () => { + renderTooltip(); + tap(trigger()); + expect(screen.getByRole('tooltip')).toBeInTheDocument(); + fireEvent.keyDown(document, { key: 'Escape' }); + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); + }); + + test('tap-opened tooltip survives the synthetic mouseleave a tap can emit', () => { + renderTooltip(); + tap(trigger()); + fireEvent.mouseLeave(trigger()); + expect(screen.getByRole('tooltip')).toBeInTheDocument(); + }); +}); + +describe('Tooltip hover regression', () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + test('mouse hover still shows after delay and hides on leave', () => { + renderTooltip(150); + fireEvent.mouseEnter(trigger()); + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); + act(() => { vi.advanceTimersByTime(200); }); + expect(screen.getByRole('tooltip')).toBeInTheDocument(); + fireEvent.mouseLeave(trigger()); + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); + }); + + test('mouse click does not toggle a hover-opened tooltip closed', () => { + renderTooltip(0); + fireEvent.mouseEnter(trigger()); + act(() => { vi.advanceTimersByTime(50); }); + expect(screen.getByRole('tooltip')).toBeInTheDocument(); + firePointerDown(trigger(), 'mouse'); + fireEvent.click(trigger()); + expect(screen.getByRole('tooltip')).toBeInTheDocument(); + }); +}); diff --git a/app/src/components/ui/tooltip.tsx b/app/src/components/ui/tooltip.tsx index 7a7e484c..c3894408 100644 --- a/app/src/components/ui/tooltip.tsx +++ b/app/src/components/ui/tooltip.tsx @@ -29,6 +29,12 @@ export const Tooltip: React.FC = ({ const triggerRef = useRef(null); const [style, setStyle] = useState({}); const [mounted, setMounted] = useState(false); + // Touch support (#127): hover never fires on touchscreens, so taps toggle the + // tooltip instead. Track the last pointerdown's type with a timestamp — the + // synthetic mouseenter/focus/click a tap emits arrive within milliseconds, so + // a recent non-mouse pointerdown means "this interaction is a tap". + const lastPointerRef = useRef<{ type: string; at: number }>({ type: 'mouse', at: 0 }); + const openedByTapRef = useRef(false); useEffect(() => { setMounted(true); return () => setMounted(false); }, []); @@ -38,7 +44,40 @@ export const Tooltip: React.FC = ({ clear(); timeoutRef.current = window.setTimeout(() => setOpen(true), delay); }; - const hide = () => { clear(); setOpen(false); }; + const hide = () => { clear(); openedByTapRef.current = false; setOpen(false); }; + + const isRecentTouch = () => + lastPointerRef.current.type !== 'mouse' && Date.now() - lastPointerRef.current.at < 1000; + + const recordPointer = (e: React.PointerEvent) => { + lastPointerRef.current = { type: e.pointerType || 'mouse', at: Date.now() }; + }; + + const handleMouseEnter = () => { if (!isRecentTouch()) show(); }; + const handleMouseLeave = () => { if (!openedByTapRef.current) hide(); }; + const handleFocus = () => { if (!isRecentTouch()) show(); }; + const handleClick = () => { + if (!isRecentTouch()) return; // mouse/keyboard users keep the pure hover/focus UX + clear(); + openedByTapRef.current = !open; + setOpen(v => !v); + }; + + // While tap-opened, dismiss on tap outside the trigger or on Escape. + useEffect(() => { + if (!open || !openedByTapRef.current) return; + const onDocPointerDown = (e: PointerEvent) => { + if (triggerRef.current && !triggerRef.current.contains(e.target as Node)) hide(); + }; + const onKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') hide(); }; + document.addEventListener('pointerdown', onDocPointerDown, true); + document.addEventListener('keydown', onKeyDown); + return () => { + document.removeEventListener('pointerdown', onDocPointerDown, true); + document.removeEventListener('keydown', onKeyDown); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); useEffect(() => { if (open && triggerRef.current) { @@ -102,10 +141,13 @@ export const Tooltip: React.FC = ({ return ( {children} From c77071107b888a1d95bf643684b519653e7da807 Mon Sep 17 00:00:00 2001 From: Maciek Date: Tue, 21 Jul 2026 12:59:38 +0200 Subject: [PATCH 26/67] fix(app): Constrain Edit Profile modal width and stack danger card Signed-off-by: Maciek --- .../e2e/layout/edit-profile-dialog.spec.ts | 30 ++++++++++++------- app/src/pages/header/EditProfileDialog.tsx | 14 +++++---- 2 files changed, 28 insertions(+), 16 deletions(-) diff --git a/app/src/__tests__/e2e/layout/edit-profile-dialog.spec.ts b/app/src/__tests__/e2e/layout/edit-profile-dialog.spec.ts index 96ba442a..a6583a97 100644 --- a/app/src/__tests__/e2e/layout/edit-profile-dialog.spec.ts +++ b/app/src/__tests__/e2e/layout/edit-profile-dialog.spec.ts @@ -3,14 +3,17 @@ import { registerMocks } from '../../mocks/registerMocks'; // #122: in the Edit Profile dialog the "Delete profile" danger card used a // non-wrapping flex row, so on narrow (mobile) viewports the button overflowed -// the card and overlapped the description text. The card must lay the button -// out below the text on mobile and never intersect the description. -test.describe('@layout edit profile dialog danger card', () => { +// the card and overlapped the description text, and the dialog itself +// (max-w-3xl, overriding the primitive's mobile margins) spanned the full +// viewport on phones. The dialog is now capped like the account-preferences +// modals (calc(100vw-2rem) / 500px) and the danger card always stacks the +// button below the description. +test.describe('@layout edit profile dialog', () => { test.beforeEach(async ({ page }) => { await registerMocks(page, { authenticated: true }); }); - test('delete button does not overlap the danger card description', async ({ page }) => { + test('dialog fits the viewport and delete button sits below the description', async ({ page }) => { // The profile dropdown is hidden on /home; use the Rules page like the // issue's repro steps (Rules tab -> select profile -> edit icon). await page.goto('/custom-rules'); @@ -19,6 +22,17 @@ test.describe('@layout edit profile dialog danger card', () => { await page.getByRole('combobox').first().click(); await page.getByTestId('edit-profile-settings').click(); + const dialog = page.locator('[data-slot="dialog-content"]'); + await expect(dialog).toBeVisible(); + + // Dialog leaves horizontal breathing room (1rem each side on mobile, + // 500px cap on larger screens) like the account-preferences modals. + const viewport = page.viewportSize(); + const dialogBox = await dialog.boundingBox(); + expect(dialogBox).not.toBeNull(); + expect(dialogBox!.width).toBeLessThanOrEqual(Math.min(viewport!.width - 24, 500)); + expect(dialogBox!.x).toBeGreaterThanOrEqual(8); + const description = page.getByText(/You can delete your profile immediately/); await expect(description).toBeVisible(); const deleteButton = page.getByRole('button', { name: 'Delete profile' }); @@ -29,11 +43,7 @@ test.describe('@layout edit profile dialog danger card', () => { expect(textBox).not.toBeNull(); expect(buttonBox).not.toBeNull(); - const intersects = - buttonBox!.x < textBox!.x + textBox!.width && - buttonBox!.x + buttonBox!.width > textBox!.x && - buttonBox!.y < textBox!.y + textBox!.height && - buttonBox!.y + buttonBox!.height > textBox!.y; - expect(intersects, 'delete button must not overlap the description text').toBe(false); + // Stacked layout: the button starts below the description at every size. + expect(buttonBox!.y).toBeGreaterThanOrEqual(textBox!.y + textBox!.height - 1); }); }); diff --git a/app/src/pages/header/EditProfileDialog.tsx b/app/src/pages/header/EditProfileDialog.tsx index 45dc2669..481b9d52 100644 --- a/app/src/pages/header/EditProfileDialog.tsx +++ b/app/src/pages/header/EditProfileDialog.tsx @@ -74,7 +74,7 @@ export default function EditProfileDialog({ return ( <> - + Edit profile @@ -84,7 +84,7 @@ export default function EditProfileDialog({
{/* Profile name section */} -
+