Files
internet_use_control/windows/tests/test_blocker.py
Sam Rolfe 1275b1baaa Phase 4: kids' Windows computer client (AUD-97)
Adds windows/, the Python client for the kids' Windows computers, and proves
both halves of the Phase 4 definition of done on the real box
(DESKTOP-TE2RHV2).

The client:
- subscribes, retained, to its own config on the 8883 TLS device plane with its
  own broker account (FR-3, FR-6);
- keeps the last valid config on disk and loads it before connecting, so a
  broker outage changes nothing (FR-14);
- terminates every process on the blocklist while the device is effectively
  locked, with the allowlist winning and the client's own tree plus critical
  system processes protected (FR-4, FR-16);
- publishes presence and daily per-app usage; the day's totals are held on disk
  and republished when the link returns (FR-7);
- shows an always-on-top warning window naming what is blocked, why and until
  when (FR-9).

install-scheduled-task.ps1 registers the logon Scheduled Task for the kid's
account with an interactive token, and grants that account Modify on the state
directory: a file created from an elevated shell inherits Administrators only,
and the limited task is otherwise refused with Access denied.

Evidence (windows/EVIDENCE.md, from the machine's own log): notepad.exe killed
inside a scheduled window (pid 16600, then 9724), the device online in the
Control Service with locked/until/reason, a telemetry row for the device
(app=690s), enforcement continuing with the broker unreachable
(192.168.20.13:8884), the warning window on the interactive desktop, and the
Scheduled Task registered at logon as New Comp.

Domains are deliberately not reported: Windows has no cheap per-process DNS
attribution, so the client sends "domains": [] and the gap is documented rather
than guessed.

54 unit tests, run with `make windows-test` (no broker, no Windows, no psutil).

AGENTS.md §11 is now 10 of 10.
2026-10-14 15:00:22 +11:00

80 lines
2.7 KiB
Python

"""Block selection: what the enforcer may and may not kill."""
from __future__ import annotations
import unittest
from datetime import datetime, timedelta, timezone
from webrestrict.blocker import select_victims, self_protection
from webrestrict.config import DeviceConfig
NOW = datetime(2026, 10, 14, 20, 0, tzinfo=timezone.utc)
def config(**overrides) -> DeviceConfig:
base = dict(
locked=True,
until=(NOW + timedelta(minutes=30)).isoformat(),
block=["notepad.exe", "steam.exe"],
allow=["Spotify.exe"],
)
base.update(overrides)
return DeviceConfig(**base)
PROCESSES = [
(100, "notepad.exe"),
(101, "Notepad.EXE"),
(200, "steam.exe"),
(300, "Spotify.exe"),
(400, "explorer.exe"),
(500, "python.exe"),
]
class SelectVictimsTests(unittest.TestCase):
def test_blocks_every_matching_process(self):
victims = select_victims(PROCESSES, config())
self.assertEqual([(v.pid, v.matched) for v in victims], [(100, "notepad"), (101, "notepad"), (200, "steam")])
def test_allowlist_wins(self):
victims = select_victims(PROCESSES, config(allow=["spotify"]))
self.assertNotIn(300, [v.pid for v in victims])
def test_nothing_is_blocked_when_unlocked(self):
self.assertEqual(select_victims(PROCESSES, config(locked=False)), [])
def test_nothing_is_blocked_when_the_blocklist_is_empty(self):
self.assertEqual(select_victims(PROCESSES, config(block=[])), [])
def test_nothing_is_blocked_after_until(self):
expired = config(until=(NOW - timedelta(minutes=1)).isoformat())
self.assertEqual(select_victims(PROCESSES, expired, now=NOW), [])
def test_protected_names_are_never_victims(self):
victims = select_victims(PROCESSES, config(block=["explorer.exe"]), protected_names=["explorer.exe"])
self.assertEqual(victims, [])
def test_protected_pids_are_never_victims(self):
victims = select_victims(PROCESSES, config(block=["python.exe"]), protected_pids=[500])
self.assertEqual(victims, [])
def test_duplicate_pids_are_reported_once(self):
victims = select_victims([(100, "notepad.exe"), (100, "notepad.exe")], config())
self.assertEqual(len(victims), 1)
def test_bad_rows_are_ignored(self):
victims = select_victims([("x", "notepad.exe"), (0, ""), (None, None)], config())
self.assertEqual(victims, [])
class SelfProtectionTests(unittest.TestCase):
def test_includes_the_process_and_its_parents(self):
pids, names = self_protection(10, ["python.exe", "pythonw.exe"], [5, 3, 1])
self.assertEqual(pids, {10, 5, 3, 1})
self.assertEqual(names, {"python.exe", "pythonw.exe"})
if __name__ == "__main__":
unittest.main()