Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions test/test_api_v3_non_finite_numbers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
"""Non-finite JSON numbers must be rejected, not raise.

json.loads accepts Infinity/-Infinity/NaN by default (they are not valid JSON,
but Python's parser emits them) and Flask's get_json passes them straight
through. int(float('inf')) raises OverflowError, which is neither ValueError
nor TypeError -- so validation blocks that carefully caught those let it
through and Flask turned it into a 500.

The damage was not the status code. /config/dim-schedule answered with
CONFIG_SAVE_FAILED and suggested "Check file permissions on config directory"
and "Check available disk space" for what was actually an invalid number.

NaN already returned 400 (int(nan) raises ValueError), which is why this only
showed up for the infinities.
"""
import sys
from pathlib import Path

import pytest

sys.path.insert(0, str(Path(__file__).parent.parent))

from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402


#: (route, field) that returned 500 before OverflowError was caught. Both
#: infinity signs are exercised: int() raises OverflowError for either, but
#: only one of them was in the original report, and a guard that special-cased
#: the sign would pass a one-sided test.
NON_FINITE_ROUTES = [
('/api/v3/config/dim-schedule', 'dim_brightness'),
('/api/v3/errors/clear', 'max_age_hours'),
('/api/v3/config/main', 'multiplexing'),
('/api/v3/config/main', 'row_address_type'),
]
NON_FINITE_CASES = [
(route, '{"%s": %s}' % (field, literal))
for route, field in NON_FINITE_ROUTES
for literal in ('Infinity', '-Infinity')
]


@pytest.mark.parametrize("route,body", NON_FINITE_CASES)
def test_infinity_is_a_client_error_not_a_server_error(api_v3_client, route, body):
"""Exactly 400, not merely "some 4xx".

Accepting any 4xx would let a 404 pass, so renaming one of these routes
would leave the test green while testing nothing -- the failure mode this
whole file exists to catch.
"""
response = api_v3_client.post(route, data=body, content_type='application/json')
assert response.status_code == 400, (
f"{route} with {body} answered {response.status_code}; expected 400"
)


@pytest.mark.parametrize("route,body", [
('/api/v3/config/dim-schedule', '{"dim_brightness": NaN}'),
('/api/v3/errors/clear', '{"max_age_hours": NaN}'),
])
def test_nan_is_also_a_client_error(api_v3_client, route, body):
"""int(nan) raises ValueError so this path already worked -- pinned so a
refactor that narrows the except tuple cannot quietly break it."""
response = api_v3_client.post(route, data=body, content_type='application/json')
assert response.status_code == 400


def test_a_valid_number_is_accepted(api_v3_client, api_v3_module, monkeypatch):
"""Prove the widened except did not start swallowing ordinary input.

Asserting "not a 400" would not show that: the mocked save path fails for
any input, so the assertion would hold even if validation had rejected the
value. Give load_config a real dict and stub the atomic save, and the
endpoint reaches its success response -- which only happens if 30 passed
validation.
"""
api_v3_module.api_v3.config_manager.load_config.return_value = {}
monkeypatch.setattr(api_v3_module, '_save_config_atomic',
lambda *a, **k: (True, ''))
response = api_v3_client.post(
'/api/v3/config/dim-schedule',
data='{"dim_brightness": 30}',
content_type='application/json',
)
assert response.status_code == 200, response.get_data(as_text=True)[:200]
54 changes: 27 additions & 27 deletions web_interface/blueprints/api_v3.py
Original file line number Diff line number Diff line change
Expand Up @@ -597,7 +597,7 @@ def save_dim_schedule_config():
dim_brightness = 30
else:
dim_brightness = int(dim_brightness_raw)
except (ValueError, TypeError):
except (ValueError, TypeError, OverflowError):
return error_response(
ErrorCode.VALIDATION_ERROR,
"dim_brightness must be an integer between 0 and 100",
Expand Down Expand Up @@ -797,7 +797,7 @@ def save_main_config():
}), 400
try:
target_fps = int(raw_target_fps)
except (ValueError, TypeError):
except (ValueError, TypeError, OverflowError):
return jsonify({
'status': 'error',
'message': "Invalid value for target_fps: must be an integer"
Expand Down Expand Up @@ -867,7 +867,7 @@ def save_main_config():
mux_val = int(data['multiplexing'])
if mux_val < 0 or mux_val > 22:
return jsonify({'status': 'error', 'message': f"Invalid multiplexing value '{data['multiplexing']}'. Must be an integer from 0 to 22."}), 400
except (ValueError, TypeError):
except (ValueError, TypeError, OverflowError):
return jsonify({'status': 'error', 'message': f"Invalid multiplexing value '{data['multiplexing']}'. Must be an integer from 0 to 22."}), 400

# Validate pixel_mapper_config (free-form mapper string, e.g. "U-mapper;Rotate:90")
Expand All @@ -885,7 +885,7 @@ def save_main_config():
rat_val = int(data['row_address_type'])
if rat_val < 0 or rat_val > 4:
return jsonify({'status': 'error', 'message': f"Invalid row_address_type '{data['row_address_type']}'. Must be an integer from 0 to 4."}), 400
except (ValueError, TypeError):
except (ValueError, TypeError, OverflowError):
return jsonify({'status': 'error', 'message': f"Invalid row_address_type '{data['row_address_type']}'. Must be an integer from 0 to 4."}), 400

# Handle hardware settings
Expand All @@ -910,7 +910,7 @@ def save_main_config():
if rp1_val not in (0, 1):
return jsonify({'status': 'error', 'message': "rp1_rio must be 0 (PIO) or 1 (RIO)"}), 400
current_config['display']['runtime']['rp1_rio'] = rp1_val
except (ValueError, TypeError):
except (ValueError, TypeError, OverflowError):
return jsonify({'status': 'error', 'message': "rp1_rio must be 0 or 1"}), 400

# Handle checkboxes - coerce to bool to ensure proper JSON types
Expand Down Expand Up @@ -963,7 +963,7 @@ def _copies_fits_hardware(copies: int) -> Optional[str]:
copies = None
try:
copies = int(data['double_sided_copies'])
except (ValueError, TypeError):
except (ValueError, TypeError, OverflowError):
if enabled:
return jsonify({'status': 'error', 'message': "Double-sided copies must be an integer"}), 400
if copies is not None and not (2 <= copies <= 8):
Expand Down Expand Up @@ -1036,7 +1036,7 @@ def _copies_fits_hardware(copies: int) -> Optional[str]:
if data.get('vegas_extend_threshold_screens') not in ('', None):
try:
screens = float(data['vegas_extend_threshold_screens'])
except (ValueError, TypeError):
except (ValueError, TypeError, OverflowError):
return jsonify({
'status': 'error',
'message': "Invalid value for vegas_extend_threshold_screens: "
Expand All @@ -1053,7 +1053,7 @@ def _copies_fits_hardware(copies: int) -> Optional[str]:
if data.get('vegas_max_plugin_width_ratio') not in ('', None):
try:
ratio = float(data['vegas_max_plugin_width_ratio'])
except (ValueError, TypeError):
except (ValueError, TypeError, OverflowError):
return jsonify({
'status': 'error',
'message': "Invalid value for vegas_max_plugin_width_ratio: "
Expand Down Expand Up @@ -1101,7 +1101,7 @@ def _copies_fits_hardware(copies: int) -> Optional[str]:
continue
try:
int_value = int(raw_value)
except (ValueError, TypeError):
except (ValueError, TypeError, OverflowError):
return jsonify({
'status': 'error',
'message': f"Invalid value for {field_name}: must be an integer"
Expand Down Expand Up @@ -1153,7 +1153,7 @@ def _copies_fits_hardware(copies: int) -> Optional[str]:
if not (1024 <= port_val <= 65535):
return jsonify({'status': 'error', 'message': "sync_port must be between 1024 and 65535"}), 400
current_config['sync']['port'] = port_val
except (ValueError, TypeError):
except (ValueError, TypeError, OverflowError):
return jsonify({'status': 'error', 'message': "sync_port must be an integer"}), 400

if "sync_follower_position" in data:
Expand Down Expand Up @@ -1197,7 +1197,7 @@ def _copies_fits_hardware(copies: int) -> Optional[str]:
raw_value = data.pop(field)
try:
int_value = int(raw_value)
except (ValueError, TypeError):
except (ValueError, TypeError, OverflowError):
return jsonify({'status': 'error',
'message': f"Invalid duration for {field}: must be an integer"}), 400
current_config['display']['display_durations'][field] = int_value
Expand All @@ -1220,7 +1220,7 @@ def _copies_fits_hardware(copies: int) -> Optional[str]:
continue
try:
int_value = int(raw_value)
except (ValueError, TypeError):
except (ValueError, TypeError, OverflowError):
return jsonify({'status': 'error',
'message': f"Invalid duration for mode '{mode_key}': must be an integer"}), 400
current_config['display']['display_durations'][mode_key] = int_value
Expand Down Expand Up @@ -5118,7 +5118,7 @@ def fix_array_structures(config_dict, schema_props, prefix=''):
converted_array.append(int(v))
else:
converted_array.append(float(v))
except (ValueError, TypeError):
except (ValueError, TypeError, OverflowError):
converted_array.append(v)
else:
converted_array.append(v)
Expand All @@ -5143,7 +5143,7 @@ def fix_array_structures(config_dict, schema_props, prefix=''):
converted_array.append(int(v))
else:
converted_array.append(float(v))
except (ValueError, TypeError):
except (ValueError, TypeError, OverflowError):
converted_array.append(v)
else:
converted_array.append(v)
Expand Down Expand Up @@ -5180,7 +5180,7 @@ def fix_array_structures(config_dict, schema_props, prefix=''):
converted_array.append(int(v))
else:
converted_array.append(float(v))
except (ValueError, TypeError):
except (ValueError, TypeError, OverflowError):
converted_array.append(v)
else:
converted_array.append(v)
Expand All @@ -5204,7 +5204,7 @@ def fix_array_structures(config_dict, schema_props, prefix=''):
converted_array.append(int(v))
else:
converted_array.append(float(v))
except (ValueError, TypeError):
except (ValueError, TypeError, OverflowError):
converted_array.append(v)
else:
converted_array.append(v)
Expand Down Expand Up @@ -5371,7 +5371,7 @@ def _fix_json_arrays(cfg, props):
if isinstance(v, str):
try:
converted.append(int(v) if item_type == 'integer' else float(v))
except (ValueError, TypeError):
except (ValueError, TypeError, OverflowError):
converted.append(v)
else:
converted.append(v)
Expand Down Expand Up @@ -5496,7 +5496,7 @@ def normalize_config_values(config, schema_props, prefix=''):
try:
normalized[key] = int(value_stripped)
continue
except (ValueError, TypeError):
except (ValueError, TypeError, OverflowError):
pass
elif isinstance(value, (int, float)):
normalized[key] = int(value)
Expand All @@ -5514,7 +5514,7 @@ def normalize_config_values(config, schema_props, prefix=''):
try:
normalized[key] = float(value_stripped)
continue
except (ValueError, TypeError):
except (ValueError, TypeError, OverflowError):
pass
elif isinstance(value, (int, float)):
normalized[key] = float(value)
Expand Down Expand Up @@ -5569,7 +5569,7 @@ def normalize_config_values(config, schema_props, prefix=''):
try:
normalized_array.append(int(v))
continue
except (ValueError, TypeError):
except (ValueError, TypeError, OverflowError):
pass
elif isinstance(v, (int, float)):
normalized_array.append(int(v))
Expand All @@ -5579,7 +5579,7 @@ def normalize_config_values(config, schema_props, prefix=''):
try:
normalized_array.append(float(v))
continue
except (ValueError, TypeError):
except (ValueError, TypeError, OverflowError):
pass
elif isinstance(v, (int, float)):
normalized_array.append(float(v))
Expand All @@ -5595,7 +5595,7 @@ def normalize_config_values(config, schema_props, prefix=''):
if isinstance(v, str):
try:
normalized_array.append(int(v))
except (ValueError, TypeError):
except (ValueError, TypeError, OverflowError):
normalized_array.append(v)
elif isinstance(v, (int, float)):
normalized_array.append(int(v))
Expand All @@ -5609,7 +5609,7 @@ def normalize_config_values(config, schema_props, prefix=''):
if isinstance(v, str):
try:
normalized_array.append(float(v))
except (ValueError, TypeError):
except (ValueError, TypeError, OverflowError):
normalized_array.append(v)
else:
normalized_array.append(v)
Expand All @@ -5632,7 +5632,7 @@ def normalize_config_values(config, schema_props, prefix=''):
if isinstance(value, str):
try:
normalized[key] = int(value)
except (ValueError, TypeError):
except (ValueError, TypeError, OverflowError):
normalized[key] = value
else:
normalized[key] = value
Expand All @@ -5641,7 +5641,7 @@ def normalize_config_values(config, schema_props, prefix=''):
if isinstance(value, str):
try:
normalized[key] = float(value)
except (ValueError, TypeError):
except (ValueError, TypeError, OverflowError):
normalized[key] = value
else:
normalized[key] = value
Expand Down Expand Up @@ -6779,7 +6779,7 @@ def get_font_preview() -> tuple[Response, int] | Response:
# Safe integer parsing for size
try:
size = int(request.args.get('size', 12))
except (ValueError, TypeError):
except (ValueError, TypeError, OverflowError):
return jsonify({'status': 'error', 'message': 'Invalid font size'}), 400

if not font_filename:
Expand Down Expand Up @@ -8360,7 +8360,7 @@ def clear_old_errors():
context={'provided_value': raw_max_age},
status_code=400
)
except (ValueError, TypeError):
except (ValueError, TypeError, OverflowError):
return error_response(
error_code=ErrorCode.INVALID_INPUT,
message="max_age_hours must be a valid integer",
Expand Down
Loading