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.
This commit is contained in:
79
windows/tests/test_blocker.py
Normal file
79
windows/tests/test_blocker.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user