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.
150 lines
5.7 KiB
Python
150 lines
5.7 KiB
Python
"""Config parsing, effective lock state and the keep-last-state cache."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import tempfile
|
|
import unittest
|
|
from datetime import datetime, timedelta, timezone
|
|
from pathlib import Path
|
|
|
|
from webrestrict.config import ConfigCache, DeviceConfig, bare, parse_time
|
|
|
|
NOW = datetime(2026, 10, 14, 20, 0, tzinfo=timezone.utc)
|
|
|
|
|
|
def payload(**overrides) -> str:
|
|
body = {
|
|
"v": 1,
|
|
"device_id": 2,
|
|
"name": "Finn's computer",
|
|
"platform": "windows",
|
|
"locked": True,
|
|
"until": (NOW + timedelta(minutes=30)).isoformat().replace("+00:00", "Z"),
|
|
"allow": ["Spotify.exe"],
|
|
"block": ["notepad.exe", "steam"],
|
|
"reason": "schedule",
|
|
"generated_at": "2026-10-14T19:00:00Z",
|
|
}
|
|
body.update(overrides)
|
|
return json.dumps(body)
|
|
|
|
|
|
class ParseTests(unittest.TestCase):
|
|
def test_parses_the_phase3_shape(self):
|
|
config = DeviceConfig.from_json(payload(extra_field="ignored"))
|
|
self.assertEqual(config.v, 1)
|
|
self.assertEqual(config.device_id, 2)
|
|
self.assertTrue(config.locked)
|
|
self.assertEqual(config.allow, ["Spotify.exe"])
|
|
self.assertEqual(config.block, ["notepad.exe", "steam"])
|
|
self.assertEqual(config.reason, "schedule")
|
|
self.assertIsNotNone(config.until_at())
|
|
|
|
def test_missing_lists_become_empty_arrays(self):
|
|
config = DeviceConfig.from_json('{"v":1,"locked":true}')
|
|
self.assertEqual(config.allow, [])
|
|
self.assertEqual(config.block, [])
|
|
|
|
def test_rejects_a_newer_schema(self):
|
|
with self.assertRaises(ValueError):
|
|
DeviceConfig.from_json(payload(v=2))
|
|
|
|
def test_rejects_malformed_json(self):
|
|
with self.assertRaises(json.JSONDecodeError):
|
|
DeviceConfig.from_json("{not json")
|
|
|
|
def test_parse_time_accepts_z_and_offsets(self):
|
|
self.assertEqual(parse_time("2026-10-14T20:00:00Z").tzinfo, timezone.utc)
|
|
self.assertEqual(parse_time("2026-10-14T20:00:00+10:00").utcoffset(), timedelta(hours=10))
|
|
self.assertIsNone(parse_time("nonsense"))
|
|
self.assertIsNone(parse_time(None))
|
|
|
|
|
|
class EffectiveStateTests(unittest.TestCase):
|
|
def test_not_locked_never_blocks(self):
|
|
config = DeviceConfig.from_json(payload(locked=False))
|
|
self.assertFalse(config.is_blocking(NOW))
|
|
|
|
def test_locked_without_until_blocks_indefinitely(self):
|
|
config = DeviceConfig.from_json(payload(until=None))
|
|
self.assertTrue(config.is_blocking(NOW))
|
|
self.assertTrue(config.is_blocking(NOW + timedelta(days=30)))
|
|
|
|
def test_locked_until_a_future_time_blocks(self):
|
|
config = DeviceConfig.from_json(payload())
|
|
self.assertTrue(config.is_blocking(NOW))
|
|
|
|
def test_locked_until_a_past_time_does_not_block(self):
|
|
config = DeviceConfig.from_json(payload(until=(NOW - timedelta(minutes=1)).isoformat()))
|
|
self.assertFalse(config.is_blocking(NOW))
|
|
|
|
def test_a_live_grant_never_blocks(self):
|
|
config = DeviceConfig.from_json(
|
|
payload(locked=False, until=None, grant_until=(NOW + timedelta(minutes=10)).isoformat())
|
|
)
|
|
self.assertFalse(config.is_blocking(NOW))
|
|
|
|
def test_an_expired_grant_does_not_block_either(self):
|
|
config = DeviceConfig.from_json(
|
|
payload(locked=False, until=None, grant_until=(NOW - timedelta(minutes=10)).isoformat())
|
|
)
|
|
self.assertFalse(config.is_blocking(NOW))
|
|
|
|
|
|
class NameTests(unittest.TestCase):
|
|
def test_bare_normalises_case_and_exe(self):
|
|
self.assertEqual(bare("Notepad.EXE"), "notepad")
|
|
self.assertEqual(bare(" STEAM.exe "), "steam")
|
|
self.assertEqual(bare(None), "")
|
|
|
|
def test_blocked_names_subtract_the_allowlist(self):
|
|
config = DeviceConfig.from_json(payload(block=["notepad.exe", "Spotify"], allow=["spotify.EXE"]))
|
|
self.assertEqual(config.blocked_names(), {"notepad"})
|
|
|
|
def test_allowlist_wins_per_name(self):
|
|
config = DeviceConfig.from_json(payload(block=["notepad.exe", "steam.exe"], allow=["Notepad"]))
|
|
self.assertFalse(config.is_blocked_name("NOTEPAD.EXE"))
|
|
self.assertTrue(config.is_blocked_name("steam.exe"))
|
|
|
|
|
|
class CacheTests(unittest.TestCase):
|
|
def setUp(self):
|
|
self.tmp = tempfile.TemporaryDirectory()
|
|
self.path = Path(self.tmp.name) / "config-cache.json"
|
|
self.addCleanup(self.tmp.cleanup)
|
|
|
|
def test_round_trips_through_disk(self):
|
|
cache = ConfigCache(self.path)
|
|
stored = cache.accept(payload())
|
|
self.assertIsNotNone(stored)
|
|
again = ConfigCache(self.path).load()
|
|
self.assertEqual(again.block, stored.block)
|
|
self.assertTrue(again.locked)
|
|
|
|
def test_a_malformed_payload_keeps_the_last_config(self):
|
|
cache = ConfigCache(self.path)
|
|
cache.accept(payload())
|
|
self.assertIsNone(cache.accept("{not json"))
|
|
self.assertEqual(cache.current.block, ["notepad.exe", "steam"])
|
|
|
|
def test_an_older_config_is_ignored(self):
|
|
cache = ConfigCache(self.path)
|
|
cache.accept(payload(generated_at="2026-10-14T19:30:00Z", block=["steam.exe"]))
|
|
self.assertIsNone(cache.accept(payload(generated_at="2026-10-14T18:00:00Z", block=["notepad.exe"])))
|
|
self.assertEqual(cache.current.block, ["steam.exe"])
|
|
|
|
def test_a_newer_config_replaces_it(self):
|
|
cache = ConfigCache(self.path)
|
|
cache.accept(payload(generated_at="2026-10-14T19:30:00Z", block=["steam.exe"]))
|
|
stored = cache.accept(payload(generated_at="2026-10-14T19:45:00Z", block=["notepad.exe"]))
|
|
self.assertIsNotNone(stored)
|
|
self.assertEqual(cache.current.block, ["notepad.exe"])
|
|
|
|
def test_a_missing_file_is_not_an_error(self):
|
|
self.assertIsNone(ConfigCache(self.path).load())
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|