From b91ecbd5f63c2527bbc8099d39a9685b9a6ce402 Mon Sep 17 00:00:00 2001 From: Joshua Date: Thu, 24 Sep 2026 19:55:43 -0400 Subject: [PATCH] Reject non-integer weekday values in presence pattern requests The create and edit pattern models typed weekday values as lax int, so JSON true, "1" and 1.0 were coerced to weekday 1. Type them StrictInt so each is a 422 naming the pattern field, and add create and edit regression cases for bool, string, float and out-of-range weekdays. Co-Authored-By: Claude Opus 5.5 --- api/routes_admin.py | 9 +++++---- tests/test_api_admin_presence.py | 11 +++++++++++ 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/api/routes_admin.py b/api/routes_admin.py index 419658a..b70e8eb 100644 --- a/api/routes_admin.py +++ b/api/routes_admin.py @@ -623,15 +623,16 @@ class AdminPresencePatternCreateRequest(BaseModel): covers that week (Monday=0); every week of the cycle must be covered, which the PRESENCE MODEL enforces at construction, not this shape — as it does the non-blank ``name`` and the - ``home``/``away`` ``kind``. ``cycle_length_weeks`` is a strict - JSON integer: ``2.0`` or ``"2"`` is a 422, not a silent coercion. + ``home``/``away`` ``kind``. ``cycle_length_weeks`` and the weekday + values are strict JSON integers: ``2.0``, ``"2"`` or ``true`` is a + 422, not a silent coercion. """ name: str kind: str cycle_length_weeks: StrictInt anchor_date: str - pattern: dict[int, list[int]] + pattern: dict[int, list[StrictInt]] class AdminPresencePatternEditRequest(BaseModel): @@ -646,7 +647,7 @@ class AdminPresencePatternEditRequest(BaseModel): kind: str | None = None cycle_length_weeks: StrictInt | None = None anchor_date: str | None = None - pattern: dict[int, list[int]] | None = None + pattern: dict[int, list[StrictInt]] | None = None class AdminPresencePatternResponse(BaseModel): diff --git a/tests/test_api_admin_presence.py b/tests/test_api_admin_presence.py index 84f7b95..5ce5590 100644 --- a/tests/test_api_admin_presence.py +++ b/tests/test_api_admin_presence.py @@ -690,6 +690,12 @@ async def test_pattern_patch_unknown_id_is_404( ({"anchor_date": None}, "anchor_date"), ({"pattern": {}}, "pattern"), ({"pattern": None}, "pattern"), + ({"pattern": {"0": [7]}}, "pattern"), # weekday out of 0..6 + # Strict JSON integer weekdays: no bool, string or float coercion. + ({"pattern": {"0": [True]}}, "pattern"), + ({"pattern": {"0": [False]}}, "pattern"), + ({"pattern": {"0": ["1"]}}, "pattern"), + ({"pattern": {"0": [1.0]}}, "pattern"), ], ) async def test_rejected_pattern_patch_is_422_and_changes_nothing( @@ -882,6 +888,11 @@ async def test_pattern_and_override_for_unknown_child_are_404( (_pattern_body(anchor_date="2026-13-40"), "anchor_date"), (_pattern_body(anchor_date="05/01/2026"), "anchor_date"), (_pattern_body(pattern={"0": [7]}), "pattern"), # weekday out of 0..6 + # Strict JSON integer weekdays: no bool, string or float coercion. + (_pattern_body(pattern={"0": [True]}), "pattern"), + (_pattern_body(pattern={"0": [False]}), "pattern"), + (_pattern_body(pattern={"0": ["1"]}), "pattern"), + (_pattern_body(pattern={"0": [1.0]}), "pattern"), (_pattern_body(pattern={"nope": [0]}), "pattern"), # non-int week (_pattern_body(pattern={"0": "0,2,4"}), "pattern"), # not a list (_pattern_body(kind="sometimes"), "kind"),