testsAndMisc/python_pkg/focus_policy/tests/test_loader.py
Krzysztof kuhy Rudnicki f636d2b21b Make focus policy and blocklist usable without root; fix workout CDN unblock
phone_focus_mode is built for a rooted phone: the hosts layer is a read-only
bind mount over /system/etc/hosts, chosen so that even root-in-a-terminal
cannot overwrite it. None of that ports to a stock locked device. This splits
out the parts that are device-independent so one policy can drive both.

python_pkg/focus_policy parses config.sh rather than copying it, so the shell
config stays the source of truth and the two cannot drift. It rejects the
shipped REDACTED_* coordinate placeholder instead of defaulting to (0,0),
which would move "home" to the Atlantic and quietly disable the location gate.

blocklist.py converts the hosts file to domains for a DoT resolver or a local
VpnService. Three properties of the real 194k-line file make the obvious parser
wrong, and all three are handled: generate_hosts_file.sh *comments out*
deliberate unblocks rather than deleting them, so stripping '#' re-blocks
facebook/messenger/linkedin/4chan; the StevenBlack header carries loopback and
IPv6 housekeeping that would blackhole localhost; and ~1060 upstream annotation
tokens ('#[adware.zeno]', '"spam') render as address=/"spam/, which makes
dnsmasq refuse to start -- taking DNS down rather than blocking anything.
Verified with dnsmasq --test over all 185681 generated domains.

strip_workout_hosts.py had the same class of bug on the live rooted phone:
exact set membership meant the allowlisted googlevideo.com never matched the
dynamic r*---sn-*.googlevideo.com hosts YouTube actually streams from, so the
workout unblock was only partly working. Now suffix-matched (17 lines stripped,
was 14). The three new tests were checked against the original code in a
throwaway worktree; two of them fail there, so they pin the fix rather than
passing vacuously.

focus_owner is a device-owner app that deliberately does nothing yet. It ships
clearDeviceOwnerApp() implemented and tested first, because dpm
remove-active-admin does not work on a device owner: an app that locks the
device down before proving it can let go leaves a soft-brick recoverable only
through fastboot on a locked bootloader. Built, installed and launched on the
Pixel 6a; dumpsys afterwards confirms Device Owner Type -1 and no user
restrictions, i.e. the phone is unchanged.

Measured while scoping this, and not yet reflected in any enforcement code:
on Android 16 both pm suspend and pm disable-user are cleared by a reboot, so
shell-level app blocking is per-session only. setApplicationHidden is assumed
to persist under Device Owner but is untested.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AwepyL5Lxs5QKbKkMpxxxL
2026-08-05 09:15:02 +02:00

244 lines
9.6 KiB
Python

"""Tests for parsing policy out of the existing shell config."""
from __future__ import annotations
from datetime import time
from typing import TYPE_CHECKING
import pytest
from python_pkg.focus_policy.export import policy_to_dict, policy_to_json
from python_pkg.focus_policy.loader import (
load_policy,
parse_hhmm,
parse_package_list,
parse_shell_assignments,
)
from python_pkg.focus_policy.model import PolicyError
if TYPE_CHECKING:
from pathlib import Path
MINIMAL_CONFIG = """
export RADIUS=150
export HYSTERESIS=30
export NIGHT_CURFEW_ENABLED=1
export NIGHT_CURFEW_START="2300"
export NIGHT_CURFEW_END="0500"
export LAUNCHER_PACKAGE="com.launcher"
export WHITELIST="
com.launcher
com.good
pl.mbank
"
export NIGHT_WHITELIST="
pl.mbank
"
export SYSTEM_NEVER_DISABLE="
com.android.settings
"
export WORKOUT_UNBLOCK_DOMAINS="
youtube.com
"
"""
SECRETS = "export HOME_LAT=52.2297\nexport HOME_LON=21.0122\n"
@pytest.fixture
def config_dir(tmp_path: Path) -> Path:
"""Write a minimal config plus secrets into a temporary directory."""
(tmp_path / "config.sh").write_text(MINIMAL_CONFIG, encoding="utf-8")
(tmp_path / "config_secrets.sh").write_text(SECRETS, encoding="utf-8")
return tmp_path
class TestParseShellAssignments:
"""The narrow shell-assignment subset the loader understands."""
@pytest.mark.parametrize(
("text", "expected"),
[
('export A="x"', {"A": "x"}),
("export A='x'", {"A": "x"}),
("A=x", {"A": "x"}),
("readonly A=x", {"A": "x"}),
('export A="one\ntwo"', {"A": "one\ntwo"}),
],
)
def test_assignment_forms(self, text: str, expected: dict[str, str]) -> None:
"""Quoted, bare, exported, readonly, and multi-line forms all parse."""
assert parse_shell_assignments(text) == expected
def test_later_assignment_wins(self) -> None:
"""Re-assignment overwrites, matching shell semantics."""
assert parse_shell_assignments("A=1\nA=2")["A"] == "2"
def test_ignores_prose(self) -> None:
"""Comment prose without an assignment yields nothing."""
assert parse_shell_assignments("# just a comment\n") == {}
class TestParsePackageList:
"""Whitespace-separated list blocks."""
def test_drops_blanks_and_comments(self) -> None:
"""Only real entries survive."""
assert parse_package_list("\ncom.a\n# note\n\n com.b \n") == frozenset(
{"com.a", "com.b"},
)
def test_empty_string_is_empty_set(self) -> None:
"""An unset list is empty, not a set containing an empty string."""
assert parse_package_list("") == frozenset()
class TestParseHhmm:
"""HHMM time parsing."""
def test_parses_valid_time(self) -> None:
"""A well-formed HHMM string becomes a time."""
assert parse_hhmm("2300", field_name="X") == time(23, 0)
@pytest.mark.parametrize("value", ["230", "23000", "abcd", "2560", "2400"])
def test_rejects_malformed_time(self, value: str) -> None:
"""Wrong length, non-digits, and impossible clock values all raise."""
with pytest.raises(PolicyError, match="X"):
parse_hhmm(value, field_name="X")
class TestLoadPolicy:
"""End-to-end policy loading."""
def test_loads_expected_policy(self, config_dir: Path) -> None:
"""Every field is populated from the two files."""
policy = load_policy(config_dir / "config.sh")
assert policy.home.latitude == pytest.approx(52.2297)
assert policy.home.radius_m == pytest.approx(150.0)
assert policy.allowed_packages == frozenset(
{"com.launcher", "com.good", "pl.mbank"},
)
assert policy.night_allowed_packages == frozenset({"pl.mbank"})
assert policy.never_disable_prefixes == ("com.android.settings",)
assert policy.workout_unblock_domains == frozenset({"youtube.com"})
assert policy.curfew is not None
assert policy.curfew.start == time(23, 0)
assert policy.launcher_package == "com.launcher"
def test_explicit_secrets_path_is_honoured(
self,
config_dir: Path,
tmp_path: Path,
) -> None:
"""Secrets may live outside the config directory."""
elsewhere = tmp_path / "other_secrets.sh"
elsewhere.write_text("export HOME_LAT=1.0\nexport HOME_LON=2.0\n", "utf-8")
policy = load_policy(config_dir / "config.sh", elsewhere)
assert policy.home.latitude == pytest.approx(1.0)
def test_rejects_placeholder_coordinates(self, config_dir: Path) -> None:
"""The shipped placeholder must fail loudly, never default to (0, 0).
Silently treating an unset coordinate as the origin would place "home"
in the Atlantic and disable the location gate entirely.
"""
(config_dir / "config_secrets.sh").write_text(
"export HOME_LAT=REDACTED_LAT\nexport HOME_LON=REDACTED_LON\n",
encoding="utf-8",
)
with pytest.raises(PolicyError, match="placeholder"):
load_policy(config_dir / "config.sh")
def test_missing_secrets_file_reports_missing_setting(
self,
config_dir: Path,
) -> None:
"""With no secrets at all the error names the absent key."""
(config_dir / "config_secrets.sh").unlink()
with pytest.raises(PolicyError, match="HOME_LAT"):
load_policy(config_dir / "config.sh")
def test_missing_whitelist_is_fatal(self, tmp_path: Path) -> None:
"""A config without an allowlist cannot produce a usable policy."""
(tmp_path / "config.sh").write_text("export RADIUS=150\n", encoding="utf-8")
(tmp_path / "config_secrets.sh").write_text(SECRETS, encoding="utf-8")
with pytest.raises(PolicyError, match="WHITELIST"):
load_policy(tmp_path / "config.sh")
def test_curfew_disabled_yields_no_window(self, config_dir: Path) -> None:
"""NIGHT_CURFEW_ENABLED=0 disables the window entirely."""
text = MINIMAL_CONFIG.replace(
"NIGHT_CURFEW_ENABLED=1",
"NIGHT_CURFEW_ENABLED=0",
)
(config_dir / "config.sh").write_text(text, encoding="utf-8")
assert load_policy(config_dir / "config.sh").curfew is None
def test_defaults_apply_when_numbers_absent(self, config_dir: Path) -> None:
"""Radius and hysteresis fall back to the documented defaults."""
text = MINIMAL_CONFIG.replace("export RADIUS=150\n", "").replace(
"export HYSTERESIS=30\n",
"",
)
(config_dir / "config.sh").write_text(text, encoding="utf-8")
policy = load_policy(config_dir / "config.sh")
assert policy.home.radius_m == pytest.approx(150.0)
assert policy.home.hysteresis_m == pytest.approx(30.0)
def test_blank_number_falls_back_to_default(self, config_dir: Path) -> None:
"""An empty value is treated as unset rather than as zero."""
text = MINIMAL_CONFIG.replace("export RADIUS=150", 'export RADIUS=""')
(config_dir / "config.sh").write_text(text, encoding="utf-8")
assert load_policy(config_dir / "config.sh").home.radius_m == pytest.approx(
150.0,
)
def test_non_numeric_radius_is_fatal(self, config_dir: Path) -> None:
"""A malformed number is an error, not a silent fallback."""
text = MINIMAL_CONFIG.replace("export RADIUS=150", "export RADIUS=wide")
(config_dir / "config.sh").write_text(text, encoding="utf-8")
with pytest.raises(PolicyError, match="RADIUS"):
load_policy(config_dir / "config.sh")
def test_blank_launcher_becomes_none(self, config_dir: Path) -> None:
"""An empty launcher setting disables the launcher invariant."""
text = MINIMAL_CONFIG.replace(
'export LAUNCHER_PACKAGE="com.launcher"',
'export LAUNCHER_PACKAGE=""',
)
(config_dir / "config.sh").write_text(text, encoding="utf-8")
assert load_policy(config_dir / "config.sh").launcher_package is None
class TestExport:
"""Rendering the policy for the Kotlin backend."""
def test_dict_round_trips_key_fields(self, config_dir: Path) -> None:
"""The exported document carries the policy the loader produced."""
payload = policy_to_dict(load_policy(config_dir / "config.sh"))
assert payload["schema_version"] == 1
assert payload["curfew"] == {"start": "23:00", "end": "05:00"}
assert payload["allowed_packages"] == ["com.good", "com.launcher", "pl.mbank"]
assert payload["home"]["latitude"] == pytest.approx(52.2297)
def test_absent_curfew_serialises_as_null(self, config_dir: Path) -> None:
"""No curfew renders as JSON null rather than being omitted."""
text = MINIMAL_CONFIG.replace(
"NIGHT_CURFEW_ENABLED=1",
"NIGHT_CURFEW_ENABLED=0",
)
(config_dir / "config.sh").write_text(text, encoding="utf-8")
assert policy_to_dict(load_policy(config_dir / "config.sh"))["curfew"] is None
def test_redaction_hides_coordinates_only(self, config_dir: Path) -> None:
"""Redacted output keeps the radius but drops where the user lives."""
policy = load_policy(config_dir / "config.sh")
assert "52.2297" not in policy_to_json(policy, redact_home=True)
assert "52.2297" in policy_to_json(policy)
assert '"radius_m": 150.0' in policy_to_json(policy, redact_home=True)
def test_json_is_stable(self, config_dir: Path) -> None:
"""Repeated renders are byte-identical, so diffs stay meaningful."""
policy = load_policy(config_dir / "config.sh")
assert policy_to_json(policy) == policy_to_json(policy)