Files
kadence/desktop/tests/test_blocker_manager.py
timka123 36af1e9e83 desktop: route wildcard zones to a local DNS stub
A hosts file cannot express a wildcard, so '*.youtube.com' was only ever the
base domain plus the guessed labels. The patterns the server now hands over
as unexpandable are answered by a stub on 127.0.0.1, and the helper owns one
systemd-resolved drop-in that points just those zones at it:

  * the stub is listening before the drop-in is written, and the drop-in is
    removed before the stub stops, so a zone's queries never land on a dead
    port;
  * stub and route live as long as the app, not the interval: between
    intervals they forward everything, instead of restarting resolved (and
    dropping its cache) on every pomodoro;
  * a crashed run leaves a world-readable drop-in the next start adopts, and
    the stub comes back before anything else;
  * masks with nothing routable after their last '*' ('*gram*') are reported
    as unenforceable, not silently dropped.

The Blocker page now reports what is really in place — the hosts region, the
routed zones, and the masks this machine cannot do — rather than the whole
expansion. 39 desktop tests, including the ordering invariants.
2026-10-19 18:45:42 +03:00

189 lines
6.8 KiB
Python

"""The blocker's lifecycle: which mechanism comes up when, and in which order.
`BlockerManager` is the one place that knows both mechanisms exist, and the
invariants are all about *ordering*: the DNS stub has to be listening before the
drop-in points zones at it, and the drop-in has to be gone before the stub stops.
Both rules are cheap to state and easy to break in a refactor, so they are
pinned here with stand-ins that do nothing but write down what was asked of them.
"""
from __future__ import annotations
from kadence_desktop.blocking.manager import BlockerManager
class RecordingStub:
"""Stands in for the DNS stub: a flag, a port, and a list of events."""
def __init__(self, events: list, *, start_ok: bool = True) -> None:
self.events = events
self.start_ok = start_ok
self.running = False
self.port = 5353
self.error = None if start_ok else "the local DNS stub could not bind 127.0.0.1"
self.rules: tuple[list[str], list[str], str, bool] | None = None
def start(self) -> bool:
self.events.append("stub.start")
if not self.start_ok:
return False
self.running = True
return True
def stop(self) -> None:
self.events.append("stub.stop")
self.running = False
def set_rules(self, patterns: list[str], allow: list[str], redirect_ip: str, *, active: bool) -> None:
self.events.append(("rules", tuple(patterns), active))
self.rules = (list(patterns), list(allow), redirect_ip, active)
class RecordingRoute:
"""Stands in for the resolved drop-in."""
def __init__(self, events: list, *, ok: bool = True, applied=None) -> None:
self.events = events
self.ok = ok
self.applied = applied
self.last_error = None if ok else "the authorisation prompt was dismissed"
def on(self, zones: list[str], port: int) -> bool:
self.events.append(("route.on", tuple(zones), port))
if not self.ok:
return False
self.applied = (tuple(zones), port)
return True
def off(self) -> bool:
self.events.append("route.off")
if not self.ok:
return False
self.applied = None
return True
def policy(*, active: bool = True, domains=("youtube.com",), unexpandable=(), allowed=()) -> dict:
return {
"active": active,
"enabled": True,
"domains": list(domains),
"apps": [],
"kill_apps": False,
"redirect_ip": "127.0.0.1",
"unexpandable": list(unexpandable),
"allowed": list(allowed),
}
def manager(events: list, *, stub: RecordingStub | None = None, route: RecordingRoute | None = None) -> BlockerManager:
# A helper path that leads nowhere: the hosts half then refuses every write
# without touching anything, which is exactly what these tests do not need.
return BlockerManager(
helper_path="/nonexistent/kadence-helper",
report=lambda *args: None,
stub=stub if stub is not None else RecordingStub(events),
route=route if route is not None else RecordingRoute(events),
)
def test_the_stub_comes_up_before_the_route_and_the_route_before_the_rules():
events: list = []
blocker = manager(events)
blocker.sync(policy(unexpandable=["*.youtube.com"]))
assert events.index("stub.start") < events.index(("route.on", ("youtube.com",), 5353))
assert events.index(("route.on", ("youtube.com",), 5353)) < events.index(
("rules", ("*.youtube.com",), True)
)
assert blocker.routed_zones == ["youtube.com"]
assert blocker.active
def test_the_route_stays_up_between_intervals():
# A restart of systemd-resolved drops the machine's DNS cache, so the route
# belongs to the app's lifetime, not to one interval: between intervals the
# stub keeps the port and simply forwards everything.
events: list = []
blocker = manager(events)
# The server keeps reporting `unexpandable` while nothing is blocking — it is
# a property of the rules, not of the interval — so the route survives.
blocker.sync(policy(unexpandable=["*.youtube.com"]))
events.clear()
blocker.sync(policy(active=False, unexpandable=["*.youtube.com"]))
assert "route.off" not in events
assert "stub.stop" not in events
assert ("rules", (), False) in events
assert not blocker.active
def test_shutdown_takes_the_dropin_down_before_the_stub():
events: list = []
blocker = manager(events)
blocker.sync(policy(unexpandable=["*.youtube.com"]))
events.clear()
blocker.shutdown()
assert events.index("route.off") < events.index("stub.stop")
assert ("rules", (), False) in events
assert not blocker.active
def test_a_mask_without_a_zone_never_touches_the_route():
# `*gram*` has nothing after its last `*`: no zone to route, so the route is
# not opened just to say nothing.
events: list = []
blocker = manager(events)
blocker.sync(policy(domains=("instagram.com",), unexpandable=["*gram*"]))
assert blocker.unroutable == ["*gram*"]
assert not [event for event in events if isinstance(event, tuple) and event[0] == "route.on"]
assert blocker.routed_zones == []
def test_switching_hosts_file_enforcement_off_takes_the_route_with_it():
events: list = []
blocker = manager(events)
blocker.sync(policy(unexpandable=["*.youtube.com"]))
events.clear()
blocker.enforce_hosts = False
blocker.sync(policy(unexpandable=["*.youtube.com"]))
assert events.index("route.off") < events.index("stub.stop")
assert blocker.routed_zones == []
def test_a_stub_that_cannot_bind_leaves_no_route_behind():
# The invariant, upside down: if nothing is listening, the drop-in must not
# be written — that is the state where the zones' DNS simply dies.
events: list = []
blocker = manager(events, stub=RecordingStub(events, start_ok=False))
blocker.sync(policy(unexpandable=["*.youtube.com"]))
assert not [event for event in events if isinstance(event, tuple) and event[0] == "route.on"]
assert "route.off" not in events
assert blocker.route_error and "could not bind" in blocker.route_error
def test_a_dropin_left_by_a_crash_gets_its_stub_back_at_startup():
events: list = []
route = RecordingRoute(events, applied=(("youtube.com",), 5353))
manager(events, route=route) # construction alone must do it
assert events == ["stub.start"]
def test_a_refused_authorisation_is_reported_alongside_the_hosts_error():
events: list = []
blocker = manager(events, route=RecordingRoute(events, ok=False))
blocker.sync(policy(unexpandable=["*.youtube.com"]))
assert blocker.route_error == "the authorisation prompt was dismissed"
# The hosts half failed too (there is no helper), and it speaks first.
assert blocker.error == blocker.last_error
assert blocker.error is not None