Files
bambuddy/backend/tests/unit/test_spoolman_settings_value_coercion.py
T
maziggy 88dc56d6e1 Security hardening (maziggy/bambuddy-security #7)
fix(settings): accept JSON booleans on the Spoolman settings endpoint
2026-07-30 13:52:56 +02:00

164 lines
6.3 KiB
Python

"""PUT /settings/spoolman must not 500 on a JSON boolean.
The endpoint takes a free-form ``dict`` body, and settings are persisted in a
VARCHAR column that every reader compares as a string. Sending the natural JSON
form — ``{"spoolman_enabled": true}`` — used to fail twice over:
- ``bool.lower()`` raised AttributeError while deciding whether the mode had
changed, surfacing as an opaque 500;
- the raw bool was handed to ``upsert_setting``, which SQLite silently coerces
to 1/0 while asyncpg rejects it — so the stored representation depended on
the deployment's database.
The shipped UI sends strings, so this was reachable only through the REST API
(scripts, Home Assistant ``rest_command``) — which is exactly where a JSON
boolean is the obvious thing to send.
These tests cover the normalisers directly. They are pure functions, so the
matrix stays readable and the endpoint keeps a single code path per field.
"""
from __future__ import annotations
import pytest
from fastapi import HTTPException
from backend.app.api.routes.settings import (
normalize_bool_setting,
normalize_str_setting,
setting_is_true,
)
# ---------------------------------------------------------------------------
# The reported crash
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(("value", "expected"), [(True, "true"), (False, "false")])
def test_json_booleans_are_accepted_and_canonicalised(value: bool, expected: str):
"""The exact input that used to 500."""
assert normalize_bool_setting("spoolman_enabled", value) == expected
@pytest.mark.parametrize(("value", "expected"), [(1, "true"), (0, "false")])
def test_json_numbers_one_and_zero_are_accepted(value: int, expected: str):
assert normalize_bool_setting("spoolman_enabled", value) == expected
# ---------------------------------------------------------------------------
# String spellings — generous on purpose, this is a documented REST surface
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("value", ["true", "TRUE", "True", " true ", "1", "yes", "on", "ON"])
def test_truthy_spellings(value: str):
assert normalize_bool_setting("auto_add_unknown_rfid", value) == "true"
@pytest.mark.parametrize("value", ["false", "FALSE", "False", " false ", "0", "no", "off"])
def test_falsy_spellings(value: str):
assert normalize_bool_setting("auto_add_unknown_rfid", value) == "false"
def test_python_style_capitalised_true_is_normalised_lowercase():
"""The frontend compares with a case-sensitive ``=== 'true'``.
A client sending "True" previously had it stored verbatim, so the UI
rendered the setting as OFF while every backend reader (which all use
``.lower()``) treated it as ON.
"""
assert normalize_bool_setting("spoolman_enabled", "True") == "true"
# ---------------------------------------------------------------------------
# Empty means "use the default" — deliberately NOT normalised to "false"
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("value", ["", " "])
def test_empty_is_preserved_not_turned_into_false(value: str):
"""get_spoolman_settings reads these with ``or "<default>"``.
spoolman_report_partial_usage and auto_add_unknown_rfid default to ON, so
coercing a blank submission to "false" would silently switch them off.
Whitespace-only collapses to "" so it takes the same path rather than
being stored as a truthy-but-meaningless " ".
"""
assert normalize_bool_setting("spoolman_report_partial_usage", value) == ""
# ---------------------------------------------------------------------------
# Values with no sensible reading get a 400 naming the field, not a 500
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("value", ["banana", "maybe", "2", "-1", None, [], {}, 3.5, 7])
def test_uninterpretable_values_raise_400_naming_the_field(value: object):
with pytest.raises(HTTPException) as exc:
normalize_bool_setting("spoolman_enabled", value)
assert exc.value.status_code == 400
assert "spoolman_enabled" in str(exc.value.detail)
# ---------------------------------------------------------------------------
# String settings
# ---------------------------------------------------------------------------
def test_str_setting_passes_strings_through_untouched():
assert normalize_str_setting("spoolman_url", "http://192.168.1.5:7912/") == "http://192.168.1.5:7912/"
def test_str_setting_stringifies_numbers():
"""An unquoted host or port is a plausible client slip, not a hard error."""
assert normalize_str_setting("spoolman_url", 7912) == "7912"
def test_str_setting_maps_null_to_empty():
assert normalize_str_setting("spoolman_url", None) == ""
@pytest.mark.parametrize("value", [{"a": 1}, ["x"]])
def test_str_setting_refuses_containers_rather_than_storing_a_repr(value: object):
with pytest.raises(HTTPException) as exc:
normalize_str_setting("spoolman_url", value)
assert exc.value.status_code == 400
# ---------------------------------------------------------------------------
# setting_is_true — used for the mode-switch comparison
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
("stored", "expected"),
[
("true", True),
("True", True),
("TRUE", True),
(" true ", True),
("false", False),
("", False),
("banana", False),
(None, False), # setting absent from the table
(True, True), # legacy row: SQLite coerced a raw bool into the column
(False, False),
],
)
def test_setting_is_true(stored: object, expected: bool):
assert setting_is_true(stored) is expected
@pytest.mark.parametrize("stored", ["1", "on", "yes"])
def test_setting_is_true_stays_narrower_than_the_write_path(stored: str):
"""Reading must agree with the rest of the codebase, which only accepts "true".
normalize_bool_setting is generous about what clients may *send*; every
reader (spoolman_tracking, filament_deficit, inventory, spoolbuddy, labels,
main) compares ``.lower() == "true"``. Accepting more here would make the
mode-switch check disagree with them about a legacy row.
"""
assert setting_is_true(stored) is False