disallow to disable guard during focus
docker / image (push) Failing after 24s
lint / ruff (push) Failing after 28s
tests / backend (push) Successful in 37s
tests / desktop (push) Successful in 22s
tests / android-core (push) Successful in 49s
tests / web (push) Successful in 1m20s
android / apk (push) Successful in 8m49s
docker / image (push) Failing after 24s
lint / ruff (push) Failing after 28s
tests / backend (push) Successful in 37s
tests / desktop (push) Successful in 22s
tests / android-core (push) Successful in 49s
tests / web (push) Successful in 1m20s
android / apk (push) Successful in 8m49s
This commit is contained in:
21 files changed
+481
-43
No files matched your search
@@ -38,13 +38,20 @@ def put_settings(session: DbSession, patch: dict[str, Any] = Body(...)) -> dict:
|
||||
# `exc.errors()` can carry exception objects in `ctx`, which JSON cannot
|
||||
# take; pydantic's own `json()` renders them as text.
|
||||
raise HTTPException(status_code=422, detail=json.loads(exc.json())) from exc
|
||||
except blocker_service.GuardLocked as exc:
|
||||
# 409, not 422: the body is valid, the current state refuses it. A plain
|
||||
# string detail is what every client's error path already surfaces.
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
return {"settings": settings_service.mask_secrets(updated.model_dump(mode="json"))}
|
||||
|
||||
|
||||
@router.post("/reset")
|
||||
def reset_settings(session: DbSession) -> dict:
|
||||
"""Back to defaults, re-seeded from the environment."""
|
||||
updated = settings_service.save_settings(session, settings_service.settings_from_env())
|
||||
try:
|
||||
updated = settings_service.reset_to_env(session)
|
||||
except blocker_service.GuardLocked as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
return {"settings": settings_service.mask_secrets(updated.model_dump(mode="json"))}
|
||||
|
||||
|
||||
|
||||
@@ -187,12 +187,59 @@ def schedule_active(settings: AppSettings, moment: datetime | None = None) -> bo
|
||||
return False
|
||||
|
||||
|
||||
GUARD_LOCK_NOTE = "Finish or drop the interval to switch the focus guard off."
|
||||
"""Said in one voice: the 409 detail, and the `lock_reason` every client renders."""
|
||||
|
||||
|
||||
class GuardLocked(RuntimeError):
|
||||
"""Switching the guard off was refused because an interval is live."""
|
||||
|
||||
|
||||
def guard_locked(session: Session, settings: AppSettings) -> bool:
|
||||
"""The guard is on and an interval is live, so it may not be switched off.
|
||||
|
||||
Any kind, running or paused — `pomodoro.current_session` is exactly that. A
|
||||
break is not an escape hatch and neither is pausing the clock.
|
||||
|
||||
Deliberately keyed off `blocker.enabled` rather than off `policy()["active"]`:
|
||||
in `scheduled` mode outside every window nothing is being blocked right now,
|
||||
and the guard still must not be quietly switched off before the window opens.
|
||||
"""
|
||||
return settings.blocker.enabled and pomodoro.current_session(session) is not None
|
||||
|
||||
|
||||
def ensure_guard_not_switched_off(
|
||||
session: Session, before: AppSettings, after: AppSettings
|
||||
) -> None:
|
||||
"""Refuse a settings write that would turn the guard off mid-interval.
|
||||
|
||||
Compares the two documents rather than reading the patch. The patch is a deep
|
||||
merge of a shared object and arrives loosely typed — `{"enabled": "no"}`
|
||||
coerces to `False`, so inspecting the raw keys would have to re-implement
|
||||
pydantic to be right — and "did the stored value go from on to off" is the
|
||||
only question that cannot be fooled. It also means an unrelated patch, a
|
||||
full-document echo and a re-sent `enabled: false` all pass by construction.
|
||||
|
||||
Switching the guard *on* is never refused.
|
||||
"""
|
||||
if not after.blocker.enabled and guard_locked(session, before):
|
||||
raise GuardLocked(GUARD_LOCK_NOTE)
|
||||
|
||||
|
||||
def policy(session: Session, settings: AppSettings) -> dict[str, Any]:
|
||||
"""What the desktop app polls: should I block, and what."""
|
||||
"""What the desktop app polls: should I block, and what.
|
||||
|
||||
`active` and `locked` answer two different questions and do disagree: in
|
||||
`scheduled` mode outside every window, nothing is blocked (`active` false)
|
||||
while the guard is still locked on (`locked` true) because an interval runs.
|
||||
"""
|
||||
blocker = settings.blocker
|
||||
current = pomodoro.current_session(session)
|
||||
active = False
|
||||
reason = "disabled"
|
||||
# The same rule as `guard_locked()`, reusing the row this function already
|
||||
# fetched rather than querying twice — `test_blocker.py` pins the two together.
|
||||
locked = blocker.enabled and current is not None
|
||||
|
||||
if blocker.enabled:
|
||||
if blocker.mode == "always":
|
||||
@@ -218,7 +265,8 @@ def policy(session: Session, settings: AppSettings) -> dict[str, Any]:
|
||||
"reason": reason,
|
||||
"mode": blocker.mode,
|
||||
"enabled": blocker.enabled,
|
||||
"strict": blocker.strict,
|
||||
"locked": locked,
|
||||
"lock_reason": GUARD_LOCK_NOTE if locked else "",
|
||||
"redirect_ip": blocker.redirect_ip,
|
||||
"domains": expand_domains(settings) if active else [],
|
||||
"apps": [app.strip() for app in blocker.blocked_apps if app.strip()] if active else [],
|
||||
|
||||
@@ -20,7 +20,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import env_settings
|
||||
from app.models import SettingsRow
|
||||
from app.services import pomodoro
|
||||
from app.services import blocker_service, pomodoro
|
||||
from app.settings_schema import SECRET_FIELDS, SECRET_MASK, AppSettings
|
||||
|
||||
_SETTINGS_ID = 1
|
||||
@@ -192,10 +192,17 @@ def unmask_secrets(stored: dict[str, Any], incoming: dict[str, Any]) -> dict[str
|
||||
|
||||
def apply_patch(session: Session, patch: dict[str, Any]) -> AppSettings:
|
||||
"""Merge a partial settings document (as the UI sends it) and store it."""
|
||||
current = load_settings(session).model_dump(mode="json")
|
||||
stored = load_settings(session)
|
||||
current = stored.model_dump(mode="json")
|
||||
patch = unmask_secrets(current, patch)
|
||||
merged = _merge(current, patch)
|
||||
updated = save_settings(session, AppSettings.model_validate(merged))
|
||||
candidate = AppSettings.model_validate(merged)
|
||||
|
||||
# Validate, then refuse, then write: a malformed patch stays a 422 rather
|
||||
# than becoming a 409, and nothing is stored either way.
|
||||
blocker_service.ensure_guard_not_switched_off(session, stored, candidate)
|
||||
|
||||
updated = save_settings(session, candidate)
|
||||
|
||||
# A changed Focus/Short/Long minute (directly, or via a preset) resizes
|
||||
# whatever interval is already running or paused — see
|
||||
@@ -203,3 +210,16 @@ def apply_patch(session: Session, patch: dict[str, Any]) -> AppSettings:
|
||||
# is simplest to always run it rather than detect which keys changed.
|
||||
pomodoro.rescale_current(session, updated)
|
||||
return updated
|
||||
|
||||
|
||||
def reset_to_env(session: Session) -> AppSettings:
|
||||
"""Back to the environment seed — refused if that would unguard a live interval.
|
||||
|
||||
`settings_from_env` never touches the `blocker` subtree, so a reset always
|
||||
lands on the schema default `enabled = False`: without this check one POST
|
||||
would switch the focus guard off mid-interval.
|
||||
"""
|
||||
stored = load_settings(session)
|
||||
fresh = settings_from_env()
|
||||
blocker_service.ensure_guard_not_switched_off(session, stored, fresh)
|
||||
return save_settings(session, fresh)
|
||||
@@ -204,8 +204,6 @@ class BlockerSettings(BaseModel):
|
||||
"""Terminate a blocked app when it appears; otherwise only warn and log."""
|
||||
|
||||
schedule: list[BlockWindow] = Field(default_factory=list)
|
||||
strict: bool = False
|
||||
"""The desktop app refuses to switch blocking off while a work interval runs."""
|
||||
|
||||
redirect_ip: str = "127.0.0.1"
|
||||
app_poll_seconds: int = Field(default=5, ge=1, le=60)
|
||||
|
||||
@@ -125,6 +125,36 @@ def test_invalid_settings_are_rejected(client):
|
||||
assert client.put("/api/settings", json={"timezone": "Mars/Olympus"}).status_code == 422
|
||||
|
||||
|
||||
def test_switching_the_guard_off_mid_interval_is_a_409(client):
|
||||
assert client.put("/api/settings", json={"blocker": {"enabled": True}}).status_code == 200
|
||||
client.post("/api/timer/start", json={"kind": "work"})
|
||||
|
||||
assert client.get("/api/blocker/policy").json()["locked"] is True
|
||||
|
||||
refused = client.put("/api/settings", json={"blocker": {"enabled": False}})
|
||||
assert refused.status_code == 409
|
||||
assert "focus guard" in refused.json()["detail"]
|
||||
|
||||
# Turning it on, and editing anything else, are both still fine.
|
||||
assert client.put("/api/settings", json={"blocker": {"enabled": True}}).status_code == 200
|
||||
lists = client.put("/api/settings", json={"blocker": {"blocked_apps": ["steam"]}})
|
||||
assert lists.status_code == 200
|
||||
assert lists.json()["settings"]["blocker"]["enabled"] is True
|
||||
|
||||
client.post("/api/timer/complete", json={"auto_next": False})
|
||||
assert client.put("/api/settings", json={"blocker": {"enabled": False}}).status_code == 200
|
||||
|
||||
|
||||
def test_resetting_the_settings_cannot_unguard_a_live_interval(client):
|
||||
client.put("/api/settings", json={"blocker": {"enabled": True}})
|
||||
client.post("/api/timer/start", json={"kind": "short_break"})
|
||||
|
||||
assert client.post("/api/settings/reset").status_code == 409
|
||||
|
||||
client.post("/api/timer/complete", json={"auto_next": False})
|
||||
assert client.post("/api/settings/reset").status_code == 200
|
||||
|
||||
|
||||
def test_notification_test_endpoint_falls_back_to_the_log(client):
|
||||
body = client.post("/api/notifications/test", json={}).json()
|
||||
assert body["deliveries"][0]["channel"] == "log"
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from app.services import blocker_service, pomodoro, settings_service
|
||||
|
||||
|
||||
@@ -189,3 +190,179 @@ def test_the_policy_carries_the_allow_masks_for_the_stub(session, settings):
|
||||
assert result["allowed"] == ["music.youtube.com", "*.docs.google.com"]
|
||||
assert "music.youtube.com" not in result["domains"]
|
||||
assert "m.youtube.com" in result["all_domains"]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# The lock: the guard will not be switched off mid-interval
|
||||
#
|
||||
# Only `apply_patch` and `reset_to_env` carry the check. `save_settings` stays an
|
||||
# unguarded low-level writer on purpose — the env seed, the password CLI and the
|
||||
# tests above all go through it — which is why these exercise the real write path.
|
||||
# --------------------------------------------------------------------------
|
||||
def _guarded(session, settings):
|
||||
settings.blocker.enabled = True
|
||||
settings_service.save_settings(session, settings)
|
||||
|
||||
|
||||
def test_the_guard_cannot_be_switched_off_while_an_interval_runs(session, settings):
|
||||
_guarded(session, settings)
|
||||
pomodoro.start(session, settings, kind="work")
|
||||
|
||||
with pytest.raises(blocker_service.GuardLocked):
|
||||
settings_service.apply_patch(session, {"blocker": {"enabled": False}})
|
||||
|
||||
# And nothing was written on the way out.
|
||||
assert settings_service.load_settings(session).blocker.enabled is True
|
||||
|
||||
|
||||
def test_pausing_is_not_an_escape_hatch(session, settings):
|
||||
_guarded(session, settings)
|
||||
pomodoro.start(session, settings, kind="work")
|
||||
pomodoro.pause(session, settings)
|
||||
|
||||
with pytest.raises(blocker_service.GuardLocked):
|
||||
settings_service.apply_patch(session, {"blocker": {"enabled": False}})
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["short_break", "long_break"])
|
||||
@pytest.mark.parametrize("pause", [False, True])
|
||||
def test_a_break_is_not_an_escape_hatch_either(session, settings, kind, pause):
|
||||
"""The lock is kind-blind: `current_session` is the whole rule."""
|
||||
_guarded(session, settings)
|
||||
pomodoro.start(session, settings, kind=kind)
|
||||
if pause:
|
||||
pomodoro.pause(session, settings)
|
||||
|
||||
with pytest.raises(blocker_service.GuardLocked):
|
||||
settings_service.apply_patch(session, {"blocker": {"enabled": False}})
|
||||
|
||||
|
||||
def test_the_guard_can_always_be_switched_on_mid_interval(session, settings):
|
||||
pomodoro.start(session, settings, kind="work")
|
||||
updated = settings_service.apply_patch(session, {"blocker": {"enabled": True}})
|
||||
assert updated.blocker.enabled is True
|
||||
|
||||
|
||||
def test_the_guard_switches_off_freely_when_nothing_is_running(session, settings):
|
||||
_guarded(session, settings)
|
||||
updated = settings_service.apply_patch(session, {"blocker": {"enabled": False}})
|
||||
assert updated.blocker.enabled is False
|
||||
|
||||
|
||||
def test_the_guard_switches_off_once_the_interval_is_over(session, settings):
|
||||
_guarded(session, settings)
|
||||
pomodoro.start(session, settings, kind="work")
|
||||
pomodoro.complete(session, settings, auto_next=False)
|
||||
|
||||
updated = settings_service.apply_patch(session, {"blocker": {"enabled": False}})
|
||||
assert updated.blocker.enabled is False
|
||||
|
||||
|
||||
def test_an_unrelated_blocker_patch_still_saves_mid_interval(session, settings):
|
||||
"""What the web app's list editor sends — the regression the check is shaped for."""
|
||||
_guarded(session, settings)
|
||||
pomodoro.start(session, settings, kind="work")
|
||||
|
||||
updated = settings_service.apply_patch(
|
||||
session,
|
||||
{"blocker": {"blocked_domains": ["youtube.com"], "blocked_apps": ["steam"]}},
|
||||
)
|
||||
assert updated.blocker.blocked_domains == ["youtube.com"]
|
||||
assert updated.blocker.enabled is True
|
||||
|
||||
|
||||
def test_re_sending_an_already_off_guard_is_not_a_refusal(session, settings):
|
||||
pomodoro.start(session, settings, kind="work")
|
||||
updated = settings_service.apply_patch(session, {"blocker": {"enabled": False}})
|
||||
assert updated.blocker.enabled is False
|
||||
|
||||
|
||||
def test_a_full_document_echo_survives_a_live_interval(session, settings):
|
||||
_guarded(session, settings)
|
||||
pomodoro.start(session, settings, kind="work")
|
||||
|
||||
echo = settings_service.load_settings(session).model_dump(mode="json")
|
||||
updated = settings_service.apply_patch(session, echo)
|
||||
assert updated.blocker.enabled is True
|
||||
|
||||
|
||||
def test_a_loosely_typed_off_is_still_an_off(session, settings):
|
||||
"""`{"enabled": "no"}` coerces to False, which is why the check compares
|
||||
validated documents rather than reading the patch's keys."""
|
||||
_guarded(session, settings)
|
||||
pomodoro.start(session, settings, kind="work")
|
||||
|
||||
with pytest.raises(blocker_service.GuardLocked):
|
||||
settings_service.apply_patch(session, {"blocker": {"enabled": "no"}})
|
||||
|
||||
|
||||
def test_the_policy_reports_the_lock(session, settings):
|
||||
_guarded(session, settings)
|
||||
assert blocker_service.policy(session, settings)["locked"] is False
|
||||
assert blocker_service.policy(session, settings)["lock_reason"] == ""
|
||||
|
||||
pomodoro.start(session, settings, kind="work")
|
||||
live = blocker_service.policy(session, settings)
|
||||
assert live["locked"] is True
|
||||
assert live["lock_reason"] == blocker_service.GUARD_LOCK_NOTE
|
||||
|
||||
pomodoro.pause(session, settings)
|
||||
assert blocker_service.policy(session, settings)["locked"] is True
|
||||
|
||||
|
||||
def test_a_guard_that_is_off_is_never_reported_locked(session, settings):
|
||||
"""`locked` has "and the guard is on" folded in, so no client re-derives it."""
|
||||
pomodoro.start(session, settings, kind="work")
|
||||
assert blocker_service.policy(session, settings)["locked"] is False
|
||||
|
||||
|
||||
def test_the_policy_and_the_predicate_agree(session, settings):
|
||||
"""`policy()` re-expresses `guard_locked()` to reuse the row it already has;
|
||||
these two must not drift."""
|
||||
for enabled in (False, True):
|
||||
settings.blocker.enabled = enabled
|
||||
settings_service.save_settings(session, settings)
|
||||
for kind in (None, "work", "short_break"):
|
||||
if kind is not None:
|
||||
pomodoro.start(session, settings, kind=kind)
|
||||
assert blocker_service.policy(session, settings)["locked"] is (
|
||||
blocker_service.guard_locked(session, settings)
|
||||
)
|
||||
if kind is not None:
|
||||
pomodoro.abandon(session, settings, reason="test")
|
||||
|
||||
|
||||
def test_scheduled_mode_outside_its_window_is_inactive_and_still_locked(session, settings):
|
||||
"""The distinction `active` cannot express: nothing is blocked right now, and
|
||||
the guard still must not be switched off before the window opens."""
|
||||
settings.blocker.enabled = True
|
||||
settings.blocker.mode = "scheduled"
|
||||
settings.blocker.schedule = []
|
||||
settings_service.save_settings(session, settings)
|
||||
pomodoro.start(session, settings, kind="work")
|
||||
|
||||
result = blocker_service.policy(session, settings)
|
||||
assert result["active"] is False
|
||||
assert result["locked"] is True
|
||||
|
||||
with pytest.raises(blocker_service.GuardLocked):
|
||||
settings_service.apply_patch(session, {"blocker": {"enabled": False}})
|
||||
|
||||
|
||||
def test_a_reset_cannot_switch_the_guard_off_mid_interval(session, settings):
|
||||
"""`settings_from_env` never touches the blocker subtree, so a reset always
|
||||
lands on `enabled = False` — a second, quieter off-switch."""
|
||||
_guarded(session, settings)
|
||||
pomodoro.start(session, settings, kind="work")
|
||||
|
||||
with pytest.raises(blocker_service.GuardLocked):
|
||||
settings_service.reset_to_env(session)
|
||||
assert settings_service.load_settings(session).blocker.enabled is True
|
||||
|
||||
pomodoro.complete(session, settings, auto_next=False)
|
||||
assert settings_service.reset_to_env(session).blocker.enabled is False
|
||||
|
||||
|
||||
def test_a_reset_is_fine_while_an_interval_runs_if_the_guard_is_off(session, settings):
|
||||
pomodoro.start(session, settings, kind="work")
|
||||
assert settings_service.reset_to_env(session).blocker.enabled is False
|
||||
Reference in new issue
Block a user