* fix(test): repair test infrastructure and mock fixtures
- Add test/__init__.py for proper test collection
- Fix ConfigManager instantiation to use config_path parameter
- Route schedule config through config_service mock
- Update mock to match get_raw_file_content endpoint change
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(test): correct get_main_config assertion per CodeRabbit review
The endpoint calls load_config(), not get_raw_file_content('main').
Also set up load_config mock return value in the fixture so the
test's data assertions pass correctly.
Co-Authored-By: 5ymb01 <noreply@github.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(test): correct plugin config test mock structure and schema returns
- Plugin configs live at top-level keys, not under 'plugins' subkey
- Mock schema_manager.generate_default_config to return a dict
- Mock schema_manager.merge_with_defaults to merge dicts (not MagicMock)
- Fixes test_get_plugin_config returning 500 due to non-serializable MagicMock
Co-Authored-By: 5ymb01 <noreply@github.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(test): use patch.object for config_service.get_config in schedule tests
config_service.get_config is a real method, not a mock — can't set
return_value on it directly. Use patch.object context manager instead.
Co-Authored-By: 5ymb01 <noreply@github.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: 5ymb01 <5ymb01@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: 5ymb01 <noreply@github.com>
Replace 6 bare `except:` blocks with targeted exception types:
- logo_downloader.py: OSError for file removal, (OSError, IOError) for font loading
- layout_manager.py: (ValueError, TypeError, KeyError, IndexError) for format string
- app.py: (OSError, ValueError) for CPU temp, (SubprocessError, OSError) for systemctl, (KeyError, TypeError, ValueError) for config parsing
Co-authored-by: 5ymb01 <5ymb01@users.noreply.github.com>
Co-authored-by: 5ymb01 <noreply@github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* fix(security): stop leaking Python tracebacks to HTTP clients
Replace 13 instances where traceback.format_exc() was sent in API
JSON responses (via `details=`, `traceback:`, or `details:` keys).
- 5 error_response(details=traceback.format_exc()) → generic message
- 6 jsonify({'traceback': traceback.format_exc()}) → removed key
- 2 jsonify({'details': error_details}) → logger.error() instead
Tracebacks in debug mode (app.py error handlers) are preserved as
they are guarded by app.debug and expected during development.
Co-Authored-By: 5ymb01 <noreply@github.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(security): sanitize str(e) from client responses, add server-side logging
Address CodeRabbit review findings:
- Replace str(e) in error_response message fields with generic messages
- Replace import logging/traceback + manual format with logger.exception()
- Add logger.exception() to 6 jsonify handlers that were swallowing errors
- All exception details now logged server-side only, not sent to clients
Co-Authored-By: 5ymb01 <noreply@github.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: remove duplicate traceback logging, sanitize secrets config error
Address CodeRabbit nitpicks:
- Remove manual import logging/traceback + logging.error() that duplicated
the logger.exception() call in save_raw_main_config
- Apply same fix to save_raw_secrets_config: replace str(e) in client
response with generic message, use logger.exception() for server-side
Co-Authored-By: 5ymb01 <noreply@github.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: 5ymb01 <5ymb01@users.noreply.github.com>
Co-authored-by: 5ymb01 <noreply@github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* fix(security): use Path.relative_to() for path confinement check
Replace str.startswith() path check with Path.relative_to() in the
plugin file viewer endpoint. startswith() can be bypassed when a
directory name is a prefix of another (e.g., /plugins/foo vs
/plugins/foobar). relative_to() correctly validates containment.
Co-Authored-By: 5ymb01 <noreply@github.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* chore: trigger CodeRabbit review
---------
Co-authored-by: 5ymb01 <5ymb01@users.noreply.github.com>
Co-authored-by: 5ymb01 <noreply@github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* fix(perf): cache fonts in sport base classes to avoid disk I/O per frame
Replace 7 ImageFont.truetype() calls in display methods with cached
self.fonts['detail'] lookups. The 4x6-font.ttf at size 6 is already
loaded once in _load_fonts() — loading it again on every display()
call causes unnecessary disk I/O on each render frame (~30-50 FPS).
Files: sports.py (2), football.py (1), hockey.py (2), basketball.py (1), baseball.py (1)
Co-Authored-By: 5ymb01 <noreply@github.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* chore: trigger CodeRabbit review
---------
Co-authored-by: 5ymb01 <5ymb01@users.noreply.github.com>
Co-authored-by: 5ymb01 <noreply@github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* fix(security): mask secret fields in API responses and extract helpers
GET /config/secrets returned raw API keys in plaintext to the browser.
GET /plugins/config returned merged config including deep-merged secrets.
POST /plugins/config could overwrite existing secrets with empty strings
when the GET endpoint returned masked values that were sent back unchanged.
Changes:
- Add src/web_interface/secret_helpers.py with reusable functions:
find_secret_fields, separate_secrets, mask_secret_fields,
mask_all_secret_values, remove_empty_secrets
- GET /config/secrets: mask all values with '••••••••'
- GET /plugins/config: mask x-secret fields with ''
- POST /plugins/config: filter empty-string secrets before saving
- pages_v3: mask secrets before rendering plugin config templates
- Remove three duplicated inline find_secret_fields/separate_secrets
definitions in api_v3.py (replaced by single imported module)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(security): harden secret masking against CodeRabbit findings
- Fail-closed: return 500 when schema unavailable instead of leaking secrets
- Fix falsey masking: use `is not None and != ''` instead of truthiness check
so values like 0 or False are still redacted
- Add array-item secret support: recurse into `type: array` items schema
to detect and mask secrets like accounts[].token
- pages_v3: fail-closed when schema properties missing
Addresses CodeRabbit findings on PR #276:
- Critical: fail-closed bypass when schema_mgr/schema missing
- Major: falsey values not masked (0, False leak through)
- Major: pages_v3 fail-open when schema absent
- Major: array-item secrets unsupported
Co-Authored-By: 5ymb01 <5ymb01@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: 5ymb01 <5ymb01@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* fix(web): remove shadowed sys import in plugin action handler
Two `import sys` statements inside execute_plugin_action() and
authenticate_spotify() shadowed the module-level import, causing
"cannot access local variable 'sys'" errors when sys.executable
was referenced in earlier branches of the same function.
Also fixes day number validation in the of-the-day upload endpoint
to accept 366 (leap year).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(api): correct validation message from 1-365 to 1-366
The JSON structure validation message still said '1-365' while the
actual range check accepts 1-366 for leap years. Make all three
validation messages consistent.
Addresses CodeRabbit finding on PR #280.
Co-Authored-By: 5ymb01 <5ymb01@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: 5ymb01 <5ymb01@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
The secrets template used "weather" as the key, but the weather plugin's
ID is "ledmatrix-weather". Since ConfigManager deep-merges secrets into
the main config by key, secrets under "weather" never reached the plugin
config at config["ledmatrix-weather"], making the API key invisible to
the plugin.
Co-authored-by: 5ymb01 <5ymb01@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>