diff --git a/test/test_api_v3_non_finite_numbers.py b/test/test_api_v3_non_finite_numbers.py new file mode 100644 index 00000000..4e6c217b --- /dev/null +++ b/test/test_api_v3_non_finite_numbers.py @@ -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] diff --git a/web_interface/blueprints/api_v3.py b/web_interface/blueprints/api_v3.py index 958f5f0c..a5a2979f 100644 --- a/web_interface/blueprints/api_v3.py +++ b/web_interface/blueprints/api_v3.py @@ -598,7 +598,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", @@ -798,7 +798,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" @@ -868,7 +868,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") @@ -886,7 +886,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 @@ -911,7 +911,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 @@ -964,7 +964,7 @@ def save_main_config(): 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): @@ -1037,7 +1037,7 @@ def save_main_config(): 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: " @@ -1054,7 +1054,7 @@ def save_main_config(): 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: " @@ -1102,7 +1102,7 @@ def save_main_config(): 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" @@ -1154,7 +1154,7 @@ def save_main_config(): 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: @@ -1198,7 +1198,7 @@ def save_main_config(): 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 @@ -1221,7 +1221,7 @@ def save_main_config(): 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 @@ -5122,7 +5122,7 @@ def save_plugin_config(): 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) @@ -5147,7 +5147,7 @@ def save_plugin_config(): 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) @@ -5184,7 +5184,7 @@ def save_plugin_config(): 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) @@ -5208,7 +5208,7 @@ def save_plugin_config(): 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) @@ -5375,7 +5375,7 @@ def save_plugin_config(): 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) @@ -5500,7 +5500,7 @@ def save_plugin_config(): try: normalized[key] = int(value_stripped) continue - except (ValueError, TypeError): + except (ValueError, TypeError, OverflowError): pass elif isinstance(value, (int, float)): normalized[key] = int(value) @@ -5518,7 +5518,7 @@ def save_plugin_config(): try: normalized[key] = float(value_stripped) continue - except (ValueError, TypeError): + except (ValueError, TypeError, OverflowError): pass elif isinstance(value, (int, float)): normalized[key] = float(value) @@ -5573,7 +5573,7 @@ def save_plugin_config(): 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)) @@ -5583,7 +5583,7 @@ def save_plugin_config(): 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)) @@ -5599,7 +5599,7 @@ def save_plugin_config(): 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)) @@ -5613,7 +5613,7 @@ def save_plugin_config(): 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) @@ -5636,7 +5636,7 @@ def save_plugin_config(): if isinstance(value, str): try: normalized[key] = int(value) - except (ValueError, TypeError): + except (ValueError, TypeError, OverflowError): normalized[key] = value else: normalized[key] = value @@ -5645,7 +5645,7 @@ def save_plugin_config(): if isinstance(value, str): try: normalized[key] = float(value) - except (ValueError, TypeError): + except (ValueError, TypeError, OverflowError): normalized[key] = value else: normalized[key] = value @@ -6788,7 +6788,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: @@ -8369,7 +8369,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",