testsAndMisc/python_pkg/focus_policy/tests/test_export.py
Krzysztof kuhy Rudnicki eeaf9590da
Some checks are pending
Pre-commit checks / pre-commit (push) Waiting to run
Python tests / test (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run
Let the sweep hide preinstalled apps, one named package at a time
The Device Owner sweep skipped every FLAG_SYSTEM package, which meant it
could not touch the apps it exists to block: YouTube ships at
/product/app/YouTube as a system app, and so does Chrome.

Deleting the filter is not the fix. Measured on the Pixel 6a, that would
expose 320 system packages of which 243 match no allowlist entry and no
never_disable_prefix -- including com.android.cellbroadcastreceiver
(emergency alerts), com.android.credentialmanager and
com.android.devicelockcontroller. So system apps stay default-deny and
blockable_system_packages is the opt-in.

com.android.vending is on the list deliberately: leaving Play reachable
makes every other removal a one-tap undo.

The list lives in the Python exporter rather than config.sh because it is
a safety boundary, and a shell typo should not be able to widen it. The
exporter subtracts anything already allowed, so an asset can never state
that a package is both allowed and blockable. Kotlin reads the field with
a new optionalStringSet: an asset predating the field parses to the empty
set instead of throwing, because a throw makes decide() skip the pass and
silently disable enforcement.

Verified on-device under a profile owner: hid 1 for YouTube, restored 1
when temporarily allowed, and cellbroadcastreceiver, credentialmanager,
devicelockcontroller and settings all left hidden=false.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014DqEd1uUhyT52KjaNeLanC
2026-08-10 20:24:31 +02:00

142 lines
5.0 KiB
Python

"""Tests for the JSON policy exporter.
The guarantee under test is the one that cannot be recovered from on the device
itself: the enforcing app must never be rendered into a policy that would hide
it. Everything else here is shape, not safety.
"""
from __future__ import annotations
import datetime as dt
from python_pkg.focus_policy.export import (
ENFORCER_PACKAGE,
policy_to_dict,
policy_to_json,
)
from python_pkg.focus_policy.model import CurfewWindow, FocusPolicy, HomeLocation
_DEFAULT_CURFEW = CurfewWindow(start=dt.time(23, 0), end=dt.time(5, 0))
def _policy(
*,
# The launcher must be allowed or the model rejects the policy outright.
allowed_packages: frozenset[str] = frozenset({"pl.mbank", "com.launcher"}),
night_allowed_packages: frozenset[str] = frozenset({"pl.mbank", "com.launcher"}),
curfew: CurfewWindow | None = _DEFAULT_CURFEW,
) -> FocusPolicy:
"""Build a policy with the enforcer deliberately absent from both lists."""
return FocusPolicy(
home=HomeLocation(
latitude=52.2297,
longitude=21.0122,
radius_m=150.0,
hysteresis_m=30.0,
),
curfew=curfew,
launcher_package="com.launcher",
allowed_packages=allowed_packages,
night_allowed_packages=night_allowed_packages,
never_disable_prefixes=("com.android.",),
workout_unblock_domains=frozenset({"youtube.com"}),
browser_packages=frozenset({"org.mozilla.firefox"}),
)
def test_enforcer_is_injected_into_both_allowlists() -> None:
"""The app must be allowed even when the source config omits it.
Regression guard for the on-device finding of 2026-08-09: the enforcer was
in neither allowlist, so the decision layer placed it in the hide set on
every enforcing pass, leaving one in-app check as the sole thing preventing
the app from hiding itself.
"""
payload = policy_to_dict(_policy())
assert ENFORCER_PACKAGE in payload["allowed_packages"]
assert ENFORCER_PACKAGE in payload["night_allowed_packages"]
def test_enforcer_is_not_duplicated_when_already_present() -> None:
"""Injection is a set union, so an explicit entry stays single."""
payload = policy_to_dict(
_policy(
allowed_packages=frozenset({"pl.mbank", "com.launcher", ENFORCER_PACKAGE}),
night_allowed_packages=frozenset({"com.launcher", ENFORCER_PACKAGE}),
),
)
assert payload["allowed_packages"].count(ENFORCER_PACKAGE) == 1
assert payload["night_allowed_packages"].count(ENFORCER_PACKAGE) == 1
def test_blockable_system_packages_names_the_distraction_apps() -> None:
"""The sweep is default-deny for system apps; this is the opt-in.
YouTube and Chrome ship with FLAG_SYSTEM, so without this list the Device
Owner sweep skips exactly the apps it exists to block. Play is included
because leaving it reachable makes every other removal a one-tap undo.
"""
blockable = policy_to_dict(_policy())["blockable_system_packages"]
assert "com.google.android.youtube" in blockable
assert "com.android.chrome" in blockable
assert "com.android.vending" in blockable
def test_blockable_system_packages_never_contradicts_the_allowlist() -> None:
"""An allowed package must not also be listed as blockable.
Otherwise the asset would state both at once and the outcome would depend
on which check the runner happened to apply first.
"""
payload = policy_to_dict(
_policy(
allowed_packages=frozenset(
{"pl.mbank", "com.launcher", "com.android.chrome"},
),
),
)
assert "com.android.chrome" not in payload["blockable_system_packages"]
assert "com.android.chrome" in payload["allowed_packages"]
def test_lists_are_sorted_for_stable_diffs() -> None:
"""Sorted output keeps a committed asset's diff meaningful."""
payload = policy_to_dict(_policy())
for key in (
"allowed_packages",
"night_allowed_packages",
"never_disable_prefixes",
"workout_unblock_domains",
"browser_packages",
):
assert payload[key] == sorted(payload[key])
def test_curfew_is_rendered_as_wall_clock_strings() -> None:
"""Kotlin parses `HH:MM`, not an ISO time."""
assert policy_to_dict(_policy())["curfew"] == {"start": "23:00", "end": "05:00"}
def test_absent_curfew_renders_as_null() -> None:
"""A policy with no curfew must not fabricate one."""
assert policy_to_dict(_policy(curfew=None))["curfew"] is None
def test_redact_home_blanks_coordinates_but_keeps_geometry() -> None:
"""A committed asset discloses the radius, never the location."""
payload = policy_to_json(_policy(), redact_home=True)
assert '"latitude": null' in payload
assert '"longitude": null' in payload
assert '"radius_m": 150.0' in payload
def test_coordinates_survive_when_not_redacted() -> None:
"""The provisioning path still needs the real values."""
assert '"latitude": 52.2297' in policy_to_json(_policy())