docker / image (push) Failing after 24s
lint / ruff (push) Failing after 28s
tests / backend (push) Successful in 37s
tests / desktop (push) Successful in 22s
tests / android-core (push) Successful in 49s
tests / web (push) Successful in 1m20s
android / apk (push) Successful in 8m49s
369 lines
15 KiB
Python
369 lines
15 KiB
Python
"""Blocking policy: when it is on, and what is on the list."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
from app.services import blocker_service, pomodoro, settings_service
|
|
|
|
|
|
def test_disabled_blocker_blocks_nothing(session, settings):
|
|
pomodoro.start(session, settings, kind="work")
|
|
policy = blocker_service.policy(session, settings)
|
|
assert policy["active"] is False and policy["domains"] == []
|
|
|
|
|
|
def test_during_work_mode_follows_the_timer(session, settings):
|
|
settings.blocker.enabled = True
|
|
settings.blocker.blocked_domains = ["youtube.com"]
|
|
settings_service.save_settings(session, settings)
|
|
|
|
assert blocker_service.policy(session, settings)["active"] is False
|
|
|
|
pomodoro.start(session, settings, kind="work")
|
|
policy = blocker_service.policy(session, settings)
|
|
assert policy["active"] is True
|
|
assert "www.youtube.com" in policy["domains"]
|
|
|
|
pomodoro.complete(session, settings, auto_next=False)
|
|
assert blocker_service.policy(session, settings)["active"] is False
|
|
|
|
|
|
def test_breaks_do_not_block_unless_asked(session, settings):
|
|
settings.blocker.enabled = True
|
|
settings_service.save_settings(session, settings)
|
|
pomodoro.start(session, settings, kind="short_break")
|
|
assert blocker_service.policy(session, settings)["active"] is False
|
|
|
|
settings.blocker.block_during_breaks = True
|
|
settings_service.save_settings(session, settings)
|
|
assert blocker_service.policy(session, settings)["active"] is True
|
|
|
|
|
|
def test_always_mode_ignores_the_timer(session, settings):
|
|
settings.blocker.enabled = True
|
|
settings.blocker.mode = "always"
|
|
settings_service.save_settings(session, settings)
|
|
assert blocker_service.policy(session, settings)["active"] is True
|
|
|
|
|
|
def test_allowlist_wins_over_the_blocklist(session, settings):
|
|
settings.blocker.blocked_domains = ["youtube.com"]
|
|
settings.blocker.allowed_domains = ["m.youtube.com"]
|
|
hosts = blocker_service.expand_domains(settings)
|
|
assert "youtube.com" in hosts
|
|
assert "m.youtube.com" not in hosts
|
|
|
|
|
|
def test_events_are_grouped_for_the_report(session, settings):
|
|
for _ in range(3):
|
|
blocker_service.record_event(session, settings, kind="domain", target="YouTube.com")
|
|
blocker_service.record_event(session, settings, kind="app", target="steam")
|
|
summary = blocker_service.events_for_day(session, settings)
|
|
assert summary["total"] == 4
|
|
assert summary["by_target"][0] == {"target": "youtube.com", "count": 3}
|
|
|
|
|
|
def test_masked_subdomains_are_expanded(session, settings):
|
|
"""A hosts file has no wildcards, so `*.x` has to become real names."""
|
|
settings.blocker.blocked_domains = ["*.google.com"]
|
|
settings.blocker.subdomain_expansions = ["www", "mail"]
|
|
hosts = blocker_service.expand_domains(settings)
|
|
assert hosts == ["google.com", "www.google.com", "mail.google.com"]
|
|
|
|
|
|
def test_a_plain_exception_covers_its_subdomains_like_a_plain_block_does(session, settings):
|
|
"""The two halves of one list have to read the same way.
|
|
|
|
`youtube.com` on the block list catches `music.youtube.com`. For a long time
|
|
`youtube.com` on the *allow* list caught only itself, because exceptions were
|
|
glob-matched — so the same text meant a subtree in one box and one hostname
|
|
in the other, with nothing saying so.
|
|
"""
|
|
settings.blocker.blocked_domains = ["*.youtube.com", "reddit.com"]
|
|
settings.blocker.subdomain_expansions = ["www", "m", "music"]
|
|
settings.blocker.allowed_domains = ["youtube.com"]
|
|
|
|
hosts = blocker_service.expand_domains(settings)
|
|
|
|
assert hosts == ["reddit.com", "www.reddit.com", "m.reddit.com"]
|
|
assert blocker_service.is_allowed("music.youtube.com", ["youtube.com"])
|
|
assert not blocker_service.is_allowed("notyoutube.com", ["youtube.com"])
|
|
|
|
|
|
def test_a_masked_exception_is_still_a_glob(session, settings):
|
|
"""Which is how you allow something narrower than a whole subtree."""
|
|
assert blocker_service.is_allowed("docs.google.com", ["docs.*"])
|
|
assert blocker_service.is_allowed("a.docs.google.com", ["*.docs.google.com"])
|
|
assert not blocker_service.is_allowed("sheets.google.com", ["docs.*"])
|
|
|
|
|
|
def test_allow_masks_carve_holes_in_block_masks(session, settings):
|
|
settings.blocker.blocked_domains = ["*.google.com", "reddit.com"]
|
|
settings.blocker.subdomain_expansions = ["www", "docs"]
|
|
settings.blocker.allowed_domains = ["docs.*", "m.reddit.com"]
|
|
hosts = blocker_service.expand_domains(settings)
|
|
assert "docs.google.com" not in hosts
|
|
assert "m.reddit.com" not in hosts
|
|
assert "www.google.com" in hosts and "reddit.com" in hosts
|
|
|
|
|
|
def test_patterns_a_hosts_file_cannot_express_are_reported(session, settings):
|
|
settings.blocker.blocked_domains = ["*gram*", "youtube.com", "*.x.com"]
|
|
assert blocker_service.unexpandable_masks(settings) == ["*gram*"]
|
|
hosts = blocker_service.expand_domains(settings)
|
|
assert "youtube.com" in hosts and "x.com" in hosts
|
|
|
|
|
|
def test_app_masks_are_passed_through_untouched(session, settings):
|
|
"""Process matching happens on the desktop, where globs work directly."""
|
|
settings.blocker.enabled = True
|
|
settings.blocker.mode = "always"
|
|
settings.blocker.blocked_apps = ["steam*", "*discord*"]
|
|
settings_service.save_settings(session, settings)
|
|
policy = blocker_service.policy(session, settings)
|
|
assert policy["apps"] == ["steam*", "*discord*"]
|
|
|
|
|
|
def test_a_wildcard_means_every_subdomain(session, settings):
|
|
"""`*` in the expansion list is the user asking for all of them.
|
|
|
|
It used to be refused (it is not a DNS label), which left `*.telegram.org`
|
|
expanded to just the bare domain with a red banner beside it. Now the labels
|
|
still expand, the pattern is handed to the clients that can match masks, and
|
|
only entries that are neither a label nor `*` are refused.
|
|
"""
|
|
settings.blocker.blocked_domains = ["*.telegram.org"]
|
|
settings.blocker.subdomain_expansions = ["*", "web", "", "not a label"]
|
|
|
|
expansion = blocker_service.parse_expansions(settings.blocker.subdomain_expansions)
|
|
assert expansion.labels == ["web"]
|
|
assert expansion.wildcard is True
|
|
assert expansion.rejected == ["not a label"]
|
|
|
|
hosts = blocker_service.expand_domains(settings)
|
|
assert hosts == ["telegram.org", "web.telegram.org"]
|
|
assert not any("*" in host for host in hosts)
|
|
|
|
# The pattern comes back too: a hosts file cannot say "every subdomain", so
|
|
# the desktop app's resolver stub and the phone's URL matcher take it.
|
|
assert blocker_service.unexpandable_masks(settings) == ["*.telegram.org"]
|
|
|
|
|
|
def test_without_a_wildcard_the_pattern_stays_in_the_expansion_list(session, settings):
|
|
"""`*.x.com` alone means the configured labels, not a wildcard — nothing else."""
|
|
settings.blocker.blocked_domains = ["*.google.com"]
|
|
settings.blocker.subdomain_expansions = ["www", "mail"]
|
|
|
|
assert blocker_service.unexpandable_masks(settings) == []
|
|
assert blocker_service.expand_domains(settings) == [
|
|
"google.com",
|
|
"www.google.com",
|
|
"mail.google.com",
|
|
]
|
|
|
|
|
|
def test_the_policy_reports_expansions_it_had_to_refuse(session, settings):
|
|
settings.blocker.enabled = True
|
|
settings.blocker.mode = "always"
|
|
settings.blocker.blocked_domains = ["*.telegram.org"]
|
|
settings.blocker.subdomain_expansions = ["*", ".."]
|
|
|
|
result = blocker_service.policy(session, settings)
|
|
assert result["invalid_expansions"] == [".."]
|
|
assert result["domains"] == ["telegram.org"]
|
|
assert result["unexpandable"] == ["*.telegram.org"]
|
|
|
|
|
|
def test_the_policy_carries_the_allow_masks_for_the_stub(session, settings):
|
|
"""The desktop app answers DNS for a whole zone, so it needs the exceptions.
|
|
|
|
Without them the resolver stub would sinkhole `music.youtube.com` along with
|
|
everything else under a wildcarded domain — an exception that works over the
|
|
hosts file but not over DNS is an exception nobody will trust.
|
|
"""
|
|
settings.blocker.enabled = True
|
|
settings.blocker.mode = "always"
|
|
settings.blocker.blocked_domains = ["*.youtube.com"]
|
|
settings.blocker.allowed_domains = [" music.youtube.com ", "*.docs.google.com"]
|
|
|
|
result = blocker_service.policy(session, settings)
|
|
assert result["allowed"] == ["music.youtube.com", "*.docs.google.com"]
|
|
assert "music.youtube.com" not in result["domains"]
|
|
assert "m.youtube.com" in result["all_domains"]
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# The lock: the guard will not be switched off mid-interval
|
|
#
|
|
# Only `apply_patch` and `reset_to_env` carry the check. `save_settings` stays an
|
|
# unguarded low-level writer on purpose — the env seed, the password CLI and the
|
|
# tests above all go through it — which is why these exercise the real write path.
|
|
# --------------------------------------------------------------------------
|
|
def _guarded(session, settings):
|
|
settings.blocker.enabled = True
|
|
settings_service.save_settings(session, settings)
|
|
|
|
|
|
def test_the_guard_cannot_be_switched_off_while_an_interval_runs(session, settings):
|
|
_guarded(session, settings)
|
|
pomodoro.start(session, settings, kind="work")
|
|
|
|
with pytest.raises(blocker_service.GuardLocked):
|
|
settings_service.apply_patch(session, {"blocker": {"enabled": False}})
|
|
|
|
# And nothing was written on the way out.
|
|
assert settings_service.load_settings(session).blocker.enabled is True
|
|
|
|
|
|
def test_pausing_is_not_an_escape_hatch(session, settings):
|
|
_guarded(session, settings)
|
|
pomodoro.start(session, settings, kind="work")
|
|
pomodoro.pause(session, settings)
|
|
|
|
with pytest.raises(blocker_service.GuardLocked):
|
|
settings_service.apply_patch(session, {"blocker": {"enabled": False}})
|
|
|
|
|
|
@pytest.mark.parametrize("kind", ["short_break", "long_break"])
|
|
@pytest.mark.parametrize("pause", [False, True])
|
|
def test_a_break_is_not_an_escape_hatch_either(session, settings, kind, pause):
|
|
"""The lock is kind-blind: `current_session` is the whole rule."""
|
|
_guarded(session, settings)
|
|
pomodoro.start(session, settings, kind=kind)
|
|
if pause:
|
|
pomodoro.pause(session, settings)
|
|
|
|
with pytest.raises(blocker_service.GuardLocked):
|
|
settings_service.apply_patch(session, {"blocker": {"enabled": False}})
|
|
|
|
|
|
def test_the_guard_can_always_be_switched_on_mid_interval(session, settings):
|
|
pomodoro.start(session, settings, kind="work")
|
|
updated = settings_service.apply_patch(session, {"blocker": {"enabled": True}})
|
|
assert updated.blocker.enabled is True
|
|
|
|
|
|
def test_the_guard_switches_off_freely_when_nothing_is_running(session, settings):
|
|
_guarded(session, settings)
|
|
updated = settings_service.apply_patch(session, {"blocker": {"enabled": False}})
|
|
assert updated.blocker.enabled is False
|
|
|
|
|
|
def test_the_guard_switches_off_once_the_interval_is_over(session, settings):
|
|
_guarded(session, settings)
|
|
pomodoro.start(session, settings, kind="work")
|
|
pomodoro.complete(session, settings, auto_next=False)
|
|
|
|
updated = settings_service.apply_patch(session, {"blocker": {"enabled": False}})
|
|
assert updated.blocker.enabled is False
|
|
|
|
|
|
def test_an_unrelated_blocker_patch_still_saves_mid_interval(session, settings):
|
|
"""What the web app's list editor sends — the regression the check is shaped for."""
|
|
_guarded(session, settings)
|
|
pomodoro.start(session, settings, kind="work")
|
|
|
|
updated = settings_service.apply_patch(
|
|
session,
|
|
{"blocker": {"blocked_domains": ["youtube.com"], "blocked_apps": ["steam"]}},
|
|
)
|
|
assert updated.blocker.blocked_domains == ["youtube.com"]
|
|
assert updated.blocker.enabled is True
|
|
|
|
|
|
def test_re_sending_an_already_off_guard_is_not_a_refusal(session, settings):
|
|
pomodoro.start(session, settings, kind="work")
|
|
updated = settings_service.apply_patch(session, {"blocker": {"enabled": False}})
|
|
assert updated.blocker.enabled is False
|
|
|
|
|
|
def test_a_full_document_echo_survives_a_live_interval(session, settings):
|
|
_guarded(session, settings)
|
|
pomodoro.start(session, settings, kind="work")
|
|
|
|
echo = settings_service.load_settings(session).model_dump(mode="json")
|
|
updated = settings_service.apply_patch(session, echo)
|
|
assert updated.blocker.enabled is True
|
|
|
|
|
|
def test_a_loosely_typed_off_is_still_an_off(session, settings):
|
|
"""`{"enabled": "no"}` coerces to False, which is why the check compares
|
|
validated documents rather than reading the patch's keys."""
|
|
_guarded(session, settings)
|
|
pomodoro.start(session, settings, kind="work")
|
|
|
|
with pytest.raises(blocker_service.GuardLocked):
|
|
settings_service.apply_patch(session, {"blocker": {"enabled": "no"}})
|
|
|
|
|
|
def test_the_policy_reports_the_lock(session, settings):
|
|
_guarded(session, settings)
|
|
assert blocker_service.policy(session, settings)["locked"] is False
|
|
assert blocker_service.policy(session, settings)["lock_reason"] == ""
|
|
|
|
pomodoro.start(session, settings, kind="work")
|
|
live = blocker_service.policy(session, settings)
|
|
assert live["locked"] is True
|
|
assert live["lock_reason"] == blocker_service.GUARD_LOCK_NOTE
|
|
|
|
pomodoro.pause(session, settings)
|
|
assert blocker_service.policy(session, settings)["locked"] is True
|
|
|
|
|
|
def test_a_guard_that_is_off_is_never_reported_locked(session, settings):
|
|
"""`locked` has "and the guard is on" folded in, so no client re-derives it."""
|
|
pomodoro.start(session, settings, kind="work")
|
|
assert blocker_service.policy(session, settings)["locked"] is False
|
|
|
|
|
|
def test_the_policy_and_the_predicate_agree(session, settings):
|
|
"""`policy()` re-expresses `guard_locked()` to reuse the row it already has;
|
|
these two must not drift."""
|
|
for enabled in (False, True):
|
|
settings.blocker.enabled = enabled
|
|
settings_service.save_settings(session, settings)
|
|
for kind in (None, "work", "short_break"):
|
|
if kind is not None:
|
|
pomodoro.start(session, settings, kind=kind)
|
|
assert blocker_service.policy(session, settings)["locked"] is (
|
|
blocker_service.guard_locked(session, settings)
|
|
)
|
|
if kind is not None:
|
|
pomodoro.abandon(session, settings, reason="test")
|
|
|
|
|
|
def test_scheduled_mode_outside_its_window_is_inactive_and_still_locked(session, settings):
|
|
"""The distinction `active` cannot express: nothing is blocked right now, and
|
|
the guard still must not be switched off before the window opens."""
|
|
settings.blocker.enabled = True
|
|
settings.blocker.mode = "scheduled"
|
|
settings.blocker.schedule = []
|
|
settings_service.save_settings(session, settings)
|
|
pomodoro.start(session, settings, kind="work")
|
|
|
|
result = blocker_service.policy(session, settings)
|
|
assert result["active"] is False
|
|
assert result["locked"] is True
|
|
|
|
with pytest.raises(blocker_service.GuardLocked):
|
|
settings_service.apply_patch(session, {"blocker": {"enabled": False}})
|
|
|
|
|
|
def test_a_reset_cannot_switch_the_guard_off_mid_interval(session, settings):
|
|
"""`settings_from_env` never touches the blocker subtree, so a reset always
|
|
lands on `enabled = False` — a second, quieter off-switch."""
|
|
_guarded(session, settings)
|
|
pomodoro.start(session, settings, kind="work")
|
|
|
|
with pytest.raises(blocker_service.GuardLocked):
|
|
settings_service.reset_to_env(session)
|
|
assert settings_service.load_settings(session).blocker.enabled is True
|
|
|
|
pomodoro.complete(session, settings, auto_next=False)
|
|
assert settings_service.reset_to_env(session).blocker.enabled is False
|
|
|
|
|
|
def test_a_reset_is_fine_while_an_interval_runs_if_the_guard_is_off(session, settings):
|
|
pomodoro.start(session, settings, kind="work")
|
|
assert settings_service.reset_to_env(session).blocker.enabled is False
|