"""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()