Reject coerced assignee ids
The create and edit quest-definition bodies parsed assignee_child_ids as lax list[int], so JSON true, 1.0 and "1" were coerced to child id 1 before the core's validator saw them. Use list[StrictInt] on both so such ids are a 422 and nothing is written. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+12
-3
@@ -252,7 +252,13 @@ import datetime
|
|||||||
from typing import Annotated, Literal, NoReturn
|
from typing import Annotated, Literal, NoReturn
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||||
from pydantic import BaseModel, StrictBool, field_validator, model_validator
|
from pydantic import (
|
||||||
|
BaseModel,
|
||||||
|
StrictBool,
|
||||||
|
StrictInt,
|
||||||
|
field_validator,
|
||||||
|
model_validator,
|
||||||
|
)
|
||||||
|
|
||||||
from api import transitions as api_transitions
|
from api import transitions as api_transitions
|
||||||
from api.auth import require_admin
|
from api.auth import require_admin
|
||||||
@@ -457,11 +463,13 @@ class AdminQuestDefinitionCreateRequest(BaseModel):
|
|||||||
date the child is absent). ``windows`` entries are a window name (``morning``) or a two-item
|
date the child is absent). ``windows`` entries are a window name (``morning``) or a two-item
|
||||||
``[window, due_time]`` pair whose due time is a strict 24-hour
|
``[window, due_time]`` pair whose due time is a strict 24-hour
|
||||||
HH:MM (or ``null`` for none) — both forms the core normalizes.
|
HH:MM (or ``null`` for none) — both forms the core normalizes.
|
||||||
|
Assignee ids are strict JSON integers: a boolean, float or numeric
|
||||||
|
string is 422, never coerced to a child id.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
title: str
|
title: str
|
||||||
rule: AdminRuleRequest
|
rule: AdminRuleRequest
|
||||||
assignee_child_ids: list[int]
|
assignee_child_ids: list[StrictInt]
|
||||||
windows: list[str | tuple[str, str | None]]
|
windows: list[str | tuple[str, str | None]]
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
icon: str | None = None
|
icon: str | None = None
|
||||||
@@ -483,6 +491,7 @@ class AdminQuestDefinitionEditRequest(BaseModel):
|
|||||||
REPLACES the whole assignee set (non-empty, every id an existing
|
REPLACES the whole assignee set (non-empty, every id an existing
|
||||||
active child — create's contract; a rejection changes nothing). An
|
active child — create's contract; a rejection changes nothing). An
|
||||||
omitted (or ``null``) ``skip_on_away`` keeps the stored value.
|
omitted (or ``null``) ``skip_on_away`` keeps the stored value.
|
||||||
|
Assignee ids are strict JSON integers, as on create.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
title: str | None = None
|
title: str | None = None
|
||||||
@@ -491,7 +500,7 @@ class AdminQuestDefinitionEditRequest(BaseModel):
|
|||||||
rule: AdminRuleRequest | None = None
|
rule: AdminRuleRequest | None = None
|
||||||
windows: list[str | tuple[str, str | None]] | None = None
|
windows: list[str | tuple[str, str | None]] | None = None
|
||||||
skip_on_away: StrictBool | None = None
|
skip_on_away: StrictBool | None = None
|
||||||
assignee_child_ids: list[int] | None = None
|
assignee_child_ids: list[StrictInt] | None = None
|
||||||
|
|
||||||
|
|
||||||
class AdminQuestDefinitionActiveRequest(BaseModel):
|
class AdminQuestDefinitionActiveRequest(BaseModel):
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ assignee set inside the core edit's ONE transaction
|
|||||||
- an empty list, a duplicate, an unknown child or an inactive child is
|
- an empty list, a duplicate, an unknown child or an inactive child is
|
||||||
422 and NOTHING is written — the stored assignees (and any other
|
422 and NOTHING is written — the stored assignees (and any other
|
||||||
field supplied in the same body) are unchanged;
|
field supplied in the same body) are unchanged;
|
||||||
|
- a JSON boolean, float or numeric string id is 422 on create and edit
|
||||||
|
(never coerced to an int) and nothing is written;
|
||||||
- an omitted ``assignee_child_ids`` leaves the assignees unchanged;
|
- an omitted ``assignee_child_ids`` leaves the assignees unchanged;
|
||||||
- an assignment change regenerates the definition's future instances
|
- an assignment change regenerates the definition's future instances
|
||||||
through the existing core path;
|
through the existing core path;
|
||||||
@@ -39,6 +41,9 @@ from tests.admin_jwt_harness import (
|
|||||||
_dao_rules = importlib.import_module("nestquest_core.dao_rules")
|
_dao_rules = importlib.import_module("nestquest_core.dao_rules")
|
||||||
_dao_instances = importlib.import_module("nestquest_core.dao_instances")
|
_dao_instances = importlib.import_module("nestquest_core.dao_instances")
|
||||||
_materialize = importlib.import_module("nestquest_core.materialize")
|
_materialize = importlib.import_module("nestquest_core.materialize")
|
||||||
|
_quest_definitions = importlib.import_module(
|
||||||
|
"nestquest_core.quest_definitions"
|
||||||
|
)
|
||||||
|
|
||||||
#: The panel service token the harness app is configured with.
|
#: The panel service token the harness app is configured with.
|
||||||
HARNESS_PANEL_TOKEN = "admin-test-panel-token"
|
HARNESS_PANEL_TOKEN = "admin-test-panel-token"
|
||||||
@@ -245,6 +250,66 @@ async def test_rejected_assignee_list_is_422_and_changes_nothing(
|
|||||||
assert (await dao.get(definition_id)).title == "Brush teeth"
|
assert (await dao.get(definition_id)).title == "Brush teeth"
|
||||||
|
|
||||||
|
|
||||||
|
#: JSON ids the lax ``int`` parser would coerce (``true`` -> 1).
|
||||||
|
_COERCIBLE_IDS = [True, 1.0, "1"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("coercible", _COERCIBLE_IDS)
|
||||||
|
async def test_edit_rejects_coercible_assignee_ids_and_changes_nothing(
|
||||||
|
admin_assignees: SimpleNamespace, coercible: object
|
||||||
|
) -> None:
|
||||||
|
"""A bool, float or numeric-string id edit is 422; nothing changes."""
|
||||||
|
client = admin_assignees.client
|
||||||
|
ada, bo = await _create_children(client, ("Ada", "Bo"))
|
||||||
|
# Assign bo only, so a coerced id 1 (ada) would be a visible change.
|
||||||
|
definition_id = await _create_definition(client, [bo])
|
||||||
|
assert ada == 1
|
||||||
|
|
||||||
|
response = await client.patch(
|
||||||
|
_edit_path(definition_id),
|
||||||
|
json={"title": "Renamed", "assignee_child_ids": [coercible]},
|
||||||
|
headers=_admin_headers(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
|
assert await _stored_assignee_ids(
|
||||||
|
admin_assignees.database, definition_id
|
||||||
|
) == {bo}
|
||||||
|
dao = _dao_rules.QuestDefinitionsDao(admin_assignees.database)
|
||||||
|
assert (await dao.get(definition_id)).title == "Brush teeth"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("coercible", _COERCIBLE_IDS)
|
||||||
|
async def test_create_rejects_coercible_assignee_ids_and_persists_nothing(
|
||||||
|
admin_assignees: SimpleNamespace, coercible: object
|
||||||
|
) -> None:
|
||||||
|
"""A bool, float or numeric-string id create is 422; nothing is written."""
|
||||||
|
client = admin_assignees.client
|
||||||
|
ada, bo = await _create_children(client, ("Ada", "Bo"))
|
||||||
|
assert ada == 1
|
||||||
|
existing_id = await _create_definition(client, [bo])
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
"/api/v1/admin/quest-definitions",
|
||||||
|
json={
|
||||||
|
"title": "Tidy the den",
|
||||||
|
"rule": {"rule_type": "daily"},
|
||||||
|
"assignee_child_ids": [coercible],
|
||||||
|
"windows": ["morning"],
|
||||||
|
},
|
||||||
|
headers=_admin_headers(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
|
bundles = await _quest_definitions.list_active_definitions(
|
||||||
|
admin_assignees.database
|
||||||
|
)
|
||||||
|
assert [bundle.definition.id for bundle in bundles] == [existing_id]
|
||||||
|
assert await _stored_assignee_ids(
|
||||||
|
admin_assignees.database, existing_id
|
||||||
|
) == {bo}
|
||||||
|
|
||||||
|
|
||||||
async def test_rejected_assignees_on_unknown_definition_is_404(
|
async def test_rejected_assignees_on_unknown_definition_is_404(
|
||||||
admin_assignees: SimpleNamespace,
|
admin_assignees: SimpleNamespace,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user